Files

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 10 execute 4
13-08
core/archipelago/src/assistant/backends/ollama.rs
core/archipelago/src/assistant/backends/mod.rs
core/archipelago/src/assistant/history.rs
core/archipelago/src/assistant/mod.rs
core/archipelago/src/api/rpc/assistant_chat.rs
true
AIUI-01
truths artifacts key_links
Node data never leaves the node when a local model is available: Ollama is tried first, Claude second (D-04)
The local model gets tools, and its writes clear the same confirm gate as any other backend — so a mis-called tool from a weak model surfaces as a prompt the user rejects, not a wrong action (D-07)
Chat history lives node-side under data_dir, inheriting the node's backup, factory-reset and future LUKS story rather than growing a second sensitive-data location (D-08)
History is scoped by caller identity and permission scope, so an operator's AIUI session and a mesh peer's query never see each other's transcript (D-02)
A transcript that outgrows the local model's context window is compacted, not truncated mid-turn, and the compaction summary is regenerated incrementally rather than from scratch
path provides contains
core/archipelago/src/assistant/backends/ollama.rs Ollama /api/chat tool-calling adapter — a different endpoint and request shape from assist.rs::call_ollama api/chat
path provides contains
core/archipelago/src/assistant/history.rs D-08 node-side chat persistence under data_dir, scoped by CallerScope, with compaction pub struct History
from to via pattern
core/archipelago/src/assistant/backends/mod.rs core/archipelago/src/assistant/backends/ollama.rs select_backend tries Ollama before Claude, per D-04's order OllamaBackend
from to via pattern
core/archipelago/src/assistant/history.rs core/archipelago/src/assistant/mod.rs History keyed by CallerScope, the promoted primary from 13-01 CallerScope
Complete D-04's local-first half and D-08's persistence.

Ollama. mesh/listener/assist.rs::call_ollama posts a bare prompt string to /api/generate with no tools field — that endpoint has no tool-calling support at all. The new adapter is a different endpoint (/api/chat), a different request shape (a messages array plus a tools array), and a different response shape (message.tool_calls). Do not extend call_ollama in place. Two cross-provider gotchas are in play and are the reason the ToolCall normalization lives at the adapter edge: Ollama returns tool-call arguments as an already-parsed object (unlike OpenAI-shape, which returns a JSON-encoded string), and Ollama gives tool calls no id field, so the adapter must synthesize a stable per-turn id or the loop's result-matching breaks silently.

Weak local models are accepted, not chased. A small model will hallucinate tool names, omit required arguments and emit malformed JSON far more often than Claude. D-07's mitigation is that every destructive call passes the same confirm gate regardless of backend — so this is a UX/latency concern, not a security gap, and the fix is never a prompt trick.

History (D-08). The transcript lives under data_dir, inheriting the node's backup, factory-reset and future LUKS story rather than growing a second sensitive-data location. It is keyed by caller identity and permission scope, so an operator's AIUI session and a mesh peer's !ai query never see each other's history. Pending confirmations remain in-memory only — that is 13-08's structural property and this plan must not accidentally give them a persistence path by writing the whole ToolExecCtx.

Output: backends/ollama.rs, the D-04 chain wired in order, history.rs, and assistant.history.

<flagged_assumptions> qwen2.5-coder tool-capability is [ASSUMED]. AI-SPEC §4 flags that assist.rs's DEFAULT_MODEL = "qwen2.5-coder" has not been confirmed tool-capable. Task 1 checks the configured model's capability at runtime and degrades to the next backend rather than silently producing tool-free answers; the check, not the assumption, is what ships. </flagged_assumptions>

<artifacts_this_phase_produces> Symbols created by this plan:

  • assistant/backends/ollama.rs: pub struct OllamaBackend, fn synthesize_call_id, fn model_supports_tools, const OLLAMA_CHAT_URL, const OLLAMA_NUM_PREDICT
  • assistant/backends/mod.rs: select_backend extended with the Ollama leg, pub enum BackendId
  • assistant/history.rs: pub struct History, pub struct HistoryKey, History::load, History::append, History::recent, History::compact, const KEEP_VERBATIM_TURNS, const MAX_TOOL_RESULT_CHARS
  • api/rpc/assistant_chat.rs: handle_assistant_history, handle_assistant_clear_history
  • New RPC method names: assistant.history, assistant.clear-history (through 13-01's existing assistant. arm — dispatcher.rs is not touched) </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-08-SUMMARY.md Task 1: Ollama tool-calling, first in the D-04 chain core/archipelago/src/assistant/backends/ollama.rs, core/archipelago/src/assistant/backends/mod.rs - A turn with tools produces a request to the chat endpoint carrying a `messages` array and a `tools` array — never the generate endpoint and never a bare prompt string. - A response containing tool calls maps to `BackendTurn::ToolCalls`, with each call assigned a non-empty, unique-within-the-turn id even though the wire response carries none. - Tool-call arguments arrive as an already-parsed object and are passed through without a second string-parse. - A response with only text maps to `BackendTurn::Text`. - Every turn that may emit a tool call is requested non-streaming, so arguments are complete before validation. - The generation length cap is set explicitly on every request; it is never left unbounded. - `select_backend` returns Ollama when Ollama is reachable and its configured model reports tool capability; it falls through to Claude when Ollama is unreachable, and also when the configured model is reachable but not tool-capable. - An Ollama transport error falls through to the next backend rather than failing the turn. - `core/archipelago/src/mesh/listener/assist.rs` lines 429-451 (`call_ollama`) — `13-PATTERNS.md` marks this an **exact** analog for the HTTP client construction and a **do-not-copy** for everything else: the endpoint, the body shape and the constants all change. Read `run_assist`'s catch-and-fall-back-to-next-backend handling too; that is the model for the D-04 chain. - `core/archipelago/src/api/rpc/mesh/assistant.rs` lines 164-192 — `detect_ollama()`, which already reports `ollama_detected` and `models`. **Reuse it rather than re-probing.** - `core/archipelago/src/assistant/backends/mod.rs` and `backends/claude.rs` from 13-01 — the `Backend` trait, `BackendTurn`, and the `select_backend` seam the tracer left for exactly this. - `.planning/phases/13-.../13-AI-SPEC.md` §3 Pitfalls 1, 2, 3, 4 and 6, and §4 "Model Configuration" (Ollama). Create `core/archipelago/src/assistant/backends/ollama.rs` implementing the `Backend` trait against Ollama's chat endpoint. Build the request with a `messages` array mapped from `ChatMessage`, and a `tools` array whose entries wrap each `ToolDef`'s name, description and `parameters` in Ollama's function-tool envelope. Request non-streaming for every turn while the loop is still deciding whether a tool is being called — partial JSON tool arguments cannot be structurally validated mid-stream. Set the generation-length cap explicitly in the request options; never leave it unbounded.

Parse message.tool_calls into BackendTurn::ToolCalls. Ollama's response carries no id per call, so synthesize_call_id assigns a monotonically increasing per-turn id — leaving it empty makes the loop's result-matching fail silently, which is worse than failing loudly. Ollama's function.arguments is an already-parsed object: assign it straight into ToolCall.arguments; do not run a string-parse over it. That normalization belongs here, at the adapter edge, so the shared loop stays wire-agnostic.

Define new module constants for the chat URL and the generation cap. Do not import OLLAMA_TIMEOUT, MAX_REPLY_CHARS or CHUNK_CHARS from assist.rs — those are LoRa-airtime-tuned and would either under-time-out a multi-turn loop or truncate a chat answer that has no reason to be capped.

model_supports_tools queries the configured model's capability through Ollama's own model-info endpoint and caches the answer for the process lifetime. This is what turns AI-SPEC's [ASSUMED] note about qwen2.5-coder into a runtime fact: a model that cannot call tools is not silently used as the assistant's primary, it falls through to Claude, and the fall-through reason is logged and surfaced.

Extend select_backend in backends/mod.rs to D-04's order — Ollama, then Claude, with the Routstr slot left where 13-13 will insert it. Reuse detect_ollama() rather than writing a second probe. A transport error at any leg falls through to the next, matching run_assist's existing behaviour.

Write the tests FIRST, one per <behavior> bullet, using a local HTTP stub for the Ollama endpoint. Name them under assistant::backends::ollama::tests::, including ollama_uses_chat_endpoint_not_generate, tool_calls_get_synthesized_ids, arguments_object_is_not_string_parsed, and non_tool_capable_model_falls_through_to_claude. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::backends:: 2>&1 | tail -25 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago ollama_uses_chat_endpoint_not_generate <acceptance_criteria>

  • cd core && cargo test --package archipelago assistant::backends:: exits 0 with a test per <behavior> bullet
  • grep -c 'api/generate' core/archipelago/src/assistant/backends/ollama.rs returns 0
  • grep -q 'api/chat' core/archipelago/src/assistant/backends/ollama.rs
  • `grep -rncE 'OLLAMA_TIMEOUT|MAX_REPLY_CHARS|CHUNK_CHARS' core/archipelago/src/assistant/ | grep -vq ':[1-9]' — no airtime-tuned constant is imported into the assistant module
  • grep -q 'synthesize_call_id' core/archipelago/src/assistant/backends/ollama.rs and the test asserts ids are non-empty and unique within a turn
  • grep -c 'from_str' core/archipelago/src/assistant/backends/ollama.rs returns 0 — Ollama's arguments are not string-parsed
  • grep -q 'detect_ollama' core/archipelago/src/assistant/backends/mod.rs — the existing probe is reused, not duplicated
  • grep -q 'model_supports_tools' core/archipelago/src/assistant/backends/ollama.rs </acceptance_criteria> A backend adapter behind an existing trait; adding or reordering legs of the chain is additive. A local model gets tools through the chat endpoint with synthesized call ids and an explicit generation cap; a non-tool-capable or unreachable Ollama falls through to Claude with a logged reason instead of silently degrading the assistant.
Task 2: Node-side history, scoped by caller, compacted rather than truncated core/archipelago/src/assistant/history.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs - A completed turn is appended to a transcript stored under `data_dir` and survives a daemon restart. - An operator's AIUI transcript and a mesh peer's transcript are separate: reading one never returns a turn from the other. - A tool result longer than the cap is truncated before it enters history, with a visible marker that it was truncated. - Once the transcript exceeds the verbatim window, older turns fold into a running summary and the recent window stays verbatim; the summary is extended incrementally rather than regenerated from the full transcript. - `assistant.history` returns only the calling session's own transcript. - `assistant.clear-history` removes the calling session's transcript and nothing else. - No pending confirmation and no tool argument value from a `wallet`- or `files`-category tool is written to the transcript file. - The transcript file is created 0600. - `core/archipelago/src/streaming/session.rs` — `13-PATTERNS.md`'s role-match analog for `data_dir`-scoped persisted state: its load/save shape, its file permissions, its error handling. - `core/archipelago/src/assistant/mod.rs` — `CallerScope` (13-01's promoted primary) and `PermissionCategory`. `HistoryKey` is derived from `CallerScope`, which is what makes the per-caller separation fall out of the type rather than out of a convention. - `core/archipelago/src/assistant/confirm.rs` (13-08) — read it to confirm you are not giving pending confirmations a persistence path by serializing something that reaches them. S-09 is structural and this task must not weaken it. - `.planning/phases/13-.../13-AI-SPEC.md` §4 "State Management" and §4b.4 "Context Window Management" (truncate tool results with an assistant-scoped constant, keep the last K turns verbatim, fold older turns into an incrementally-regenerated summary, and budget conservatively when the model's context length is unknown). - `.planning/phases/13-.../13-AI-SPEC.md` §7b — the field policy: what may and may not be emitted. It applies to the transcript as well as to the logs. Create `core/archipelago/src/assistant/history.rs`. `HistoryKey` is derived from `CallerScope` so a mesh peer's transcript and the local operator's transcript are structurally distinct files or structurally distinct keys — not two rows distinguished by a field someone could forget to filter on. Store under `data_dir` with 0600 permissions, following `streaming/session.rs`'s conventions, and write atomically (temp sibling plus rename) so a crash mid-append cannot corrupt a transcript.

MAX_TOOL_RESULT_CHARS is a new, assistant-scoped constant — a long log tail or directory listing is truncated with a visible marker before it becomes a ToolResult in history. Do not reuse the mesh reply cap; it is airtime-tuned, not context-window-tuned.

compact keeps the last KEEP_VERBATIM_TURNS turns verbatim and folds older turns into a running summary, extending the existing summary with the turns that just aged out rather than re-summarizing the whole transcript — otherwise the summarization cost itself grows without bound. Generate the summary with the already-selected backend, preferring the local one when it is available: this is a sub-task, and D-04's chain is already the cost lever. When the configured model's context length is not discoverable, assume a conservative window and truncate proactively rather than letting a request fail mid-loop.

Apply AI-SPEC §7b's field policy to what is persisted: never write a tool's raw argument values for a wallet- or files-category tool, and never write anything reachable from the pending-confirmation state. Record tool name, category, outcome and a truncated result instead. A transcript is a sensitive-data location by definition, which is exactly why D-08 puts it where the node's backup and factory-reset story already reaches.

Wire mod.rs's chat() to append each completed turn, and add handle_assistant_history and handle_assistant_clear_history to assistant_chat.rs — both scoped to the calling session's own HistoryKey, both routed through 13-01's existing assistant. arm. Do not touch dispatcher.rs.

Write the tests FIRST, one per <behavior> bullet, under assistant::history::tests::. Name the isolation case operator_and_mesh_transcripts_are_separate and the redaction case wallet_tool_arguments_never_reach_the_transcript. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago operator_and_mesh_transcripts_are_separate cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs <acceptance_criteria>

  • cd core && cargo test --package archipelago assistant:: exits 0 with a test per <behavior> bullet
  • grep -q 'pub struct History' core/archipelago/src/assistant/history.rs and grep -q 'CallerScope' core/archipelago/src/assistant/history.rs
  • grep -cE '0o600|from_mode' core/archipelago/src/assistant/history.rs ≥ 1
  • grep -cE 'rename' core/archipelago/src/assistant/history.rs ≥ 1 — the append is atomic
  • grep -q 'MAX_TOOL_RESULT_CHARS' core/archipelago/src/assistant/history.rs and grep -c 'MAX_REPLY_CHARS' core/archipelago/src/assistant/history.rs returns 0
  • cd core && cargo test --package archipelago assistant::confirm::tests::restart_drops_pending_not_executes still passes — S-09 was not weakened
  • cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs exits 0 </acceptance_criteria> Transcripts persist under data_dir per caller scope, survive restarts, stay inside a bounded context budget through incremental compaction, and never carry a wallet/files argument value or a pending confirmation.

<threat_model>

Trust Boundaries

Boundary Description
node → 127.0.0.1:11434 Loopback only; nothing leaves the node on the Ollama leg
node → api.anthropic.com The fall-through leg; the only egress in this plan
transcript → disk A new sensitive-data location, deliberately placed inside data_dir so backup/factory-reset/LUKS already cover it
one caller's transcript → another caller Mesh peers and the local operator share the service but must not share history

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-61 Information Disclosure A mesh peer reading the operator's transcript high mitigate HistoryKey derives from CallerScope, so separation is structural rather than a filter someone can forget. Asserted by operator_and_mesh_transcripts_are_separate
T-13-62 Information Disclosure A secret or a sensitive path landing in a persisted transcript high mitigate AI-SPEC §7b field policy applied to persistence: no wallet/files argument values, no pending-confirmation state, truncated results. Asserted by wallet_tool_arguments_never_reach_the_transcript
T-13-63 Information Disclosure Transcript world-readable on disk high mitigate 0600 under data_dir, following streaming/session.rs. Asserted by grep
T-13-64 Information Disclosure Node data escalated to a cloud backend when the local model could have answered high mitigate D-04 order enforced in select_backend with Ollama first; the escalation payload minimality guardrail (G-B2/E-04) lands in 13-12 and is named there, not assumed here
T-13-65 Tampering A weak local model's malformed tool call coerced into an execution medium mitigate D-07: the same confirm gate and the same validate run regardless of backend. Accepted as a UX cost per AI-SPEC §3 Pitfall 4 — not chased with prompt tricks
T-13-66 Tampering Silent loop breakage from empty Ollama tool-call ids medium mitigate synthesize_call_id assigns non-empty unique ids; asserted by tool_calls_get_synthesized_ids
T-13-67 Denial of Service Unbounded generation length, or a summarization cost that grows with the transcript medium mitigate Explicit generation cap on every Ollama request; compaction extends the summary incrementally instead of re-summarizing the whole transcript
T-13-68 Repudiation A non-tool-capable model silently answering without tools, so the assistant looks broken rather than misconfigured low mitigate model_supports_tools checks at runtime, falls through to Claude, and logs the reason. This retires AI-SPEC §4's [ASSUMED] on qwen2.5-coder with a check rather than a guess
T-13-69 Tampering Pending confirmations gaining a persistence path via history serialization high mitigate Nothing reachable from the pending-confirmation state is serialized; 13-08's S-09 test is re-run as an acceptance criterion of this plan
T-13-SC Tampering npm/pip/cargo installs high mitigate Zero packages added. No install task, so no legitimacy checkpoint required
</threat_model>
- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green, including 13-08's confirm suite - `grep -c 'api/generate' core/archipelago/src/assistant/backends/ollama.rs` == 0 - `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 - With Ollama running and tool-capable, a read question is answered locally; with Ollama stopped, the same question falls through to Claude and the fall-through is logged

<success_criteria> The assistant is local-first in fact rather than in intent, the local model gets real tools behind the same gate as every other backend, and the transcript lives exactly where D-08 put it — per caller, bounded, atomic, and carrying nothing the field policy forbids. </success_criteria>

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-10-SUMMARY.md` when done