Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 85 additions & 24 deletions crates/tinycomputer-examples/src/bin/task_live/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Value, LabError> {
let key = openrouter_key()?;
let optional = |name: &str| std::env::var(name).ok();
let mut browser = serde_json::Map::new();
for (field, variable) in [
Expand All @@ -162,30 +174,76 @@ fn module_config() -> Result<Value, LabError> {
json!(args.split_whitespace().collect::<Vec<_>>()),
);
}
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<String>) -> 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<Value, LabError> {
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<String>, jev: Value) -> Result<Value, LabError> {
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`.
Expand Down Expand Up @@ -257,3 +315,6 @@ async fn plan(
std::fs::write(out.join("plan.json"), &text)?;
Ok(plan.flow)
}

#[cfg(test)]
mod main_tests;
91 changes: 91 additions & 0 deletions crates/tinycomputer-examples/src/bin/task_live/main_tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
let variables: BTreeMap<String, String> = 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(())
}
45 changes: 44 additions & 1 deletion crates/tinycomputer-examples/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests likely

Test wait_until_ready's failure paths, not just its comment

This new function is the behavioural heart of the host-side change: the comment claims the loader refuses reconfiguration until setup returns, and the code asserts that claim only in prose. Its three outcomes — returning Ok on ModuleState::Ready | ModuleState::Serving, returning a descriptive error on Rejected | Unresolved | Faulted | Failed | Stopped | Disabled, and the 30-second timeout — are exercised by no test in the diff, and the repository requires failure paths to be covered and at least 90% line coverage per source file. A regression that flips the match arms (e.g. treating Faulted as retryable, or timing out instantly) would ship silently. If constructing a ModuleHost in a test is impractical, at minimum factor the state-classification into a pure function over Option<ModuleState> and test that.

[RULE] untested-error-path ·

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()
Expand Down
11 changes: 7 additions & 4 deletions docs/crates/tinycomputer-examples/live-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:**

Expand All @@ -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:**

Expand Down
Loading
Loading