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 |
|
|
true |
|
|
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_PREDICTassistant/backends/mod.rs:select_backendextended with the Ollama leg,pub enum BackendIdassistant/history.rs:pub struct History,pub struct HistoryKey,History::load,History::append,History::recent,History::compact,const KEEP_VERBATIM_TURNS,const MAX_TOOL_RESULT_CHARSapi/rpc/assistant_chat.rs:handle_assistant_history,handle_assistant_clear_history- New RPC method names:
assistant.history,assistant.clear-history(through 13-01's existingassistant.arm —dispatcher.rsis 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>bulletgrep -c 'api/generate' core/archipelago/src/assistant/backends/ollama.rsreturns 0grep -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.rsand the test asserts ids are non-empty and unique within a turngrep -c 'from_str' core/archipelago/src/assistant/backends/ollama.rsreturns 0 — Ollama's arguments are not string-parsedgrep -q 'detect_ollama' core/archipelago/src/assistant/backends/mod.rs— the existing probe is reused, not duplicatedgrep -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.
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>bulletgrep -q 'pub struct History' core/archipelago/src/assistant/history.rsandgrep -q 'CallerScope' core/archipelago/src/assistant/history.rsgrep -cE '0o600|from_mode' core/archipelago/src/assistant/history.rs≥ 1grep -cE 'rename' core/archipelago/src/assistant/history.rs≥ 1 — the append is atomicgrep -q 'MAX_TOOL_RESULT_CHARS' core/archipelago/src/assistant/history.rsandgrep -c 'MAX_REPLY_CHARS' core/archipelago/src/assistant/history.rsreturns 0cd core && cargo test --package archipelago assistant::confirm::tests::restart_drops_pending_not_executesstill passes — S-09 was not weakenedcd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rsexits 0 </acceptance_criteria> Transcripts persist underdata_dirper 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> |
<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