Skip to content

ADR 0004 G3: apps offer tools end to end (+G8, G11, G12, G13, peer/input/reject) - #160

Merged
ymote merged 9 commits into
mainfrom
feat/adr0004-g3-app-tools
Sep 29, 2026
Merged

ymote merged 9 commits into
mainfrom
feat/adr0004-g3-app-tools

Conversation

@ymote

@ymote ymote commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

ADR 0004: apps offer tools end to end (G3), fail closed (G13), relay checks (G8), terminal.run only with a process Terminal (G12), agent questions (G11), Rinx's grant from the manifest, peer/input/reject

Closes the ADR 0004 gap-review items G3, G13, G8, G12, the shell part of G11, and "the agent grants are generated" (§1). Rebased on main after #151 (toolbox peers), #152/#153/#155 (G1/G2/G4), #154 (makepad 953d9fa3), #156/#157 (G6/G9), #158 (G7) and #159 (G5/G10).

Commits

  1. feat(shell): agent question requests (G11): the broker forwards user_question/requested on an app peer's session and contexts to the host with a QuestionAnswer (sends user_question/respond once, on the link it came on); the app's context only hears user_question/handled_by_host, and an app's ContextOp::Approval for an id the host holds is refused. crates/shell/src/questions/ is a routing layer (request model plus subscribing consumers): the system chat shows the system agent's questions, and the approvals overlay shows the app's. The only answer path is questions::answer with a PersonAnswer, which only the shell's surfaces make (a source-scan test checks this). The peer link and the octos host service have no answer method. There is no host.ask.
  2. feat(native-apps): native-apps.json's agent block is validated and generated. It holds tools (the app's own tools.json entries), generic_tools (the exact kernel tools; octos's shell is refused), grants ({app, tool}) and budget. crates/ai-host/src/native_agents.rs is generated, and Policy::shipped() reads Rinx's grant from it. The behaviour is unchanged.
  3. feat(host-tools) (G3): the seams for toolbox-peers: offer the system toolbox to app agents (ADR 0002 §6, ADR 0004 §12, behind a feature) #151 and toolbox-peers: offer the system toolbox to app agents (ADR 0002 §6, behind a feature) #108 are declare(app, tools) → grant(caller, owning_app, tool) (with toolbox-peers: offer the system toolbox to app agents (ADR 0002 §6, ADR 0004 §12, behind a feature) #151's set_grants and offered adopted) → set_executor(app, …), plus set_generic(app, tools).
    • Native apps' tools come from the manifest: the Terminal's terminal.run, terminal.read_screen and terminal.read_scrollback, run on its AI bus service.
    • Script apps' tools.json and agent.tools are loaded with App Hub's AgentBundle::load when the peer first registers and again at install. Plain names become kernel tools and dotted names become grants marked with their owner; the toolbox grant goes through toolbox::grant_manifest.
    • HostServiceExecutor runs a tool as the app's own host.request. News offers news.list and news.read (apps/news/bundle/tools.json, capability news).
    • Each peer is registered with exactly its granted generic_tools and never gets octos's shell.
    • A real-kernel test covers octos → broker → ShellToolHost and relay → News's host service → the model's turn. phone.yml builds the pinned octos (cached) and runs the test.
  4. fix(kernel) (G13): a tool policy that cannot be enforced (a foreign policy, the person's own octos home, or a profile that does not read back) starts no kernel, and every consumer is told why.
  5. feat(shell): questions follow the turn's origin: routing uses the turn's origin (person, app or system agent), not the session. The origin comes from octos's field once octos sends it; until then it comes from the broker's per-turn trigger (G2).
  6. feat(host-tools) (G8): arguments are checked against input_schema (capped at 64 KiB) before anything runs, and results against output_schema (capped at 256 KiB). Each calling agent has a budget per turn and per day, set by agent.budget or the defaults (32 and 1000).
  7. feat(system-chat) (G12): terminal.run is registered, and callable by the system agent, only when command execution is granted and the Terminal runs as a process on this device (apps::terminal_runs_as_process, where process-if-vulkan resolves). Setup › Assistant › Command execution otherwise says "needs Terminal as a process on this device".
  8. feat(app-peers): octos is pinned to c608384d (octos#2621). A peer/input the host refuses (signed_out, no_consent, busy past 8 queued, other plus a message) gets a peer/input/reject on its connection before any turn/start.
  9. docs: architecture.md (+zh), the ADR 0004 follow-ups and ai-services.md.

Tests

  • New tests cover each item: generator and agent-block rules, Policy::shipped from the manifest, the shipped catalog and grants keyed by owner, the script-app loader and executor, generic tools never including the shell, schemas and budgets, G13 in tests/core.rs, the G12 gate and the Setup row, questions routing and "no app-facing path answers", and each peer/input/reject reason.
  • Local runs: cargo test -p octosense-shell --features app-terminal 548 passed; from phone/ with --features mobile-apps, shell 565 and home 183; app-peers (octos-core,ws) lib 21, broker 19, account lifecycle 1; ai-host 29; kernel lib 27 and core 12; python3 -m unittest discover -s tools 55.
  • The real-kernel suites ran locally against octos c608384d and were not skipped: crates/kernel 6 (+1 ignored), crates/app-peers 8, and the new shell test host_tools::real_kernel_tests (together with the system chat's).

Not in this PR

  • Rinx's own tools.json and executor (Rinx#40).
  • The host read tools files.*.
  • A Terminal agent.
  • Rate limits and background policy beyond the per-agent budgets.

🤖 Generated with Claude Code

ymote and others added 9 commits September 28, 2026 23:55
…right conversation [skip ci]

ADR 0004 §6 (G11, shell part): octos keeps `ask_user_question` on
host-driven turns, so an app agent's question needs a conversation.

- app-peers: the broker takes `user_question/requested` on the peer's
  session and its request contexts, stamps the turn's origin (a
  context's, the peer's own, or a `peer/input` turn's) and the context's
  client, and hands it to the host (`ToolHost::user_question`) with a
  `QuestionAnswer` that sends `user_question/respond` once, on the link
  it came on. The app's context hears only
  `user_question/handled_by_host`; the turn's end closes the question
  (`ToolHost::user_question_closed`); an app's `ContextOp::Approval` for
  an id the host holds (a question or a `host_tool` approval) is refused.
- shell: `crates/shell/src/questions/` is the request model, a routing
  layer: one `Request` per question (owning app, peer, account, context,
  client, turn, questions and options, who started the turn, the
  conversation, state), consumers subscribe to it. A context's or the
  peer's own turn asks in the app's conversation; a `peer/input` turn
  (the system agent's) in the system chat. The system chat subscribes and
  shows its questions in the conversation; the approvals overlay shows
  the app's. The only answer path is `questions::answer` with a
  `PersonAnswer`, made only by the shell's surfaces (a test scans the
  sources); the peer link and the `octos` host service have no method to
  answer. No `host.ask`.
- docs: architecture.md (+zh) item 11 and the questions bullet; the ADR
  follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom the manifest [skip ci]

native-apps.json's `agent` block now carries what an app's agent gets,
reviewed with the entry (ADR 0004 §1, §7, §12):

- `tools`: the app's own tools, as `tools.json` entries (the Terminal's
  `terminal.run`, `terminal.read_screen`, `terminal.read_scrollback`);
- `generic_tools`: exactly the octos kernel tools its agent gets (Rinx:
  its workspace files, `ask_user_question`, memory, the web); never
  octos's shell (the generator refuses `shell`, `bash`, `exec_command`,
  `write_stdin`, `group:runtime`);
- `grants`: other apps' shareable tools, `{app, tool}` (a native owner's
  tool must be declared and shareable);
- `budget`: tool calls per turn and per day.

tools/native_apps.py validates it, generates it into
crates/shell/src/native_apps.rs, and generates
crates/ai-host/src/native_agents.rs: the `octos.*` grants
`Policy::shipped()` now hands out instead of a hard-coded Rinx line
(same behaviour: Rinx gets the four `octos.*` services). --check covers
both files; generator and ai-host tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir granted kernel tools [skip ci]

ADR 0004 §4, §7, §12 (G3).

- The seams (crates/shell/src/host_tools, for #108 too):
  `declare(app, tools)` → `grant(caller, owning_app, tool)` (grants are
  keyed by owner and tool) → an executor per owning app
  (`set_executor`); `set_generic(app, tools)` sets exactly the octos
  kernel tools an app's agent keeps (never octos's shell).
- Native apps: the shipped catalog is native-apps.json's agent blocks
  (the Terminal's `terminal.run`, `terminal.read_screen`,
  `terminal.read_scrollback`; each app's grants and kernel tools). The
  Terminal's tools run on its AI bus service; declarations always name
  their owning app.
- Script apps (host_tools/script_apps.rs): a system or installed app's
  `tools.json` and manifest `agent.tools` are loaded with App Hub's own
  loader (`AgentBundle::load`: digest and gate rules) when its peer first
  registers and again at install; plain names become its kernel tools,
  dotted ones cross-app grants marked with their owner. Its host-service
  tools run on `HostServiceExecutor`: App Hub's dispatch with the app's
  identity, never from a sheet, only for a granted family; answers come
  back through `host_tools::pump`.
- News offers real tools: apps/news/bundle/tools.json (`news.list`,
  `news.read`, read-only, shareable) on its host service; its manifest
  now has the `news` capability.
- app-peers: `ToolHost::generic_tools`; the broker registers each peer
  with the host's exact `generic_tools` (the shell always sets one: an
  app granted none keeps none).
- The real-kernel test (host_tools/real_kernel_tests.rs): octos at the
  pinned rev → the broker → `ShellToolHost` and the relay → News's host
  service → the result in the model's turn; the peer is offered News's
  tools, no kernel tools and never octos's shell. phone.yml builds the
  pinned octos (cached) and runs it (`OCTOS_SHELL_TEST_KERNEL`); without
  a binary it says so and passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p ci]

ADR 0004 §12 (G13): `launch::prepare` ignored a refused `tool_policy`
write (a foreign policy in the profile, the person's own octos home via
OCTOS_APP_CORE_DIR, an unwritable profile) and started the kernel anyway,
so the system agent and every app peer had octos's shell.

- `system_tools::enforce` reads the profile back after writing and
  refuses when the policy is not there.
- `launch::prepare` returns the refusal; the generation then does not
  start the kernel and closes every consumer's connection with
  `CloseReason::Failed` saying why ("The assistant was not started:
  OctoSense could not enforce its tool policy …").
- App peers no longer rely on the profile policy alone: they are
  registered with exactly their granted `generic_tools` (previous
  commit), never octos's shell.
- Test: a foreign policy starts no kernel program, leaves the person's
  policy alone, and a fixed profile starts it with the policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
G11 follow-up (the person's decision): an app agent has ONE shared
conversation on its peer session, driven by the person (the app's UI,
its cards), the app and the system agent; a question follows the TURN's
origin, not its session.

- app-peers: `TurnOrigin` (person, app, system_agent) on each
  `AgentQuestion`: the kernel's `origin` on the event when octos carries
  it (`origin_reported`), until then the broker's derivation: the turns it
  started for `peer/input` are the system agent's, a context's the
  person's, any other the app's.
- shell: the request model keeps the `Origin` (and whether it was
  reported); person and app turns ask in the app's conversation, system
  agent turns in the system chat. The later octos field replaces the
  derivation without touching the consumers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d budget each agent [skip ci]

ADR 0004 §3 (G8): the relay authorized by (owning app, tool) and caller
only.

- Arguments: checked against the tool's declared `input_schema` (the
  `tools.json` subset: type, properties, required, additionalProperties,
  items, enum, const, bounds, lengths, item counts;
  host_tools/schema.rs) and capped at 64 KiB, before anything runs;
  refused as `invalid_args` with the path of the first violation.
- Results: every answer goes back through a checked reply: over 256 KiB
  is `result_too_large`, outside the declared `output_schema` is
  `invalid_result`; errors pass through; a cancel closes both replies.
- Budgets: each calling agent (and the system agent) has calls per turn
  and per day, from its manifest (`native-apps.json` `agent.budget`) or
  the defaults (32 per turn, 1000 per day); spent is `budget_exceeded`;
  a new turn and a new day start again.
- Tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess on this device [skip ci]

ADR 0004 §10, §12 (G12, decided 2026-09-29): the in-process Terminal
offers its read tools only, so `terminal.run` has a target only where the
Terminal runs as its own process.

- `apps::terminal_runs_as_process`: processes on this device, the
  Terminal's hosting resolved to a process (`process`, or
  `process-if-vulkan` on a Vulkan build in a Wayland session, unless the
  person switched it to a module) and a process form to start.
- The system chat registers `terminal.run` on the system session (and the
  relay lets the system agent call it) only when command execution is
  granted AND that holds; granted without it, nothing is registered.
- Setup › Assistant › Command execution says "needs Terminal as a process
  on this device" there, on or off.
- Tests: the hosting resolution, the registration gate, the Setup rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reason [skip ci]

octos#2621 (merged as octos main c608384d) adds `peer/input/reject`: the
host can refuse the system agent's `peer/input`, and the system agent
learns why instead of the refusal being only logged (octos#2618, the
ADR 0004 follow-up "a refused peer/input is only logged").

- Pin octos c608384d: octos-core, octos-cli and octos-research move
  together; tools/test_kernel_artifact.py and the docs that name the
  current pin follow.
- `ToolHost::admit_input` refuses with an `InputRefusal` (`signed_out`,
  `no_consent`, `busy`, `other` + a one-line message); the shell's host
  says `signed_out` for a suspended account and `no_consent` without the
  person's consent.
- The broker sends `peer/input/reject {session_id, peer, host_token,
  profile_id, input_id, reason, message?}` on the connection the input
  came on, before any `turn/start` with its turn id: a signed-out or
  suspended account, the host's refusal, or a busy peer past
  `MAX_QUEUED_INPUTS` (8). Queued inputs still start later with their own
  turn ids.
- Tests: each reason, the message only with `other`, the connection, no
  turn for a refused input, the queue limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…checks are on main [skip ci]

architecture.md (+zh): an app peer's tool list is exactly its granted
`generic_tools` plus its declared and granted tools (the Terminal's, News's
`news.list`/`news.read`); Rinx's grant comes from native-apps.json; the
relay checks arguments and results against the declared schemas and
budgets each agent; disagreement 4's fail-open note is fixed (G13).
ADR 0004 follow-ups: what G3/G8/G13 and `peer/input/reject` closed, and
what stays open. ai-services.md: the shell side of #2567.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant