From 8ee532dd50842dcc161fbc80c126648901c8d693 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 5 Oct 2026 17:35:41 +0530 Subject: [PATCH 1/2] Wait for the module to finish starting before configuring it The module claims its bus name while its setup is still running, and the loader refuses a reconfiguration until that setup returns. Host::load reconfigured as soon as the name appeared, so task_live could fail at once with "module must be ready before reinitialization" (seen on macOS). It now waits for the loader to report the module ready, and names the state the module stopped in when it never gets there. --- crates/tinycomputer-examples/src/host/mod.rs | 45 +++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/tinycomputer-examples/src/host/mod.rs b/crates/tinycomputer-examples/src/host/mod.rs index ec600fc4..4a54cb69 100644 --- a/crates/tinycomputer-examples/src/host/mod.rs +++ b/crates/tinycomputer-examples/src/host/mod.rs @@ -18,7 +18,12 @@ use std::{ use base64::Engine as _; use serde::de::DeserializeOwned; use serde_json::{Value, json}; -use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; +use tinybus::{ + Connection, + broker::Broker, + module::{ModuleHost, ModuleState}, + transport::memory::MemoryBus, +}; use tinycomputer_bus::agent::{ AgentResponse, AwaitTaskRequest, Capabilities, ContinueTaskRequest, PlanTaskRequest, StartTaskRequest, TaskId, TaskPlan, TaskRef, TaskReport, TaskReportRequest, TaskView, @@ -143,6 +148,7 @@ impl Host { } let client = Connection::connect(bus.connect().await?).await?; wait_for_module(&client).await?; + wait_until_ready(&module_host).await?; client.reinitialize_module("tinycomputer", config).await?; // A flow drives a real application for minutes; the bus default is // sized for single commands. @@ -497,6 +503,43 @@ async fn wait_for_module(client: &Connection) -> Result<(), LabError> { Ok(()) } +/// Waits until the loader reports the module ready for calls. The module +/// claims its bus name while its setup is still running, and the loader +/// refuses a reconfiguration until that setup has returned. +async fn wait_until_ready(module_host: &ModuleHost) -> Result<(), LabError> { + tokio::time::timeout(std::time::Duration::from_secs(30), async { + loop { + let state = module_host + .list() + .into_iter() + .find(|info| info.name == "tinycomputer") + .map(|info| info.state); + match state { + Some(ModuleState::Ready | ModuleState::Serving) => return Ok(()), + None + | Some( + ModuleState::Discovered | ModuleState::Resolved | ModuleState::Initializing, + ) => tokio::time::sleep(std::time::Duration::from_millis(20)).await, + Some( + state @ (ModuleState::Rejected { .. } + | ModuleState::Unresolved { .. } + | ModuleState::Faulted { .. } + | ModuleState::Failed { .. } + | ModuleState::Stopped + | ModuleState::Disabled), + ) => { + return Err(io::Error::other(format!( + "tinycomputer did not start: {state:?}" + ))); + } + } + } + }) + .await + .map_err(|_| io::Error::other("timed out waiting for tinycomputer to finish starting"))??; + Ok(()) +} + fn verify_allowlisted(module: &Path) -> Result<(), LabError> { let file_name = module .file_name() From 1f67db20083d6696435039976b6b06525cbc35e8 Mon Sep 17 00:00:00 2001 From: Shanu Date: Mon, 5 Oct 2026 17:35:41 +0530 Subject: [PATCH 2/2] Let task_live run on Tiny Humans' routes and show its browser TINYHUMANS_TOKEN, a Tiny Humans bearer (a session token, or an API key with the inference scope), sends Jev and the planner through Tiny Humans' routes in place of OPENROUTER_API_KEY. The gateway refuses the engine's OpenRouter model ids, so agentic-v1 plans, rescues and shapes unless a model variable names another. Sage still takes the decisions on either route when asked. TASK_HEADED=1 shows the browser the task launches, for a host run someone wants to watch. The routes are read through a variable lookup, so they are tested without touching the process environment. --- .env.example | 8 ++ .../src/bin/task_live/main.rs | 109 ++++++++++++++---- .../src/bin/task_live/main_tests.rs | 91 +++++++++++++++ .../tinycomputer-examples/live-tasks.md | 11 +- .../running-the-examples.md | 1 + 5 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 crates/tinycomputer-examples/src/bin/task_live/main_tests.rs diff --git a/.env.example b/.env.example index 598e59a3..311a10db 100644 --- a/.env.example +++ b/.env.example @@ -58,8 +58,16 @@ TINYCOMPUTER_LAB_SELF_EMAIL= # TINYCOMPUTER_BROWSER_ARGS=--no-first-run # TINYCOMPUTER_BROWSER_USER_AGENT= # TINYCOMPUTER_BROWSER_ENDPOINT=http://127.0.0.1:9222 +# task_live: 1 shows the browser the task launches instead of running it +# headless (a headed run needs a display, so it runs on the host). +# TASK_HEADED=1 # The travel fixture's address for browser_fixture. # TINYCOMPUTER_FIXTURE_URL=http://127.0.0.1:8000 +# task_live: a Tiny Humans bearer (a session token, or an API key with the +# `inference` scope) in place of OPENROUTER_API_KEY; Jev and the planner then +# go through Tiny Humans' routes, and the gateway's agentic-v1 plans, rescues, +# and shapes unless the model variables below name another. +# TINYHUMANS_TOKEN= # The planner model task_live asks for. # TINYCOMPUTER_PLANNER_MODEL= # The reasoning model task_live rescues a failed step with (default diff --git a/crates/tinycomputer-examples/src/bin/task_live/main.rs b/crates/tinycomputer-examples/src/bin/task_live/main.rs index a28f9967..36287b5d 100644 --- a/crates/tinycomputer-examples/src/bin/task_live/main.rs +++ b/crates/tinycomputer-examples/src/bin/task_live/main.rs @@ -12,6 +12,11 @@ //! - `TINYCOMPUTER_MODULE` — the attested module, as `scripts/build-module` //! prints it (default `$CARGO_TARGET_DIR/lab/`, or `target/lab/`). //! - `OPENROUTER_API_KEY` — Jev and the planner. +//! - `TINYHUMANS_TOKEN` — optional, in place of `OPENROUTER_API_KEY`: a Tiny +//! Humans bearer (a session token, or an API key with the `inference` +//! scope) that sends Jev and the planner through Tiny Humans' routes, with +//! the gateway's `agentic-v1` planning, rescuing, and shaping unless the +//! model variables below name another. //! - `TASK_FILE` — the task in plain language. //! - `FACTS_FILE` — a JSON object of facts for the task, by name. A value is //! a string, or `{"value": "...", "secret": true}` to keep it secret; a @@ -22,8 +27,9 @@ //! - `OUTPUT_FILE` — optional: a JSON `TaskOutput` (`instructions` and a //! `schema`) asking for the answer in a fixed shape; the result is written //! to `result.json`. -//! - `TINYCOMPUTER_OUTPUT_MODEL` — optional: the `OpenRouter` model that -//! shapes it (`openai/gpt-6-luna` by default). +//! - `TINYCOMPUTER_OUTPUT_MODEL` — optional: the model that shapes it +//! (`openai/gpt-6-luna` by default on `OpenRouter`, `agentic-v1` on Tiny +//! Humans). //! - `TASK_SURFACE` — optional: `browser` (default) or `desktop`, the //! applications on this Mac through the accessibility tree. A desktop task //! runs on the host, in a shell that has the Accessibility permission. @@ -33,12 +39,14 @@ //! - `TASK_MAX_MINUTES` — optional: cancel the task after this long (20). //! - `TASK_RESCUES` — optional: how many failed steps the reasoning model //! may rescue (0 to 5, default 5; 0 turns rescues off). -//! - `TINYCOMPUTER_RESCUE_MODEL` — optional: the `OpenRouter` model that -//! rescues them (`openai/gpt-6-luna` by default). +//! - `TINYCOMPUTER_RESCUE_MODEL` — optional: the model that rescues them +//! (`openai/gpt-6-luna` by default on `OpenRouter`, `agentic-v1` on Tiny +//! Humans). //! - `TINYCOMPUTER_DECISIONS` — optional: `sage` makes Levanto Sage take //! every decision in place of Jev, with `SAGE_API_KEY`, through the //! module's `jev` configuration; `SAGE_FAST=1` scores each choice in one -//! pass. The planner and the rescuer still use `OPENROUTER_API_KEY`. +//! pass. The planner and the rescuer keep their own route +//! (`OPENROUTER_API_KEY`, or `TINYHUMANS_TOKEN`). //! - `TINYCOMPUTER_BROWSER_EXECUTABLE`, `TINYCOMPUTER_BROWSER_USER_AGENT`, and //! `TINYCOMPUTER_BROWSER_ARGS` (space-separated) — how the browser //! launches, and `TINYCOMPUTER_BROWSER_PERCEPTION` (`sight` or `tree`) how @@ -48,13 +56,17 @@ //! `tinycomputer-cursor-overlay` helper, which the module finds beside //! itself, over a browser window on this screen — an attached Chrome, or a //! headed one. +//! - `TASK_HEADED` — optional: `1` shows the browser the task launches +//! instead of running it headless. A headed browser needs a display, so +//! such a run is on the host. //! - `TINYCOMPUTER_BROWSER_ENDPOINT` — attach to a running Chrome instead //! (`http://127.0.0.1:9222`); booking sites turn away a fresh headless //! browser but serve a person's own. Closing the run only disconnects. //! -//! Launching a browser runs in the Docker lab, never on the host: +//! Launching a headless browser runs in the Docker lab, never on the host: //! `scripts/docker-lab -- crates/tinycomputer-examples/tasks/run kashmir`. -//! Attaching to your own Chrome runs on the host, since that is where it is. +//! A headed launch (`TASK_HEADED=1`) and attaching to your own Chrome run on +//! the host, since that is where the display and the browser are. use std::collections::BTreeMap; use std::path::PathBuf; @@ -65,7 +77,7 @@ use tinycomputer_bus::Flow; use tinycomputer_bus::agent::{ PlanTaskRequest, StartTaskRequest, SurfaceKind, TaskBudget, TaskConstraints, TaskOutput, }; -use tinycomputer_examples::host::{Host, LabError, jev_config, module_path, openrouter_key}; +use tinycomputer_examples::host::{Host, LabError, jev_config, module_path}; use tinycomputer_examples::task::{conclude, follow, passed}; #[tokio::main] @@ -99,6 +111,7 @@ async fn main() -> Result<(), LabError> { constraints: TaskConstraints { surfaces: vec![kind], browser_endpoint: std::env::var("TINYCOMPUTER_BROWSER_ENDPOINT").ok(), + headed: std::env::var("TASK_HEADED").is_ok_and(|value| value == "1"), ..TaskConstraints::default() }, budget: TaskBudget { @@ -139,7 +152,6 @@ async fn main() -> Result<(), LabError> { /// The module's private configuration: Jev, the planner (which also brings /// the rescuer and the output shaper), the cursor, and how browsers launch. fn module_config() -> Result { - let key = openrouter_key()?; let optional = |name: &str| std::env::var(name).ok(); let mut browser = serde_json::Map::new(); for (field, variable) in [ @@ -162,30 +174,76 @@ fn module_config() -> Result { json!(args.split_whitespace().collect::>()), ); } + let (jev, planner) = routes(&|name| std::env::var(name).ok())?; Ok(json!({ - "jev": decisions(&key)?, - "planner": { - "api_key": key, - "model": optional("TINYCOMPUTER_PLANNER_MODEL"), - "rescue_model": optional("TINYCOMPUTER_RESCUE_MODEL"), - "output_model": optional("TINYCOMPUTER_OUTPUT_MODEL"), - }, + "jev": jev, + "planner": planner, "cursor": optional("TASK_CURSOR").unwrap_or_else(|| "natural".to_owned()), "browser": browser, })) } +/// The model the Tiny Humans gateway plans, rescues, and shapes with when +/// none is named: the gateway serves its own model ids, and refuses the +/// engine's `OpenRouter` vendor ids. +const TINY_HUMANS_MODEL: &str = "agentic-v1"; + +/// What this runner calls itself to the Tiny Humans routes. +const SDK_NAME: &str = "tinycomputer-task-live"; + +/// The `jev` and `planner` configurations, from the variables `var` reads: +/// Tiny Humans' routes when `TINYHUMANS_TOKEN` holds a Tiny Humans bearer (a +/// session token, or an API key with the `inference` scope), else +/// `OpenRouter` with `OPENROUTER_API_KEY`. +fn routes(var: &dyn Fn(&str) -> Option) -> Result<(Value, Value), LabError> { + let bearer = var("TINYHUMANS_TOKEN") + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + if let Some(bearer) = bearer { + let model = |name: &str| { + var(name) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| TINY_HUMANS_MODEL.to_owned()) + }; + let jev = json!({ + "api_key": bearer, + "provider": "tiny_humans_open_router", + "sdk_name": SDK_NAME, + }); + let planner = json!({ + "api_key": bearer, + "provider": "tiny_humans", + "sdk_name": SDK_NAME, + "model": model("TINYCOMPUTER_PLANNER_MODEL"), + "rescue_model": model("TINYCOMPUTER_RESCUE_MODEL"), + "output_model": model("TINYCOMPUTER_OUTPUT_MODEL"), + }); + return Ok((decisions(var, jev)?, planner)); + } + let key = var("OPENROUTER_API_KEY").ok_or_else(|| { + std::io::Error::other("neither OPENROUTER_API_KEY nor TINYHUMANS_TOKEN is exported") + })?; + let planner = json!({ + "api_key": key, + "model": var("TINYCOMPUTER_PLANNER_MODEL"), + "rescue_model": var("TINYCOMPUTER_RESCUE_MODEL"), + "output_model": var("TINYCOMPUTER_OUTPUT_MODEL"), + }); + Ok((decisions(var, jev_config(key, None)?)?, planner)) +} + /// Who takes the flow's decisions, as the module's `jev` configuration: -/// Levanto Sage when `TINYCOMPUTER_DECISIONS` is `sage`, else Jev on -/// `OpenRouter` with `key`. -fn decisions(key: &str) -> Result { - if std::env::var("TINYCOMPUTER_DECISIONS").as_deref() == Ok("sage") { - let sage = std::env::var("SAGE_API_KEY") - .map_err(|_| std::io::Error::other("TINYCOMPUTER_DECISIONS=sage needs SAGE_API_KEY"))?; - let fast = std::env::var("SAGE_FAST").is_ok_and(|value| value == "1"); +/// Levanto Sage when `TINYCOMPUTER_DECISIONS` is `sage`, else `jev`. +fn decisions(var: &dyn Fn(&str) -> Option, jev: Value) -> Result { + if var("TINYCOMPUTER_DECISIONS").as_deref() == Some("sage") { + let sage = var("SAGE_API_KEY").ok_or_else(|| { + std::io::Error::other("TINYCOMPUTER_DECISIONS=sage needs SAGE_API_KEY") + })?; + let fast = var("SAGE_FAST").is_some_and(|value| value == "1"); return Ok(json!({"api_key": sage, "provider": "sage", "fast": fast})); } - jev_config(key.to_owned(), None) + Ok(jev) } /// The surface the task runs on, from `TASK_SURFACE`. @@ -257,3 +315,6 @@ async fn plan( std::fs::write(out.join("plan.json"), &text)?; Ok(plan.flow) } + +#[cfg(test)] +mod main_tests; diff --git a/crates/tinycomputer-examples/src/bin/task_live/main_tests.rs b/crates/tinycomputer-examples/src/bin/task_live/main_tests.rs new file mode 100644 index 00000000..a1057ab0 --- /dev/null +++ b/crates/tinycomputer-examples/src/bin/task_live/main_tests.rs @@ -0,0 +1,91 @@ +//! Tests for the `task_live` binary: which routes Jev and the planner take. + +use std::collections::BTreeMap; + +use serde_json::json; +use tinycomputer_examples::host::LabError; + +use super::{TINY_HUMANS_MODEL, routes}; + +/// A variable lookup over `pairs`, in place of the process environment. +fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let variables: BTreeMap = pairs + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(); + move |name| variables.get(name).cloned() +} + +#[test] +fn a_tiny_humans_bearer_sends_jev_and_the_planner_through_tiny_humans() -> Result<(), LabError> { + let (jev, planner) = routes(&lookup(&[ + ("TINYHUMANS_TOKEN", " th-bearer \n"), + ("OPENROUTER_API_KEY", "sk-or-unused"), + ]))?; + assert_eq!(jev["provider"], "tiny_humans_open_router"); + assert_eq!(jev["api_key"], "th-bearer"); + assert_eq!(planner["provider"], "tiny_humans"); + assert_eq!(planner["api_key"], "th-bearer"); + for model in ["model", "rescue_model", "output_model"] { + assert_eq!(planner[model], TINY_HUMANS_MODEL, "{model}"); + } + Ok(()) +} + +#[test] +fn a_named_model_replaces_the_gateway_default() -> Result<(), LabError> { + let (_, planner) = routes(&lookup(&[ + ("TINYHUMANS_TOKEN", "th-bearer"), + ("TINYCOMPUTER_RESCUE_MODEL", "reasoning-v1"), + ("TINYCOMPUTER_OUTPUT_MODEL", " "), + ]))?; + assert_eq!(planner["rescue_model"], "reasoning-v1"); + assert_eq!(planner["model"], TINY_HUMANS_MODEL); + assert_eq!(planner["output_model"], TINY_HUMANS_MODEL); + Ok(()) +} + +#[test] +fn without_a_bearer_jev_and_the_planner_use_openrouter() -> Result<(), LabError> { + let (jev, planner) = routes(&lookup(&[ + ("TINYHUMANS_TOKEN", " "), + ("OPENROUTER_API_KEY", "sk-or-key"), + ("TINYCOMPUTER_PLANNER_MODEL", "anthropic/claude-sonnet-5"), + ]))?; + assert_eq!(jev["provider"], "open_router"); + assert_eq!(planner["api_key"], "sk-or-key"); + assert_eq!(planner["model"], "anthropic/claude-sonnet-5"); + assert!(planner.get("provider").is_none()); + Ok(()) +} + +#[test] +fn without_any_key_the_error_names_both_variables() { + assert!(routes(&lookup(&[])).is_err_and(|error| { + let error = error.to_string(); + error.contains("OPENROUTER_API_KEY") && error.contains("TINYHUMANS_TOKEN") + })); +} + +#[test] +fn sage_takes_the_decisions_on_either_route() -> Result<(), LabError> { + let (jev, planner) = routes(&lookup(&[ + ("TINYHUMANS_TOKEN", "th-bearer"), + ("TINYCOMPUTER_DECISIONS", "sage"), + ("SAGE_API_KEY", "sage-key"), + ("SAGE_FAST", "1"), + ]))?; + assert_eq!( + jev, + json!({"api_key": "sage-key", "provider": "sage", "fast": true}) + ); + assert_eq!(planner["provider"], "tiny_humans"); + assert!( + routes(&lookup(&[ + ("OPENROUTER_API_KEY", "sk-or-key"), + ("TINYCOMPUTER_DECISIONS", "sage"), + ])) + .is_err_and(|error| error.to_string().contains("SAGE_API_KEY")) + ); + Ok(()) +} diff --git a/docs/crates/tinycomputer-examples/live-tasks.md b/docs/crates/tinycomputer-examples/live-tasks.md index 09ea035f..363359e3 100644 --- a/docs/crates/tinycomputer-examples/live-tasks.md +++ b/docs/crates/tinycomputer-examples/live-tasks.md @@ -104,6 +104,7 @@ they control. | Variable | For | |---|---| | `OPENROUTER_API_KEY` | Jev and the planner | +| `TINYHUMANS_TOKEN` | in place of `OPENROUTER_API_KEY`: a Tiny Humans bearer (a session token, or an API key with the `inference` scope) that sends Jev and the planner through Tiny Humans' routes; the gateway's `agentic-v1` plans, rescues, and shapes unless a model variable below names another | | `TINYCOMPUTER_MODULE` | the attested module; `scripts/build-module` prints it (`tasks/run` sets it) | | `TASK_FILE` | the task, in plain language | | `FACTS_FILE` | the JSON facts file described above | @@ -118,8 +119,8 @@ they control. | `TINYCOMPUTER_FLOW_DELIBERATION` | `deep` | `deep`, `standard`, or `off` | | `TASK_MAX_MINUTES` | `20` | the task is cancelled after this long | | `TASK_RESCUES` | `5` | how many failed steps a reasoning model may rescue (`0` turns rescues off); see [rescue](../../rescue.md) | -| `TINYCOMPUTER_RESCUE_MODEL` | `openai/gpt-6-luna` | the OpenRouter model that performs a rescue | -| `TINYCOMPUTER_PLANNER_MODEL` | the engine's default | the OpenRouter model asked to plan the flow | +| `TINYCOMPUTER_RESCUE_MODEL` | `openai/gpt-6-luna` (`agentic-v1` with `TINYHUMANS_TOKEN`) | the model that performs a rescue | +| `TINYCOMPUTER_PLANNER_MODEL` | the engine's default (`agentic-v1` with `TINYHUMANS_TOKEN`) | the model asked to plan the flow | **Optional, the browser:** @@ -130,9 +131,11 @@ they control. | `TINYCOMPUTER_BROWSER_ARGS` | space-separated extra launch arguments | | `TINYCOMPUTER_BROWSER_PERCEPTION` | `sight` (default) or `tree`: how pages are read | | `TINYCOMPUTER_BROWSER_ENDPOINT` | attach to a running Chrome (e.g. `http://127.0.0.1:9222`) instead of launching one | +| `TASK_HEADED` | `1` shows the browser the task launches instead of running it headless; a headed run needs a display, so it runs on the host | -All but the endpoint become the module's `browser` configuration; the -endpoint becomes the task's `constraints.browser_endpoint`. +All but the endpoint and `TASK_HEADED` become the module's `browser` +configuration; those two become the task's `constraints.browser_endpoint` +and `constraints.headed`. **Optional, the cursor:** diff --git a/docs/crates/tinycomputer-examples/running-the-examples.md b/docs/crates/tinycomputer-examples/running-the-examples.md index aee3eaf0..8bfe7d05 100644 --- a/docs/crates/tinycomputer-examples/running-the-examples.md +++ b/docs/crates/tinycomputer-examples/running-the-examples.md @@ -196,6 +196,7 @@ that matter most for this crate: | Variable | Used by | For | |---|---|---| | `OPENROUTER_API_KEY` | `lab`, `task_live`, `task_fixture`, `live_goal`, `live_spotify` | Jev, and the optional LLM author or planner | +| `TINYHUMANS_TOKEN` | `task_live` | Jev and the planner through Tiny Humans' routes, in place of `OPENROUTER_API_KEY` | | `TINYCOMPUTER_MODULE` | `scripts/lab` | a module to load instead of building one | | `TINYCOMPUTER_JEV_JOURNAL` | any of them | turns on the debug journal | | `TINYCOMPUTER_FLOW_STRATEGY`, `TINYCOMPUTER_FLOW_DELIBERATION` | `lab`, `task_live`, `task_fixture` | narrow vs. wide asking, and how much a decision deliberates |