diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 553df6af..4638f5e1 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 11 +open_count: 13 waived_count: 0 fixed_count: 4 -total_count: 15 -last_updated: 2026-08-03T00:06:03.112Z +total_count: 17 +last_updated: 2026-08-03T18:50:07.659Z --- # Broken Windows Ledger @@ -30,6 +30,8 @@ last_updated: 2026-08-03T00:06:03.112Z | 13 | 10 | unrun-verify | core/archipelago/src/api/rpc/system/handlers.rs | | system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call. | fixed | | 2026-08-02T19:07:40.522Z | 2026-08-02T23:00:30.894Z | | 14 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched. | fixed | | 2026-08-02T22:44:15.215Z | 2026-08-02T23:16:04.071Z | | 15 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one. | fixed | | 2026-08-02T23:16:04.510Z | 2026-08-03T00:06:03.112Z | +| 16 | 13 | unrun-verify | core/archipelago/src/assistant/loop_.rs | | cargo test --package archipelago assistant:: (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session) never completed this session — killed twice under machine resource contention (load avg 35-46 on 4 cores, sibling worktree builds). cargo build --package archipelago DID complete clean (exit 0, only expected dead-code warnings). Test logic was read and reasoned correct but not independently executed — needs a follow-up cargo test run when the machine is free. | open | | 2026-08-03T18:49:48.842Z | | +| 17 | 13 | deviation | external:AIUI/packages/app/src/services/archyBridge.ts | | Task 3 (sendChat/streamViaArchy, external repo /home/archipelago/Projects/AIUI, branch development, commit e30ac1d) is committed locally but NOT pushed to git.tx1138.com — origin was unreachable this session (DNS resolves to 80.71.235.99 but TCP/TLS connect and even 'git ls-remote' timed out repeatedly, sandbox-disabled too). Needs a push from an environment with network access to git.tx1138.com before the fix lands upstream. | open | | 2026-08-03T18:50:07.659Z | | ````json [ @@ -212,6 +214,30 @@ last_updated: 2026-08-03T00:06:03.112Z "reason": "", "recorded_at": "2026-08-02T23:16:04.510Z", "resolved_at": "2026-08-03T00:06:03.112Z" + }, + { + "id": 16, + "kind": "unrun-verify", + "phase": "13", + "file": "core/archipelago/src/assistant/loop_.rs", + "line": null, + "description": "cargo test --package archipelago assistant:: (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session) never completed this session — killed twice under machine resource contention (load avg 35-46 on 4 cores, sibling worktree builds). cargo build --package archipelago DID complete clean (exit 0, only expected dead-code warnings). Test logic was read and reasoned correct but not independently executed — needs a follow-up cargo test run when the machine is free.", + "status": "open", + "reason": "", + "recorded_at": "2026-08-03T18:49:48.842Z", + "resolved_at": null + }, + { + "id": 17, + "kind": "deviation", + "phase": "13", + "file": "external:AIUI/packages/app/src/services/archyBridge.ts", + "line": null, + "description": "Task 3 (sendChat/streamViaArchy, external repo /home/archipelago/Projects/AIUI, branch development, commit e30ac1d) is committed locally but NOT pushed to git.tx1138.com — origin was unreachable this session (DNS resolves to 80.71.235.99 but TCP/TLS connect and even 'git ls-remote' timed out repeatedly, sandbox-disabled too). Needs a push from an environment with network access to git.tx1138.com before the fix lands upstream.", + "status": "open", + "reason": "", + "recorded_at": "2026-08-03T18:50:07.659Z", + "resolved_at": null } ] ```` diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md new file mode 100644 index 00000000..433ba4f3 --- /dev/null +++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md @@ -0,0 +1,274 @@ +--- +phase: 13-aiui-functional-conversational-node-control-and-content-surf +plan: 01 +subsystem: ai +tags: [rust, tokio, anthropic-claude, tool-calling, postmessage, vue, aiui, rpc] + +requires: + - phase: 10-key-material-hardening + provides: "data_dir/secrets/claude-api-key convention, session-cookie + CSRF + role.can_access() RPC gate" +provides: + - "crate::assistant module: CallerScope, PermissionCategory, ToolExecCtx, chat() entry point" + - "Curated tool registry (tools.rs) with one tool, system_disk_status, hand-written JSON Schema" + - "run_loop / execute_tool tool-calling loop (loop_.rs) — the single choke point every tool call passes through" + - "Backend trait + BackendTurn seam (backends/mod.rs) with a real ClaudeBackend and a #[cfg(test)] ScriptedBackend" + - "assistant.* RPC prefix sub-dispatcher (api/rpc/assistant_chat.rs), registered as one guarded dispatcher.rs arm" + - "chat:request / chat:response postMessage transport in neode-ui's contextBroker + aiui-protocol" + - "AIUI embedded-mode delegation: archyBridge.sendChat + useAI.ts's streamViaArchy (external repo, committed not pushed)" +affects: [13-05, 13-08, 13-09, 13-10, 13-13, 13-14] + +tech-stack: + added: [] + patterns: + - "D-02 caller-scope authority model: CallerScope::granted_categories() is the sole source of tool authority; execute_tool never reads a caller-specific field directly" + - "D-06 curated tool registry: hand-written ToolDef list, never derived from the RPC dispatcher's method table" + - "Tool execution bridges back into api::rpc via a thin pub(crate) fn (assistant_dispatch_tool) so the SAME RpcHandler method every authenticated caller uses runs the tool — never a parallel AI-only path" + +key-files: + created: + - core/archipelago/src/assistant/mod.rs + - core/archipelago/src/assistant/tools.rs + - core/archipelago/src/assistant/loop_.rs + - core/archipelago/src/assistant/backends/mod.rs + - core/archipelago/src/assistant/backends/claude.rs + - core/archipelago/src/assistant/backends/scripted.rs + - core/archipelago/src/api/rpc/assistant_chat.rs + modified: + - core/archipelago/src/api/rpc/dispatcher.rs + - core/archipelago/src/api/rpc/middleware.rs + - core/archipelago/src/api/rpc/mod.rs + - core/archipelago/src/main.rs + - neode-ui/src/services/contextBroker.ts + - neode-ui/src/types/aiui-protocol.ts + - "/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts (external repo)" + - "/home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts (external repo)" + +key-decisions: + - "Chose continuation option (a): git reset --soft HEAD~1 on the inherited WIP checkpoint (6ba52b22), then re-committed atomically — one commit for the Rust spine (Task 1), one for the neode-ui broker (Task 2) — after verifying every file against the plan's must_haves and acceptance criteria" + - "UNAUTHENTICATED_METHODS visibility widened pub(super) -> pub(crate) so assistant_methods_require_session can assert directly against the live list; contents of the list are untouched (verified by diff, not just grep)" + - "assistant_dispatch_tool + data_dir() added as thin pub(crate) bridges on RpcHandler because handle_system_disk_status is pub(in crate::api::rpc) and crate::assistant lives outside that module tree — Rust privacy, not a new capability surface" + +requirements-completed: [AIUI-01] + +coverage: + - id: D1 + description: "Rust assistant spine (CallerScope, curated tool registry, run_loop/execute_tool choke point, Claude backend) compiles clean and wires into the existing session/CSRF/RBAC-gated RPC dispatcher via a single assistant.* arm" + requirement: AIUI-01 + verification: + - kind: unit + ref: "cargo build --package archipelago (full workspace build, exit 0, only pre-existing/expected dead-code warnings)" + status: pass + - kind: unit + ref: "cargo test --package archipelago assistant:: (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session)" + status: unknown + human_judgment: true + rationale: "cargo test never completed this session — killed twice under machine resource contention (load avg 35-46 on a 4-core box with concurrent sibling-worktree builds), and the orchestrator subsequently throttled all cargo invocations in this worktree. The code was read function-by-function against the plan's must_haves and reasoned correct, and `cargo build` (not `test`) did complete clean, but the three named unit tests were never independently executed. Logged to WINDOWS.md (id 16) as an open unrun-verify; needs a follow-up `cargo test --package archipelago assistant::` when the machine is free." + - id: D2 + description: "neode-ui carries a chat turn over the existing origin-checked postMessage bridge to assistant.chat and back, without widening AIActionType or relaxing the origin guard" + requirement: AIUI-01 + verification: + - kind: unit + ref: "neode-ui/src/services/__tests__/contextBroker.test.ts (16/16 passing)" + status: pass + - kind: unit + ref: "neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts (7/7 passing)" + status: pass + - kind: other + ref: "npx vue-tsc --noEmit (neode-ui) — exit 0" + status: pass + human_judgment: false + - id: D3 + description: "AIUI delegates chat to Archy's node-side assistant loop when embedded (archyBridge.sendChat, useAI.ts's streamViaArchy), while standalone mode keeps streamClaude/streamOpenRouter unchanged (D-17)" + requirement: AIUI-01 + verification: + - kind: unit + ref: "AIUI packages/app: npx vitest run — 332/335 passing; the 3 failures (seed-conversations.test.ts, seedExtraction.test.ts, useAI.test.ts web-search-integration) were confirmed pre-existing on HEAD before this change via a scratch git-worktree diff, unrelated to sendChat/streamViaArchy" + status: pass + - kind: other + ref: "AIUI packages/app: npx vue-tsc --noEmit — exit 0" + status: pass + human_judgment: true + rationale: "The commit (e30ac1d, branch development) is local-only — git.tx1138.com was unreachable from this session (DNS resolved but TCP/TLS connect and `git ls-remote` both timed out repeatedly, sandbox-disabled too). Nothing is lost (it's a normal commit in a real clone), but a human/later session needs to push it before it reaches anyone else's checkout. Logged to WINDOWS.md (id 17)." + +duration: ~1h50m (this continuation session; the original session that produced the bulk of this diff died on a broken pipe before this session started) +completed: 2026-08-03 +status: complete +--- + +# Phase 13 Plan 01: AIUI Tracer Slice — Chat-to-Tool-to-RPC Spine Summary + +**A typed "how much space is left" question in embedded AIUI reaches a curated Rust tool-calling loop over the authenticated RPC session, executes `system.disk-status` through the same handler every caller uses, and returns a real Claude-composed answer — model key stays node-side, `assistant.*` stays authenticated.** + +## Performance + +- **Duration:** ~1h50m (this continuation session only) +- **Started:** 2026-08-03T17:00:00Z (approx, this continuation) +- **Completed:** 2026-08-03T18:50:23Z +- **Tasks:** 3/3 completed +- **Files modified:** 15 (9 Rust, 2 neode-ui TS, 2 AIUI external-repo TS, 2 planning ledger updates) + +## Continuation Context + +This plan was interrupted mid-execution by a broken pipe in a prior session. The orchestrator +committed that session's uncommitted work verbatim as a WIP checkpoint (`6ba52b22`) so it could +not be lost, and spawned this continuation agent to establish ground truth on it rather than +trust or discard it. + +**Ground truth established:** every file in the WIP commit was read in full and checked against +the plan's `must_haves.artifacts` (`contains:` symbols), the acceptance-criteria greps, and the +threat-model dispositions. The Rust spine (`mod.rs`, `tools.rs`, `loop_.rs`, `backends/*`, +`assistant_chat.rs`, the `dispatcher.rs`/`middleware.rs`/`mod.rs`/`main.rs` edits) and the +neode-ui broker changes (`contextBroker.ts`, `aiui-protocol.ts`) matched the plan's design +faithfully — correct Anthropic Messages API shape, correct D-06 curated-registry discipline, +correct D-02 caller-scope authority resolution, `UNAUTHENTICATED_METHODS` genuinely untouched +(diffed, not just grepped), single dispatcher arm, no `schemars`, no second key location, no +mesh-timeout-constant reuse. `cargo build --package archipelago` was run to completion and +exited 0 with only expected dead-code warnings (unused `PermissionCategory`/`CallerScope`/`Role` +variants not yet exercised by this tracer) — confirming the WIP genuinely compiles. + +**Chosen path: option (a).** `git reset --soft HEAD~1` on the WIP checkpoint, then re-committed +atomically — one commit per completed plan task — after fixing one small acceptance-criteria +issue found during review (see Deviations). + +**Bonus discovery:** Task 3's AIUI-repo changes (`archyBridge.sendChat`, `useAI.ts`'s +`streamViaArchy`) were *also* already present, uncommitted, in `/home/archipelago/Projects/AIUI` +— that repo is outside this worktree's git history, so the broken pipe never touched it. Ground- +truthed the same way (full read against the plan's Task 3 spec), verified against a fresh +scratch `git worktree` at the AIUI repo's prior HEAD to confirm the 3 vitest failures pre-existed +the change, then committed it. + +## Accomplishments +- Rust `assistant` module: `CallerScope` (Mesh/LocalOperator), `PermissionCategory` (D-16's ten + categories), `ToolExecCtx`, `chat()` entry point — the D-02 shared-service root +- Curated D-06 tool registry with exactly one tool (`system_disk_status`), hand-written JSON + Schema (no `schemars`), with a round-trip test proving the schema and the args struct cannot + silently drift apart +- `run_loop`/`execute_tool` — the single choke point enforcing curated-allowlist refusal, + category-grant re-checking, schema validation, and the (not-yet-reachable) destructive-tool gate +- `Backend` trait + a real `ClaudeBackend` (Anthropic Messages API, tool_use/tool_result, new + `ASSISTANT_HTTP_TIMEOUT`/`ASSISTANT_MAX_TOKENS` constants — no mesh-timeout reuse) + a + `#[cfg(test)]`-only `ScriptedBackend` +- `assistant.*` RPC surface: one guarded `dispatcher.rs` arm, reached only after the existing + session/CSRF/RBAC gate, dispatching into `assistant_chat.rs`'s own sub-match +- neode-ui `chat:request`/`chat:response` postMessage transport over the existing + origin-checked bridge, with no new permission gate duplicated browser-side +- AIUI embedded-mode delegation (external repo, committed locally, push pending network access) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: End-to-end "how much space is left" — the Rust spine, one tool, one backend** - + `fe6ccff7` (feat) +2. **Task 2: neode-ui carries chat over the existing origin-checked bridge** - `0ab9bdc7` (feat) +3. **Task 3: AIUI delegates the loop to the node when embedded, keeps its own when not** - + `e30ac1d` (feat, **in the external `/home/archipelago/Projects/AIUI` repo, branch + `development` — NOT part of this worktree's git history and NOT yet pushed**) + +**Plan metadata:** this commit (docs: complete plan) — pending, see below. + +## Files Created/Modified + +- `core/archipelago/src/assistant/mod.rs` - `CallerScope`, `PermissionCategory`, `ToolExecCtx`, `chat()` +- `core/archipelago/src/assistant/tools.rs` - `ToolDef`, `ToolRegistry`, `system_disk_status_tool`, schema round-trip test +- `core/archipelago/src/assistant/loop_.rs` - `run_loop`, `execute_tool`, `MAX_TURNS`, the 3 unit tests +- `core/archipelago/src/assistant/backends/mod.rs` - `Backend` trait, `BackendTurn`, `select_backend` +- `core/archipelago/src/assistant/backends/claude.rs` - `ClaudeBackend` (Anthropic Messages API) +- `core/archipelago/src/assistant/backends/scripted.rs` - `ScriptedBackend` (`#[cfg(test)]` only) +- `core/archipelago/src/api/rpc/assistant_chat.rs` - `handle_assistant`, `handle_assistant_chat`, `assistant_dispatch_tool`, `data_dir()` +- `core/archipelago/src/api/rpc/dispatcher.rs` - one guarded `assistant.*` arm +- `core/archipelago/src/api/rpc/middleware.rs` - `UNAUTHENTICATED_METHODS` visibility widened to `pub(crate)` (contents unchanged) +- `core/archipelago/src/api/rpc/mod.rs` - re-export `UNAUTHENTICATED_METHODS`, `mod assistant_chat;` +- `core/archipelago/src/main.rs` - `mod assistant;` +- `neode-ui/src/types/aiui-protocol.ts` - `AIUIChatRequest`, `ArchyChatResponse` +- `neode-ui/src/services/contextBroker.ts` - `handleChatRequest` +- `/home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.ts` - `sendChat` (external repo) +- `/home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.ts` - `streamViaArchy` (external repo) + +## Decisions Made + +- Reset-and-recommit (option a) rather than build-forward-from-WIP (option b): the plan asked me + to prefer (a) because it yields the clean per-task history the plan protocol expects, and + ground-truthing showed the WIP mapped cleanly onto exactly Task 1 + Task 2 with no partial or + wrong work to route around, so there was nothing (a) would have cost. +- Fixed one acceptance-criteria miss found during review before committing: a doc comment in + `backends/claude.rs` literally contained the string `OLLAMA_TIMEOUT` while explaining why it + is *not* reused — this tripped the acceptance criterion's grep even though the intent (don't + reuse the LoRa-tuned constant) was already honored. Reworded the comment to describe the same + thing without the literal identifier. No behavior change. +- Did not touch `.planning/STATE.md`, `.planning/ROADMAP.md`, or `.planning/REQUIREMENTS.md` — + per this session's explicit instruction, the orchestrator owns those writes after the wave + completes (and touching a shared cross-plan file from a parallel worktree risks merge + conflicts with sibling plans in this wave). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug/acceptance-criteria] `OLLAMA_TIMEOUT` literal string tripped a negative grep it was meant to satisfy** +- **Found during:** Task 1 ground-truth review +- **Issue:** `backends/claude.rs`'s doc comment on `ASSISTANT_HTTP_TIMEOUT` explained the constant is "NOT `assist.rs`'s `OLLAMA_TIMEOUT`" — correct in intent, but the acceptance criterion `grep -rnE 'OLLAMA_TIMEOUT|MAX_REPLY_CHARS|CHUNK_CHARS' core/archipelago/src/assistant/` returns no match` is a literal grep and doesn't distinguish "reused" from "mentioned in a comment explaining non-reuse." +- **Fix:** Reworded the comment to convey the same non-reuse rationale without the literal constant name. +- **Files modified:** `core/archipelago/src/assistant/backends/claude.rs` +- **Verification:** `grep -rnE 'OLLAMA_TIMEOUT|MAX_REPLY_CHARS|CHUNK_CHARS' core/archipelago/src/assistant/` now returns no match. +- **Committed in:** `fe6ccff7` (the edit was made before the atomic re-commit, so it landed inside Task 1's commit directly rather than as a separate follow-up) + +--- + +**Total deviations:** 1 auto-fixed (Rule 1, cosmetic/acceptance-criteria only, no behavior change). +**Impact on plan:** None beyond the one comment edit. No scope creep. + +## Issues Encountered + +- **Machine resource contention blocked full test verification.** This session ran alongside at + least one sibling worktree (`p13-02`) also compiling the same large Rust workspace, on a + 4-core box also running a live Archipelago node (bitcoind/electrumx/lnd). Load average peaked + around 46. `cargo build --package archipelago` completed once, cleanly (~22 min). A subsequent + `cargo test --package archipelago assistant::` was attempted twice and was killed both times — + once implicitly by the harness after an extremely long run, once explicitly by me under the + orchestrator's throttle instruction once it flagged the contention. **I did not independently + observe the three named unit tests pass.** I read `loop_.rs`'s test module function-by-function + against the plan's `` spec and believe it is correct, but "I read it and it looks + right" is not the same as "it ran green," and I am reporting that distinction honestly rather + than claiming a verification I did not perform. This is the single biggest open item from this + plan — see WINDOWS.md id 16. +- **git.tx1138.com unreachable for the AIUI push.** DNS resolved (`80.71.235.99`) but every + connection attempt (`curl`, `git ls-remote`, `git push`, with and without sandbox) timed out. + Task 3's commit is safe (a normal local commit in a real, persistent clone at + `/home/archipelago/Projects/AIUI`, branch `development`, commit `e30ac1d`) but not yet visible + to anyone else's checkout of that repo. See WINDOWS.md id 17. + +## User Setup Required + +None - no external service configuration required by this plan itself. (The Claude API key at +`data_dir/secrets/claude-api-key` is a Phase-10-era prerequisite this plan consumes, not one it +introduces.) + +## Next Phase Readiness + +The spine this phase exists to prove is in place and (per the compiled build + the neode-ui/AIUI +test suites) structurally sound: chat request → postMessage → authenticated RPC → curated tool → +real handler → Claude → real answer, with the model key never leaving the node and +`assistant.*` never reachable unauthenticated. + +**Before the next plan builds on this spine:** +1. Run `cargo test --package archipelago assistant::` on an unloaded machine and confirm + `disk_status_tool_executes`, `unknown_tool_is_refused_not_ignored`, and + `assistant_methods_require_session` all pass (WINDOWS.md id 16). +2. Push `/home/archipelago/Projects/AIUI`'s `development` branch (commit `e30ac1d`) to + `git.tx1138.com` once network access is available (WINDOWS.md id 17). +3. The plan's live end-to-end verification line (`assistant.chat` with + `{"text":"how much space is left"}` against a running node, session cookie + CSRF) was not + exercised — no running node was available in this session. Worth doing once 1 and 2 above are + clear. + +--- +*Phase: 13-aiui-functional-conversational-node-control-and-content-surf* +*Completed: 2026-08-03* + +## Self-Check: PASSED + +All 10 files created/modified in this worktree verified present on disk; the external AIUI +`archyBridge.ts` verified present; all 3 archy-worktree commits (`fe6ccff7`, `0ab9bdc7`, +`6efe42d3`) and the external AIUI commit (`e30ac1d`) verified present in their respective +`git log --oneline --all`. No missing items. diff --git a/core/archipelago/src/api/rpc/assistant_chat.rs b/core/archipelago/src/api/rpc/assistant_chat.rs new file mode 100644 index 00000000..afc3e9ac --- /dev/null +++ b/core/archipelago/src/api/rpc/assistant_chat.rs @@ -0,0 +1,81 @@ +//! `assistant.*` RPC surface (D-01/D-02) — the front door onto the shared +//! assistant service in `crate::assistant`. Every later `assistant.*` +//! method (13-05's `list-tools`/`grants-*`, 13-08's `confirm-tool`, 13-10's +//! `history`) is added inside this file; `dispatcher.rs` registers exactly +//! one guarded arm for the whole `assistant.` prefix (see +//! `grep -c 'starts_with("assistant.")' dispatcher.rs` == 1), never a new +//! per-method literal arm. + +use super::RpcHandler; +use anyhow::Result; +use std::sync::Arc; + +impl RpcHandler { + /// Prefix sub-dispatcher for `assistant.*`. Reached only after the + /// caller has already passed the session-cookie + CSRF + + /// `role.can_access()` gate in `api/rpc/mod.rs:264-330` — no bespoke + /// auth here (asserted by + /// `assistant::loop_::tests::assistant_methods_require_session`, which + /// confirms `assistant.*` is absent from `UNAUTHENTICATED_METHODS`). + pub(in crate::api::rpc) async fn handle_assistant( + self: &Arc, + method: &str, + params: Option, + session_token: &Option, + ) -> Result { + match method { + "assistant.chat" => self.handle_assistant_chat(params, session_token).await, + other => anyhow::bail!("no such assistant method: {other}"), + } + } + + /// assistant.chat — a single chat turn from the authenticated local + /// operator. Params: `{ "text": string }`. Returns `{ "text": string }`. + async fn handle_assistant_chat( + self: &Arc, + params: Option, + session_token: &Option, + ) -> Result { + let text = params + .as_ref() + .and_then(|p| p.get("text")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("text is required"))?; + + // The caller's authenticated session identifies this LocalOperator — + // authority is resolved node-side from CallerScope, never from + // anything the browser or the model asserts about itself. + let session_id = session_token.clone().unwrap_or_default(); + let caller = crate::assistant::CallerScope::LocalOperator { session_id }; + + let answer = crate::assistant::chat(Arc::clone(self), caller, text).await?; + Ok(serde_json::json!({ "text": answer })) + } + + /// Internal-only bridge: executes a curated assistant tool against the + /// SAME `RpcHandler` method every authenticated RPC caller dispatches + /// through (never an AI-only backdoor). NOT itself an RPC method — only + /// `assistant::loop_::execute_tool` calls this, and only for tool names + /// present in the curated D-06 registry. + /// + /// Rust module privacy is what requires this thin bridge: + /// `handle_system_disk_status` is `pub(in crate::api::rpc)`, so + /// `crate::assistant` (outside that module subtree) cannot call it + /// directly. This function lives inside `api::rpc` so it CAN call the + /// private handler, and re-exposes only the one curated method name a + /// tool call is allowed to reach — not the general RPC surface. + pub(crate) async fn assistant_dispatch_tool(&self, method: &str) -> Result { + match method { + "system.disk-status" => self.handle_system_disk_status().await, + other => anyhow::bail!("assistant_dispatch_tool: no such handler for {other}"), + } + } + + /// Read-only `data_dir` accessor for `crate::assistant`, which lives + /// outside `api::rpc`'s module tree and so cannot read the private + /// `config` field directly. Minimal, `pub(crate)`, no behavior change. + pub(crate) fn data_dir(&self) -> &std::path::Path { + &self.config.data_dir + } +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 1ad48533..161d49dc 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -444,6 +444,14 @@ impl RpcHandler { "mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await, "mesh.assistant-status" => self.handle_mesh_assistant_status().await, "mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await, + // Phase 13 (D-01/D-02): the whole `assistant.*` surface lives in + // assistant_chat.rs, not as new arms here — this is the ONLY + // dispatcher.rs registration point for it. Every later + // assistant.* method (13-05, 13-08, 13-10) is added inside + // assistant_chat.rs's own match, never as a new arm in this file. + m if m.starts_with("assistant.") => { + self.handle_assistant(m, params, session_token).await + } "mesh.schedule-message" => self.handle_mesh_schedule_message(params).await, "mesh.list-scheduled" => self.handle_mesh_list_scheduled().await, "mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await, diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index a20a184f..027ecef3 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -2,7 +2,13 @@ use crate::session::SessionStore; use std::net::IpAddr; /// Methods that do not require a valid session cookie. -pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[ +/// +/// `pub(crate)` (not just `pub(super)`) so `crate::assistant`'s test suite +/// can assert directly against the live list that the assistant RPC prefix +/// is never added to it (Phase-10 hard constraint) — see the re-export in +/// `api/rpc/mod.rs`. Read-visibility only; the list's contents and every +/// other visibility in this module are unchanged. +pub(crate) const UNAUTHENTICATED_METHODS: &[&str] = &[ "auth.login", "auth.login.totp", "auth.login.backup", diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 7dd8f8c5..bc7e4e1b 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -1,5 +1,6 @@ mod analytics; mod ark; +mod assistant_chat; mod auth; mod backup_rpc; mod bitcoin; @@ -59,9 +60,15 @@ use std::sync::Arc; use tracing::{debug, error}; pub use middleware::PeerAddr; +// Re-exported `pub(crate)` (not just imported) so `crate::assistant`'s test +// suite can assert directly against the live list that `assistant.*` is +// never added to it — the Phase-10 hard constraint this crate must hold. +// The list's *contents* are unchanged; only its read-visibility widens from +// "this module" to "this crate". +pub(crate) use middleware::UNAUTHENTICATED_METHODS; use middleware::{ derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, - CACHEABLE_METHODS, UNAUTHENTICATED_METHODS, + CACHEABLE_METHODS, }; use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse}; diff --git a/core/archipelago/src/assistant/backends/claude.rs b/core/archipelago/src/assistant/backends/claude.rs new file mode 100644 index 00000000..1b961ac4 --- /dev/null +++ b/core/archipelago/src/assistant/backends/claude.rs @@ -0,0 +1,208 @@ +//! The Claude leg of the D-04 backend chain — Anthropic Messages API with +//! `tools`/`tool_use`/`tool_result`. Modeled on +//! `mesh/listener/assist.rs::call_claude`'s HTTP client construction and +//! `api/rpc/mesh/assistant.rs`'s key-path convention, but NOT extended +//! in place: this is a new, tool-calling-capable request/response shape, +//! and its constants are new (AI-SPEC §3 Pitfall 6 — the mesh constants are +//! airtime-tuned for LoRa and must not be reused here). + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::{Backend, BackendTurn}; +use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef}; + +const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages"; +/// Kept in sync with `mesh/listener/assist.rs::CLAUDE_DEFAULT_MODEL` — +/// cheap and already proven fast enough; D-07 makes backend choice a +/// privacy/cost decision, not a capability-need one, so there is no reason +/// to default to a stronger model here. +const CLAUDE_MODEL: &str = "claude-haiku-4-5-20251001"; +/// New, separate constant for the AIUI path's multi-turn tool loop (which +/// may include a network round trip) — deliberately NOT reusing the mesh +/// module's LoRa-airtime-tuned HTTP timeout constant (60s), which is sized +/// for a different transport entirely (AI-SPEC §3 Pitfall 6). +const ASSISTANT_HTTP_TIMEOUT: Duration = Duration::from_secs(180); +/// Raised from mesh's `512` — `tool_use` content blocks and multi-turn +/// reasoning need more headroom. Never left unbounded. +const ASSISTANT_MAX_TOKENS: u32 = 2048; + +pub struct ClaudeBackend { + data_dir: PathBuf, +} + +impl ClaudeBackend { + pub fn new(data_dir: PathBuf) -> Self { + Self { data_dir } + } +} + +#[async_trait] +impl Backend for ClaudeBackend { + async fn send( + &self, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result { + // SAME key path `api/rpc/mesh/assistant.rs` probes — do not + // introduce a second key location (D-01, one key ledger). + let key = tokio::fs::read_to_string(self.data_dir.join("secrets/claude-api-key")) + .await + .map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?; + let key = key.trim(); + if key.is_empty() { + anyhow::bail!("Claude API key is empty"); + } + + let messages: Vec = history.iter().filter_map(message_to_wire).collect(); + + let claude_tools: Vec = tools + .iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "input_schema": t.parameters, + }) + }) + .collect(); + + let mut body = json!({ + "model": CLAUDE_MODEL, + "max_tokens": ASSISTANT_MAX_TOKENS, + "system": system, + "messages": messages, + "stream": false, + }); + if !claude_tools.is_empty() { + body["tools"] = json!(claude_tools); + // AI-SPEC §3 Pitfall 5: every tool_use.id from one assistant + // turn needs a matching tool_result before the next request. + // Disabling parallel tool use sidesteps that bookkeeping — + // D-06's tools are one deliberate action at a time anyway. + body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true}); + } + + let client = reqwest::Client::builder() + .timeout(ASSISTANT_HTTP_TIMEOUT) + .build()?; + let resp = client + .post(CLAUDE_URL) + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let txt = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "Claude API HTTP {}: {}", + status, + txt.chars().take(180).collect::() + ); + } + + let json: Value = resp.json().await?; + let blocks = json + .get("content") + .and_then(|c| c.as_array()) + .cloned() + .unwrap_or_default(); + + let mut tool_calls = Vec::new(); + let mut text = String::new(); + for block in &blocks { + match block.get("type").and_then(|t| t.as_str()) { + Some("tool_use") => { + let id = block + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let name = block + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let arguments = block.get("input").cloned().unwrap_or_else(|| json!({})); + tool_calls.push(ToolCall { + id, + name, + arguments, + }); + } + Some("text") => { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text.push_str(t); + } + } + _ => {} + } + } + + if !tool_calls.is_empty() { + Ok(BackendTurn::ToolCalls(tool_calls)) + } else { + Ok(BackendTurn::Text(text)) + } + } +} + +/// Map one internal `ChatMessage` onto an Anthropic Messages API turn. +/// `Role::System` returns `None` — the system prompt is sent via the +/// top-level `system` field, not as a message in the array. +fn message_to_wire(msg: &ChatMessage) -> Option { + match msg.role { + Role::System => None, + Role::User => Some(json!({ + "role": "user", + "content": msg.text.clone().unwrap_or_default(), + })), + Role::Assistant => { + if !msg.tool_calls.is_empty() { + let blocks: Vec = msg + .tool_calls + .iter() + .map(|c| { + json!({ + "type": "tool_use", + "id": c.id, + "name": c.name, + "input": c.arguments, + }) + }) + .collect(); + Some(json!({"role": "assistant", "content": blocks})) + } else { + Some(json!({ + "role": "assistant", + "content": msg.text.clone().unwrap_or_default(), + })) + } + } + Role::Tool => { + let blocks: Vec = msg + .tool_results + .iter() + .map(|r| { + json!({ + "type": "tool_result", + "tool_use_id": r.call_id, + "content": r.content, + "is_error": r.is_error, + }) + }) + .collect(); + // Anthropic's tool_result blocks travel back as a "user" turn. + Some(json!({"role": "user", "content": blocks})) + } + } +} diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs new file mode 100644 index 00000000..9c0366df --- /dev/null +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -0,0 +1,35 @@ +//! The `Backend` trait — the wire-format-agnostic seam every model backend +//! (Ollama, Claude, Routstr) implements once. The loop and every tool are +//! written against this trait only; wire-format differences live entirely +//! inside each adapter. + +use std::path::Path; + +use anyhow::Result; +use async_trait::async_trait; + +use super::tools::{ChatMessage, ToolCall, ToolDef}; + +pub mod claude; +#[cfg(test)] +pub mod scripted; + +pub enum BackendTurn { + Text(String), + ToolCalls(Vec), +} + +#[async_trait] +pub trait Backend: Send + Sync { + async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result; +} + +/// D-04's backend chain: local Ollama first (node data never leaves the +/// node when a local model is available), then Claude, then Routstr. Only +/// the Claude leg is implemented in this tracer — `backends/ollama.rs` +/// (13-10) and `backends/routstr.rs` (13-13) slot in ahead of and behind it +/// without changing the `Backend` trait; that is the architectural +/// commitment this tracer proves. +pub fn select_backend(data_dir: &Path) -> Box { + Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())) +} diff --git a/core/archipelago/src/assistant/backends/scripted.rs b/core/archipelago/src/assistant/backends/scripted.rs new file mode 100644 index 00000000..9925fc2a --- /dev/null +++ b/core/archipelago/src/assistant/backends/scripted.rs @@ -0,0 +1,44 @@ +//! Test-only backend that replays a canned sequence of turns. Never +//! compiles into the shipped binary — gated by `#![cfg(test)]` here AND by +//! `#[cfg(test)] pub mod scripted;` in `backends/mod.rs`. + +#![cfg(test)] + +use std::sync::Mutex; + +use anyhow::Result; +use async_trait::async_trait; + +use super::{Backend, BackendTurn}; +use crate::assistant::tools::{ChatMessage, ToolDef}; + +pub struct ScriptedBackend { + turns: Mutex>, +} + +impl ScriptedBackend { + /// `turns` are consumed in the order given — the first call to `send()` + /// returns `turns[0]`, the second `turns[1]`, and so on. + pub fn new(turns: Vec) -> Self { + let mut turns = turns; + turns.reverse(); + Self { + turns: Mutex::new(turns), + } + } +} + +#[async_trait] +impl Backend for ScriptedBackend { + async fn send( + &self, + _system: &str, + _tools: &[ToolDef], + _history: &[ChatMessage], + ) -> Result { + let mut turns = self.turns.lock().expect("ScriptedBackend mutex poisoned"); + turns + .pop() + .ok_or_else(|| anyhow::anyhow!("ScriptedBackend exhausted — no more turns queued")) + } +} diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs new file mode 100644 index 00000000..9640dbe2 --- /dev/null +++ b/core/archipelago/src/assistant/loop_.rs @@ -0,0 +1,237 @@ +//! The multi-turn tool-calling loop (D-01/D-02). No analog exists elsewhere +//! in this codebase — this is the first tool-calling agent loop ever +//! written here (confirmed by 13-RESEARCH.md/13-AI-SPEC.md); built directly +//! from `13-AI-SPEC.md` §3/§4's sketch. +//! +//! Concurrency discipline inherited from `mesh/listener/assist.rs`'s own +//! doc comment ("Spawned off the radio loop so it never blocks"): never +//! hold a shared lock across a `.await` that can block for human-response +//! time. `execute_tool` below holds no lock at all in this tracer — there +//! is nothing yet to hold one across (13-08's confirm gate is what +//! introduces that discipline requirement for real). + +use anyhow::Result; + +use super::backends::{Backend, BackendTurn}; +use super::tools::{ChatMessage, Role, ToolCall, ToolResult}; +use super::tools::ToolDef; +use super::ToolExecCtx; + +/// Hard stop — a looping model must never spin unbounded (D-05). +pub const MAX_TURNS: usize = 8; + +pub async fn run_loop( + backend: &dyn Backend, + system: &str, + tools: &[ToolDef], + mut history: Vec, + ctx: &ToolExecCtx, +) -> Result { + for _ in 0..MAX_TURNS { + match backend.send(system, tools, &history).await? { + BackendTurn::Text(answer) => return Ok(answer), + BackendTurn::ToolCalls(calls) => { + history.push(ChatMessage { + role: Role::Assistant, + text: None, + tool_calls: calls.clone(), + tool_results: vec![], + }); + let mut results = Vec::with_capacity(calls.len()); + for call in &calls { + results.push(execute_tool(call, ctx).await); + } + history.push(ChatMessage { + role: Role::Tool, + text: None, + tool_calls: vec![], + tool_results: results, + }); + } + } + } + anyhow::bail!("assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever") +} + +/// The single choke point every tool call passes through, regardless of +/// which backend produced it. Enforces, in order: D-06 (curated allowlist — +/// unknown names are refused, never silently ignored), D-16 (default-closed +/// category grants — re-checked here even though the system prompt already +/// omits ungranted tools; never trust that as the only enforcement layer), +/// schema validation (never coerce, never guess), and D-07 (every +/// destructive tool suspends for confirmation — 13-08 fills that branch in; +/// there are no destructive tools registered yet, so it is unreachable +/// today). +async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResult { + let Some(tool) = ctx.registry.get(&call.name) else { + return ToolResult { + call_id: call.id.clone(), + is_error: true, + content: format!("no such tool: {}", call.name), + }; + }; + + if !ctx.caller.granted_categories().contains(&tool.category) { + return ToolResult { + call_id: call.id.clone(), + is_error: true, + content: "not permitted — this category is not granted".to_string(), + }; + } + + if let Err(e) = tool.validate(&call.arguments) { + return ToolResult { + call_id: call.id.clone(), + is_error: true, + content: format!("invalid arguments: {e}"), + }; + } + + if tool.destructive { + return ToolResult { + call_id: call.id.clone(), + is_error: true, + content: "destructive tool execution is not yet implemented".to_string(), + }; + } + + match call.name.as_str() { + // Dispatches to the SAME RpcHandler method every other authenticated + // caller uses (no AI-only backdoor) — see `assistant_dispatch_tool` + // in `api/rpc/assistant_chat.rs` for why this bridge exists. + "system_disk_status" => match ctx + .handler + .assistant_dispatch_tool("system.disk-status") + .await + { + Ok(v) => ToolResult { + call_id: call.id.clone(), + is_error: false, + content: v.to_string(), + }, + Err(e) => ToolResult { + call_id: call.id.clone(), + is_error: true, + content: format!("tool execution failed: {e}"), + }, + }, + other => ToolResult { + call_id: call.id.clone(), + is_error: true, + content: format!("no execution wired for tool: {other}"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assistant::backends::scripted::ScriptedBackend; + use crate::assistant::tools::{registry, system_disk_status_tool}; + use crate::assistant::{CallerScope, PermissionCategory}; + use crate::api::rpc::RpcHandler; + use serde_json::json; + use std::sync::Arc; + + /// A minimal but real `RpcHandler` for tests: a fresh temp `data_dir` + /// (no `/var/lib/archipelago` writes), no orchestrator (container RPCs + /// aren't exercised here), matching the doc comment on `orchestrator` + /// that this is exactly why the field is `Option`. + async fn test_rpc_handler() -> (Arc, tempfile::TempDir) { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut config = crate::config::Config::default(); + config.data_dir = tmp.path().to_path_buf(); + let state_manager = Arc::new(crate::state::StateManager::new()); + let metrics_store = Arc::new(crate::monitoring::MetricsStore::new()); + let session_store = + crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")); + let handler = RpcHandler::new( + config, + state_manager, + metrics_store, + session_store, + None, + None, + ) + .await + .expect("RpcHandler::new"); + (Arc::new(handler), tmp) + } + + fn local_operator_ctx(handler: Arc) -> ToolExecCtx { + ToolExecCtx { + registry: registry(), + caller: CallerScope::LocalOperator { + session_id: "test-session".to_string(), + }, + handler, + } + } + + #[tokio::test] + async fn disk_status_tool_executes() { + let (handler, _tmp) = test_rpc_handler().await; + + // The real figures the tool path returns must match what the SAME + // handler returns when dispatched directly — proving `execute_tool` + // is not a parallel, AI-only code path. + let direct = handler + .assistant_dispatch_tool("system.disk-status") + .await + .expect("direct dispatch"); + + let ctx = local_operator_ctx(handler.clone()); + let call = ToolCall { + id: "call-1".to_string(), + name: "system_disk_status".to_string(), + arguments: json!({}), + }; + let result = execute_tool(&call, &ctx).await; + assert!(!result.is_error, "tool call errored: {}", result.content); + assert_eq!(result.content, direct.to_string()); + assert!(result.content.contains("total_bytes")); + + // Exercise the whole loop: a ScriptedBackend that names the tool, + // then answers — proving the real figures reached the final answer + // path (the answer itself is the second scripted turn, matching + // AI-SPEC's run_loop shape; the tool result that fed into it is + // asserted above). + let backend = ScriptedBackend::new(vec![ + BackendTurn::ToolCalls(vec![call.clone()]), + BackendTurn::Text("Disk space report generated.".to_string()), + ]); + let tools_list = vec![system_disk_status_tool()]; + let answer = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx) + .await + .expect("run_loop"); + assert_eq!(answer, "Disk space report generated."); + } + + #[tokio::test] + async fn unknown_tool_is_refused_not_ignored() { + let (handler, _tmp) = test_rpc_handler().await; + let ctx = local_operator_ctx(handler); + let call = ToolCall { + id: "call-1".to_string(), + name: "delete_everything".to_string(), + arguments: json!({}), + }; + let result = execute_tool(&call, &ctx).await; + assert!(result.is_error); + assert!(result.content.contains("no such tool"), "{}", result.content); + } + + /// Phase-10 hard constraint: `assistant.*` must never be reachable + /// unauthenticated. Asserted directly against the live list, not + /// assumed. + #[test] + fn assistant_methods_require_session() { + let has_assistant_method = crate::api::rpc::UNAUTHENTICATED_METHODS + .iter() + .any(|m| m.starts_with("assistant.")); + assert!( + !has_assistant_method, + "assistant.* must never be added to UNAUTHENTICATED_METHODS (Phase-10 hard constraint)" + ); + } +} diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs new file mode 100644 index 00000000..8ca3cb53 --- /dev/null +++ b/core/archipelago/src/assistant/mod.rs @@ -0,0 +1,125 @@ +//! D-02: "one assistant, many front doors." A shared assistant service — +//! one curated tool registry, one backend selector, one place the model key +//! lives — used today by AIUI chat (`CallerScope::LocalOperator`) and, by +//! design, extensible to mesh/LoRa callers (`CallerScope::Mesh`) and later +//! Pine voice without recreating a second, divergent security model. +//! +//! This is the tracer slice for Phase 13 (D-01, D-02, D-06): a typed +//! question reaches exactly one curated, read-only tool +//! (`tools::system_disk_status_tool`) via the Claude backend, dispatched +//! through the SAME `handle_system_disk_status` RPC handler every other +//! authenticated caller uses. See `13-01-PLAN.md` for the full spine. + +pub mod backends; +pub mod loop_; +pub mod tools; + +use std::collections::BTreeSet; +use std::sync::Arc; + +use anyhow::Result; + +use crate::api::rpc::RpcHandler; + +/// D-16's ten permission categories. All default-closed on a fresh node — +/// nothing is shared with the model until deliberately granted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PermissionCategory { + Apps, + System, + Network, + Wallet, + Files, + Media, + Search, + AiLocal, + Notes, + Bitcoin, +} + +/// D-02's promoted primary noun: a caller identity carrying the permission +/// scope its tool calls resolve authority through. "A mesh peer" and "the +/// local operator in AIUI" are two variants of it; Pine voice will be a +/// third (not built in this phase — no `Voice` variant exists yet, by +/// design, until that phase actually needs one). +#[derive(Debug, Clone)] +pub enum CallerScope { + /// A mesh/LoRa peer. Not exercised by this plan (mesh's existing + /// `!ai` path is Q&A-only, per `mesh/listener/assist.rs`'s own doc + /// comment) — the variant exists so the shape is right when a future + /// plan wires mesh callers into the shared loop. + Mesh { peer_id: String }, + /// The authenticated operator using AIUI, identified by their neode-ui + /// session. This is the only variant this tracer's `assistant.chat` + /// RPC constructs. + LocalOperator { session_id: String }, +} + +impl CallerScope { + /// The sole source of tool authority `execute_tool` reads. No + /// `execute_tool` branch may read a caller-specific field directly + /// instead of going through this — that would reintroduce the + /// mesh-only assumption D-02 exists to retire. + pub fn granted_categories(&self) -> BTreeSet { + match self { + // 13-05 replaces this hardcoded default with the persisted + // D-16 default-closed grants store — a data-source change, not + // an architectural one (per the plan's assumption-delta note). + CallerScope::LocalOperator { .. } => { + let mut set = BTreeSet::new(); + set.insert(PermissionCategory::System); + set + } + // Intentionally conservative for this tracer: mesh has no + // tool-calling caller path wired up yet (today's mesh `!ai` is + // Q&A-only), so there is no real trusted_only/allowed_contacts + // grant to resolve. A future plan that wires the Mesh variant + // into the shared loop threads those existing per-caller + // controls through here — this is explicitly NOT the place a + // mesh-only field gets read directly by `execute_tool`. + CallerScope::Mesh { .. } => BTreeSet::new(), + } + } +} + +/// Bundles what `execute_tool` needs regardless of which backend produced +/// the tool call: the curated registry, the caller's resolved authority, +/// and a handle back to the SAME `RpcHandler` every other authenticated +/// caller dispatches through — never an AI-only backdoor. +pub struct ToolExecCtx { + pub registry: tools::ToolRegistry, + pub caller: CallerScope, + pub handler: Arc, +} + +/// Entry point: run one chat turn for `caller` through the shared loop. +/// Builds the visible-tool set from the caller's granted categories only +/// (D-16 — the model should never even see a tool it can't use), selects a +/// backend (Claude only, in this tracer), and runs it to a final answer. +pub async fn chat(handler: Arc, caller: CallerScope, user_text: String) -> Result { + let registry = tools::registry(); + let grants = caller.granted_categories(); + let visible_tools = registry.visible_to(&grants); + + let backend = backends::select_backend(handler.data_dir()); + + let system_prompt = "You are the Archipelago node's operator-control assistant. \ + Only use the tools explicitly listed for this turn — never invent a tool name or call \ + one that isn't listed. Every write requires human confirmation you cannot bypass or \ + pre-approve on the user's behalf."; + + let history = vec![tools::ChatMessage { + role: tools::Role::User, + text: Some(user_text), + tool_calls: vec![], + tool_results: vec![], + }]; + + let ctx = ToolExecCtx { + registry, + caller, + handler, + }; + + loop_::run_loop(backend.as_ref(), system_prompt, &visible_tools, history, &ctx).await +} diff --git a/core/archipelago/src/assistant/tools.rs b/core/archipelago/src/assistant/tools.rs new file mode 100644 index 00000000..92cf01ce --- /dev/null +++ b/core/archipelago/src/assistant/tools.rs @@ -0,0 +1,170 @@ +//! D-06: a curated, hand-written tool registry. Never derived from +//! `api::rpc::dispatcher`'s method table — every capability the chat has is +//! a deliberate decision recorded here, and the model never sees the full +//! RPC surface. No `schemars` — that crate is absent from `Cargo.toml` and +//! from 13-RESEARCH.md's Package Legitimacy Audit, so `parameters` below is +//! a hand-written JSON Schema object literal instead. + +use std::collections::{BTreeSet, HashMap}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::PermissionCategory; + +/// The backend-agnostic in/out of a tool invocation — the same shape +/// regardless of which adapter (Ollama/Claude/Routstr) produced it. +#[derive(Debug, Clone)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolResult { + pub call_id: String, + pub content: String, + pub is_error: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: Role, + /// Plain text, or (a future plan's) D-10-wrapped untrusted content. + pub text: Option, + /// Assistant-authored tool calls made THIS turn (role: Assistant). + pub tool_calls: Vec, + /// Tool results fed back THIS turn (role: Tool). + pub tool_results: Vec, +} + +/// D-06: one curated, hand-written tool. Never generated from the RPC +/// dispatcher — the curated set IS the D-09 authority boundary. +#[derive(Clone)] +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// JSON Schema `{"type":"object","properties":{...},"required":[...]}`, + /// hand-written and pinned adjacent to the args struct it must never + /// drift from — see `disk_status_schema_round_trips_required_keys`. + pub parameters: Value, + pub category: PermissionCategory, + /// D-07: true => confirm gate, no exceptions. There are no destructive + /// tools in this tracer's registry; `execute_tool` refuses this branch + /// with a not-yet-implemented error until 13-08 fills it in. + pub destructive: bool, +} + +/// Args for `system_disk_status` — takes no parameters. +#[derive(Debug, Deserialize)] +pub struct SystemDiskStatusArgs {} + +impl ToolDef { + /// Deserialize + validate model-produced arguments before ANY + /// execution. Never coerce, never guess, never panic on a mismatch — + /// refuse and let the caller turn the error into a tool result the + /// model can recover from. + /// + /// This tracer's registry has exactly one tool, so this is a direct + /// deserialize; a future plan adding a second tool dispatches by + /// `self.name` here before deserializing into that tool's own args type. + pub fn validate(&self, raw: &Value) -> Result { + serde_json::from_value(raw.clone()) + .context("tool arguments did not match the declared schema") + } +} + +/// `system_disk_status` — category `System`, read-only. Reports free and +/// total disk space on this node via the same `system.disk-status` handler +/// every other authenticated caller uses. +pub fn system_disk_status_tool() -> ToolDef { + ToolDef { + name: "system_disk_status", + description: "Report free and total disk space on this Archipelago node.", + parameters: json!({ + "type": "object", + "properties": {}, + "required": [], + }), + category: PermissionCategory::System, + destructive: false, + } +} + +/// D-06's curated allowlist, name-indexed. +pub struct ToolRegistry { + tools: HashMap<&'static str, ToolDef>, +} + +impl ToolRegistry { + pub fn get(&self, name: &str) -> Option<&ToolDef> { + self.tools.get(name) + } + + /// The subset of the registry visible to a caller with `grants`. D-16: + /// an unconfigured node's system prompt should advertise close to zero + /// tools — the model should never even see a tool it can't use. + pub fn visible_to(&self, grants: &BTreeSet) -> Vec { + self.tools + .values() + .filter(|t| grants.contains(&t.category)) + .cloned() + .collect() + } +} + +/// The curated D-06 registry. This tracer registers exactly one tool. +pub fn registry() -> ToolRegistry { + let mut tools = HashMap::new(); + let tool = system_disk_status_tool(); + tools.insert(tool.name, tool); + ToolRegistry { tools } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The schema sent to the model and the struct used to deserialize its + /// output must never silently drift apart. Round-trip the schema's + /// declared `required` keys through the args struct. + #[test] + fn disk_status_schema_round_trips_required_keys() { + let tool = system_disk_status_tool(); + let required = tool + .parameters + .get("required") + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + + let mut obj = serde_json::Map::new(); + for key in &required { + if let Some(k) = key.as_str() { + obj.insert(k.to_string(), Value::Null); + } + } + let value = Value::Object(obj); + let parsed: Result = serde_json::from_value(value); + assert!(parsed.is_ok(), "schema/args struct drift: {:?}", parsed.err()); + } + + #[test] + fn registry_visible_to_respects_grants() { + let reg = registry(); + let mut grants = BTreeSet::new(); + assert!(reg.visible_to(&grants).is_empty()); + grants.insert(PermissionCategory::System); + assert_eq!(reg.visible_to(&grants).len(), 1); + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 196d40f5..aeb55b4f 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -27,6 +27,7 @@ use tracing::info; mod api; mod app_ops; +mod assistant; mod auth; mod avatar; mod backup; diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 30b7dec2..1ae55e8c 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -5,6 +5,7 @@ import type { AIContextCategory, ArchyContextResponse, ArchyActionResponse, + ArchyChatResponse, } from '@/types/aiui-protocol' import { useAIPermissionsStore } from '@/stores/aiPermissions' import { useAppStore } from '@/stores/app' @@ -81,6 +82,37 @@ export class ContextBroker { case 'theme:request': this.sendTheme() break + case 'chat:request': + this.handleChatRequest(msg.id, msg.text) + break + } + } + + // Note: no permission category is threaded through here on purpose. + // Authority for a chat turn is resolved node-side from the RPC session's + // CallerScope (assistant.chat, core/archipelago/src/assistant/mod.rs) — + // duplicating a browser-side gate here would recreate the second, + // divergent security model D-02 exists to prevent. Do not "helpfully" + // add a permission check back into this handler. + private async handleChatRequest(id: string, text: string) { + try { + const result = await rpcClient.call<{ text: string }>({ + method: 'assistant.chat', + params: { text }, + }) + this.postToIframe({ + type: 'chat:response', + id, + success: true, + text: result.text, + } satisfies ArchyChatResponse) + } catch (err) { + this.postToIframe({ + type: 'chat:response', + id, + success: false, + error: err instanceof Error ? err.message : 'Chat request failed', + } satisfies ArchyChatResponse) } } diff --git a/neode-ui/src/types/aiui-protocol.ts b/neode-ui/src/types/aiui-protocol.ts index a1aa2361..d799ba0a 100644 --- a/neode-ui/src/types/aiui-protocol.ts +++ b/neode-ui/src/types/aiui-protocol.ts @@ -45,11 +45,24 @@ export interface AIUIThemeRequest { type: 'theme:request' } +/** + * A chat turn from AIUI's embedded-mode client. Carries only the raw user + * text — tool selection is node-side (D-01/D-03) and must never be + * expressible as an AIUI-originated action, so this is deliberately NOT an + * `AIActionType` member. + */ +export interface AIUIChatRequest { + type: 'chat:request' + id: string + text: string +} + export type AIUIRequest = | AIUIContextRequest | AIUIActionRequest | AIUIReadyMessage | AIUIThemeRequest + | AIUIChatRequest // ─── Archy → AIUI (Responses) ────────────────────────────────────────────── @@ -81,11 +94,22 @@ export interface ArchyPermissionsUpdate { categories: AIContextCategory[] } +/** The node's answer to a `chat:request`. On RPC failure, `error` carries + * only the error message — never the raw exception object. */ +export interface ArchyChatResponse { + type: 'chat:response' + id: string + success: boolean + text?: string + error?: string +} + export type ArchyResponse = | ArchyContextResponse | ArchyActionResponse | ArchyThemeResponse | ArchyPermissionsUpdate + | ArchyChatResponse // ─── All messages ───────────────────────────────────────────────────────────