merge(13-01): AIUI tracer slice — assistant spine + chat over the origin-checked bridge
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-3
@@ -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
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
+274
@@ -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 `<action>` 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.
|
||||
@@ -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<Self>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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<BackendTurn> {
|
||||
// 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<Value> = history.iter().filter_map(message_to_wire).collect();
|
||||
|
||||
let claude_tools: Vec<Value> = 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::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<Value> = 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<Value> = 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}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ToolCall>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Backend: Send + Sync {
|
||||
async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result<BackendTurn>;
|
||||
}
|
||||
|
||||
/// 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<dyn Backend> {
|
||||
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf()))
|
||||
}
|
||||
@@ -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<Vec<BackendTurn>>,
|
||||
}
|
||||
|
||||
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<BackendTurn>) -> 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<BackendTurn> {
|
||||
let mut turns = self.turns.lock().expect("ScriptedBackend mutex poisoned");
|
||||
turns
|
||||
.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("ScriptedBackend exhausted — no more turns queued"))
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage>,
|
||||
ctx: &ToolExecCtx,
|
||||
) -> Result<String> {
|
||||
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<RpcHandler>, 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<RpcHandler>) -> 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PermissionCategory> {
|
||||
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<RpcHandler>,
|
||||
}
|
||||
|
||||
/// 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<RpcHandler>, caller: CallerScope, user_text: String) -> Result<String> {
|
||||
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
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Assistant-authored tool calls made THIS turn (role: Assistant).
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
/// Tool results fed back THIS turn (role: Tool).
|
||||
pub tool_results: Vec<ToolResult>,
|
||||
}
|
||||
|
||||
/// 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<SystemDiskStatusArgs> {
|
||||
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<PermissionCategory>) -> Vec<ToolDef> {
|
||||
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<SystemDiskStatusArgs, _> = 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);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod assistant;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user