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/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() 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 |