From 47bf91693b1bfff5c7ab8782d40c43cbe2932772 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:19:22 -0700 Subject: [PATCH] =?UTF-8?q?toolbox-peers:=20offer=20the=20system=20toolbox?= =?UTF-8?q?=20to=20app=20agents=20(ADR=200002=20=C2=A76,=20ADR=200004=20?= =?UTF-8?q?=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-port of #108 onto main's octos#2567 shell side (#145), behind the `toolbox-peers` feature (off by default): - the toolbox is one more owning app (`toolbox`) in the shell's host-tool relay: its tools are declared once in the catalog (`app: "toolbox"`, shareable), each app is granted exactly what it declares AND the person granted, and `ToolboxExecutor` (crates/ai-host) runs the calls the relay routes to it; main's broker registers them after every prepare and reconnect. #108's own registration and routing are not ported; - no hard-coded exclusions: #108's "never offer octos deep_research" (its empty generic_tools) is gone; - the #120 first-use consent gates the toolbox: nothing offered (Catalog::offered) and nothing run (the relay's consent_pending) before the person allowed the app's agent; - ported from #108: crates/toolbox `peer` (plus a shared `catalog`), read_links/narrow_crawl, ModelHostClient, toolbox_folder, the 256 KiB host model input, the mock model's CALL_TOOL rule; tests against a scripted kernel through main's broker, the relay, and a real octos kernel. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/apps.yml | 6 + .github/workflows/phone.yml | 1 + Cargo.lock | 2 + .../host-service/src/complete/mod.rs | 13 +- .../host-service/tests/complete.rs | 22 + crates/ai-host/Cargo.toml | 16 + crates/ai-host/README.md | 58 +- crates/ai-host/src/lib.rs | 33 + crates/ai-host/src/toolbox_peers.rs | 455 +++++++++++ crates/ai-host/tests/toolbox_peers.rs | 540 +++++++++++++ crates/ai-host/tests/toolbox_real_kernel.rs | 207 +++++ .../tests/fixtures/mock_agent_llm.py | 15 +- crates/shell/Cargo.toml | 4 + crates/shell/src/host_tools/mod.rs | 12 +- crates/shell/src/host_tools/relay.rs | 27 +- crates/shell/src/host_tools/tests.rs | 83 ++ crates/shell/src/host_tools/toolbox.rs | 70 ++ crates/shell/src/module_host.rs | 4 + crates/toolbox/README.md | 4 +- crates/toolbox/src/lib.rs | 1 + crates/toolbox/src/peer.rs | 722 ++++++++++++++++++ crates/toolbox/src/research/mod.rs | 26 + crates/toolbox/src/research/octos.rs | 99 ++- crates/toolbox/src/scope.rs | 71 ++ crates/toolbox/src/scope/compat.rs | 29 +- crates/toolbox/tests/peer.rs | 168 ++++ desktop/Cargo.toml | 2 + phone/Cargo.toml | 2 + 28 files changed, 2678 insertions(+), 14 deletions(-) create mode 100644 crates/ai-host/src/toolbox_peers.rs create mode 100644 crates/ai-host/tests/toolbox_peers.rs create mode 100644 crates/ai-host/tests/toolbox_real_kernel.rs create mode 100644 crates/shell/src/host_tools/toolbox.rs create mode 100644 crates/toolbox/src/peer.rs create mode 100644 crates/toolbox/tests/peer.rs diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index 55f49f0e..27bdf62c 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -144,6 +144,12 @@ jobs: cargo test --locked -p octosense-ai-host cargo test --locked -p octosense-ai-host --features octos-core,llm cargo clippy --locked -p octosense-ai-host --all-targets --features octos-core,llm --no-deps -- -D warnings + # The system toolbox for app agents (off by default): its grants and + # executor through main's broker, against the scripted kernel. The + # real-kernel test (tests/toolbox_real_kernel.rs) needs an octos + # binary and passes without. + cargo test --locked -p octosense-ai-host --features toolbox-peers + cargo clippy --locked -p octosense-ai-host --all-targets --features toolbox-peers --no-deps -- -D warnings - name: AppCard runtime setup safeguards working-directory: apps/appcard run: PYTHONPATH=tools python3 -m unittest core.test_native_runtime diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index 6364070f..0b67b3c2 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -88,5 +88,6 @@ jobs: cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-appcard -p octosense-ai-host # The opt-in AppCard build: its module registers into the shell. cargo test --locked --features mobile-apps,app-appcard -p octosense-shell appcard + cargo test --locked --features mobile-apps,toolbox-peers -p octosense-shell host_tools cargo test --locked -p makepad-widgets splash_policy cargo test --locked -p makepad-script-std gate::tests diff --git a/Cargo.lock b/Cargo.lock index 4e2b4edc..f119a8d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7284,7 +7284,9 @@ dependencies = [ "octosense-kernel", "octosense-llm-config", "octosense-llm-service", + "octosense-toolbox", "serde_json", + "tokio", ] [[package]] diff --git a/apps/ai-providers/host-service/src/complete/mod.rs b/apps/ai-providers/host-service/src/complete/mod.rs index b96449f7..8d0fe695 100644 --- a/apps/ai-providers/host-service/src/complete/mod.rs +++ b/apps/ai-providers/host-service/src/complete/mod.rs @@ -53,7 +53,9 @@ //! to call it too, so direct calls and template calls share one budget per //! app: its `ModelRequest {system, user, output_schema}` maps onto a //! [`Request`] with `system` set and `input` the user document ([`host`] -//! hands out the registered host). +//! hands out the registered host). A host caller's input may be up to +//! [`HOST_INPUT_MAX`] (a digest carries its articles' evidence); an app's +//! stays at [`INPUT_MAX`]. use crate::model::{effective_model, ProfileStore}; use crate::probe; use crate::vault::Vault; @@ -77,6 +79,10 @@ pub const FAMILY: &str = "model"; pub const TASK_MAX: usize = 4 * 1024; /// The input, as compact JSON, in bytes. pub const INPUT_MAX: usize = 32 * 1024; +/// The input of a host caller's call (`system` set: the toolbox's digests, +/// whose user document carries the articles' evidence), which +/// `host.request` can never make. +pub const HOST_INPUT_MAX: usize = 256 * 1024; /// The model's reply text (after a code fence is removed), in bytes: the /// host's hard cap on what an app gets back. pub const OUTPUT_MAX: usize = 16 * 1024; @@ -219,8 +225,9 @@ impl Request { return Err(bad(format!("task is over {TASK_MAX} bytes"))); } let input = self.input.to_string().len(); - if input > INPUT_MAX { - return Err(bad(format!("input is {input} bytes; the most is {INPUT_MAX}"))); + let max = if self.system.is_some() { HOST_INPUT_MAX } else { INPUT_MAX }; + if input > max { + return Err(bad(format!("input is {input} bytes; the most is {max}"))); } if self.schema.is_null() { return Err(bad("schema is required".into())); diff --git a/apps/ai-providers/host-service/tests/complete.rs b/apps/ai-providers/host-service/tests/complete.rs index 975af1d3..779ff5bd 100644 --- a/apps/ai-providers/host-service/tests/complete.rs +++ b/apps/ai-providers/host-service/tests/complete.rs @@ -466,3 +466,25 @@ fn a_host_caller_shares_the_same_ledger() { assert_eq!(body["messages"][1]["content"], "{\"query\":\"x\"}"); assert_eq!(Code::Budget.as_str(), "budget"); } + +#[test] +fn a_host_caller_may_send_a_larger_input_than_an_app() { + // A toolbox digest carries the articles' evidence (up to 6 KB each): + // over an app's 32 KiB, within the host's 256 KiB. + let rig = Rig::new("host-input", vec![deepseek("deepseek-v4-flash")]); + rig.fake.says(GOOD); + let big = "x".repeat(complete::INPUT_MAX + 1024); + let request = |system: Option<&str>| Request { + class: complete::Class::Fast, + task: if system.is_some() { String::new() } else { "t".into() }, + input: json!(big), + schema: args()["schema"].clone(), + allow_urls: true, + system: system.map(str::to_owned), + }; + let refused = rig.host.complete(APP, request(None)).unwrap_err(); + assert_eq!(refused.code, Code::BadRequest); + rig.host.complete(APP, request(Some("You write a digest."))).unwrap(); + let huge = Request { input: json!("x".repeat(complete::HOST_INPUT_MAX + 1)), ..request(Some("s")) }; + assert_eq!(rig.host.complete(APP, huge).unwrap_err().code, Code::BadRequest); +} diff --git a/crates/ai-host/Cargo.toml b/crates/ai-host/Cargo.toml index ac0f73e0..77f6f5ab 100644 --- a/crates/ai-host/Cargo.toml +++ b/crates/ai-host/Cargo.toml @@ -21,6 +21,10 @@ # dependencies below): `cfg(kernel)` in the source, set by build.rs. # - `llm`: register the `llm` host service (a shell's `app-hub` feature turns # it on; phones always). +# - `toolbox-peers`: the system toolbox offered to app agents as host-routed +# tools (octos#2567): the grants, and the executor the shell's host-tool +# relay routes the `toolbox` owner's calls to. Off by default; the shell's +# feature of the same name turns it on. # # The kernel is crates/kernel, the app-peers contract and broker # crates/app-peers, the `llm` service and its config apps/ai-providers/*; @@ -38,6 +42,16 @@ license.workspace = true default = [] octos-core = ["dep:octosense-kernel", "octosense-app-peers/octos-core", "octosense-llm-service?/octos-core"] llm = ["dep:octosense-llm-service"] +# The system toolbox for app agents (ADR 0002 section 6, ADR 0004 section +# 12): `toolbox_peers`, over the octos research engine and the `model` +# service's host. +toolbox-peers = [ + "octos-core", + "llm", + "dep:octosense-toolbox", + "octosense-toolbox/octos-engine", + "dep:tokio", +] [dependencies] makepad-widgets = { workspace = true } @@ -51,6 +65,8 @@ octosense-appstore = { workspace = true } serde_json = { workspace = true } octosense-kernel = { workspace = true, optional = true } octosense-llm-service = { workspace = true, optional = true } +octosense-toolbox = { workspace = true, optional = true } +tokio = { workspace = true, optional = true, features = ["rt", "sync", "macros"] } # Native mobile builds always carry the kernel (Android bundles liboctos.so, # OpenHarmony links the core in-process, iOS runs none) and, when the `llm` diff --git a/crates/ai-host/README.md b/crates/ai-host/README.md index fc24b87a..b566d7a6 100644 --- a/crates/ai-host/README.md +++ b/crates/ai-host/README.md @@ -38,11 +38,63 @@ QrImport` (`platform()` or `paste_only()`); `policy: Policy` (`Policy::shipped()` grants Rinx the `octos.*` services). Features: `octos-core` (the kernel, app-peers broker, llm restart; native -mobile targets always have it — `cfg(kernel)`, set by build.rs) and `llm` -(register the `llm` service; a shell's `app-hub` turns it on). +mobile targets always have it — `cfg(kernel)`, set by build.rs), `llm` +(register the `llm` service; a shell's `app-hub` turns it on) and +`toolbox-peers` (below; off by default, turned on by the shell's feature of +the same name). + +## The system toolbox for app agents (`toolbox-peers`) + +ADR 0002 section 6 and ADR 0004 section 12. The toolbox is one more owner of +host-routed tools in the shell's host-tool relay (octos#2567's shell side, +`crates/shell/src/host_tools/`): main's broker registers them after every +`peer/prepare` and reconnect (`generic_tools` omitted), and the relay +authorizes each call and routes it to the toolbox's executor. This crate adds +only the toolbox's part (`src/toolbox_peers.rs`, over `crates/toolbox`'s +`peer` module): + +| Declared and granted | Offered (risk), each `app: "toolbox"` | +| --- | --- | +| neither | nothing | +| `research` | `workflow.run` (read), `workflow.fork` (act), `toolbox.search` (read), `toolbox.web_read` (read) | +| `crawl`, with `max_depth` and `max_pages` above 0 in the scope | `toolbox.deep_crawl` (read) | + +- `catalog()`: every toolbox tool, `shareable`, owned by `toolbox`; the relay + declares it once and grants each app its `ToolboxGrant::tools()`. +- `ToolboxGrant`: what the app declares AND the person granted + (`ToolboxGrant::new(app, declared, granted, scope)`). A native module's + declared capabilities are reviewed with the shell (`for_module`). A script + app's manifest (`for_manifest`: `research`/`crawl` in `capabilities`, the + scope in octos's `Scope` shape under the top-level `research` object, App + Hub #26's shape) is, **temporarily**, granted only to system apps (`os.*`) + until the shells' App Hub pin includes #26 and the host reads its verified + grant. +- `ToolboxExecutor`: the relay's executor for the `toolbox` owner. It checks + the calling app's grant again (a forged `toolbox.deep_crawl` is + `not_granted`), runs the call with the app's `AppContext` (id, grants, + octos `Scope`) on a worker thread per app, answers once, and never answers + a cancelled call. Template model calls go through the `model` service's + `ModelHost::complete`: the person's providers and the app's daily budget, + in the same ledger as `model.complete`. +- Consent (the #120 first-use sheet) is the relay's: no toolbox tool is + offered to an app, or run for it, before the person allowed its agent. + +Nothing else is held back: octos's own generic tools (`deep_research` among +them) are the kernel's, and which of them a peer gets is its `generic_tools` +list, which the broker does not set. + +Results are written to the host-owned `/.host/toolbox/` +(`toolbox_folder`, always compiled; run results under +`toolbox/runs/