docs(13-13): complete Routstr backend / D-05 budget ceiling plan
D-04's chain complete (Ollama -> Claude -> Routstr); D-05's prepaid allowance is a hard arithmetic ceiling, verified by fault injection. Task 1 decision: proceed-docs-with-probe-first (0/9 protocol claims independently confirmed; first live call doubles as the capability probe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8ba6041251
commit
63fa8b4558
@@ -378,7 +378,7 @@ Plans:
|
||||
|
||||
**Wave 6**
|
||||
|
||||
- [ ] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01)
|
||||
- [x] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01)
|
||||
|
||||
**Wave 7**
|
||||
|
||||
|
||||
+16
-10
@@ -5,15 +5,15 @@ milestone_name: milestone
|
||||
current_phase: 13
|
||||
current_phase_name: aiui-functional-conversational-node-control-and-content-surf
|
||||
status: executing
|
||||
stopped_at: "Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices). Next: wave 6 (13-13)."
|
||||
last_updated: "2026-08-06T03:13:08.168Z"
|
||||
last_activity: 2026-08-05
|
||||
last_activity_desc: 13-10 complete (D-04 Ollama tool-calling backend + D-08 node-side chat history)
|
||||
stopped_at: "Completed 13-13-PLAN.md (Routstr backend adapter + D-05 prepaid budget arithmetic ceiling — D-04 chain complete: Ollama -> Claude -> Routstr). Next: 13-14."
|
||||
last_updated: "2026-08-06T05:40:00.000Z"
|
||||
last_activity: 2026-08-06
|
||||
last_activity_desc: 13-13 complete (Routstr backend — Nostr discovery, OpenAI chat, Cashu payment via auto_pay_token; AssistantBudget arithmetic ceiling, BudgetExhausted stop, D-04 chain complete)
|
||||
progress:
|
||||
total_phases: 13
|
||||
completed_phases: 2
|
||||
total_plans: 60
|
||||
completed_plans: 50
|
||||
completed_plans: 51
|
||||
percent: 15
|
||||
---
|
||||
|
||||
@@ -29,11 +29,11 @@ See: .planning/PROJECT.md (updated 2026-07-29)
|
||||
## Current Position
|
||||
|
||||
Phase: 13 (aiui-functional-conversational-node-control-and-content-surf) — EXECUTING
|
||||
Plan: 12 of 15 complete (13-01, 13-02, 13-03, 13-04, 13-05, 13-06, 13-07, 13-08, 13-09, 13-10) — next: wave 4 remainder (13-11)
|
||||
Plan: 13 of 15 complete (13-01..13-13) — next: 13-14
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-08-05 — 13-10 complete (D-04 Ollama tool-calling backend + D-08 node-side chat history)
|
||||
Last activity: 2026-08-06 — 13-13 complete (Routstr backend — Nostr discovery, OpenAI chat, Cashu payment; AssistantBudget arithmetic ceiling; D-04 chain complete)
|
||||
|
||||
Progress: [████████░░] 83%
|
||||
Progress: [████████░░] 87%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -69,6 +69,7 @@ Progress: [████████░░] 83%
|
||||
| Phase 13 P10 | ~2h45m | 2 tasks | 10 files |
|
||||
| Phase 13 P11 | 27min | 3 tasks | 12 files |
|
||||
| Phase 13 P12 | ~4h35m (shared-box compute contention) | 3 tasks | 9 files |
|
||||
| Phase 13 P13 | ~4h (shared-box compute contention, session crash-recovered mid-Task-3) | 3 tasks | 6 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -146,6 +147,9 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
|
||||
- [Phase ?]: 13-12: wrap_untrusted's per-call token is drawn fresh from rand on every call (never a module constant) — a forged closing boundary using a guessed/fixed token can never match, defeating EV-11 by construction; no pattern-stripping filter added anywhere in assistant/ (D-10 rejects that approach by name)
|
||||
- [Phase ?]: 13-12: assistant.chat's G-B3 rate limit is keyed by authenticated SESSION id (not client IP) via a new session_requests map on the existing EndpointRateLimiter — 13-AI-SPEC §6 is explicit that per-session, not per-IP, is the guardrail's own spec
|
||||
- [Phase ?]: 13-12: screen_outbound (G-B1/G-B2) wired into backends/claude.rs's send() and the assistant.chat rate limit wired into api/rpc/assistant_chat.rs — both Rule 3 deviations outside their task's declared file list, since the plan's own stated behavior (run on the Claude leg / rate-limited per session) had no real call site otherwise
|
||||
- [Phase 13, 13-13 Task 1 (2026-08-05, operator via AskUserQuestion)]: Routstr decision = proceed-docs-with-probe-first (0/9 protocol claims independently confirmed per 13-ROUTSTR-FINDINGS.md — no live provider was reachable during the 13-03 spike). Implemented against docs.routstr.com's cited shape (kind 38421, `Authorization: Bearer cashuA…`, OpenAI-shape chat completions); the first live HTTP call to any provider doubles as the capability probe and fails loudly (real status/body, or "no choices array") on any wrong guess rather than silently misbehaving. D-04's chain is now complete: Ollama -> Claude -> Routstr
|
||||
- [Phase 13, 13-13]: D-05's budget ceiling (`AssistantBudget`) is computed ONLY from persisted allowance_sats/spent_sats — never from anything model/tool/provider-influenced; `BudgetExhausted` (typed, anyhow-downcastable) stops `run_loop` with a plain-language message, no retry/re-price/partial-spend/fallthrough. Verified load-bearing by fault injection: temporarily replacing the terminating `return` with `continue` made `zero_budget_stops_loop_without_retry` go red (8 retries to MAX_TURNS, generic error) before being restored
|
||||
- [Phase 13, 13-13]: egress.rs's message_is_turn_own (13-12's G-B2 check) was Claude-shape-only and would have silently stripped Routstr's OpenAI-shape system prompt + tool results out of every outbound request — fixed with explicit "system"/"tool"-role handling (Rule 1 bug, found while wiring screen_outbound into routstr.rs)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -223,8 +227,10 @@ The 5x lifecycle gate was NOT run.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-08-06T03:13:08.096Z
|
||||
Stopped at: Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices). Next: wave 6 (13-13).
|
||||
Last session: 2026-08-06T05:40:00.000Z
|
||||
Stopped at: Completed 13-13-PLAN.md (Routstr backend adapter — Nostr discovery, OpenAI chat, Cashu payment via auto_pay_token; AssistantBudget D-05 arithmetic ceiling, BudgetExhausted hard-stop verified by fault injection; D-04 chain complete: Ollama -> Claude -> Routstr). Next: 13-14. Resume file: None.
|
||||
|
||||
Prior (13-12) stop note, retained for history: Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices).
|
||||
`assistant::` tests incl. `approval_nonce_binds_to_exact_action` individually; dispatcher.rs
|
||||
untouched; fc09d7a2's tools.rs/grants.rs/backends/mod.rs diffs confirmed rustfmt-only, no
|
||||
behavior change). Task 2 (ToolConfirmModal.vue, contextBroker.ts, Chat.vue mount,
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
---
|
||||
phase: 13-aiui-functional-conversational-node-control-and-content-surf
|
||||
plan: 13
|
||||
subsystem: ai-assistant-backends
|
||||
tags: [rust, routstr, nostr, cashu, payment-policy, d-04, d-05, budget]
|
||||
|
||||
requires:
|
||||
- phase: 13-aiui-functional-conversational-node-control-and-content-surf
|
||||
provides: "13-01's Backend trait/BackendTurn seam, 13-10's Ollama backend + async select_backend + FallbackChain, 13-12's egress::screen_outbound/EgressContext and AssistantCounters/OwnerNotice"
|
||||
provides:
|
||||
- "assistant/backends/routstr.rs: RoutstrBackend — Nostr kind-38421 provider discovery (5-min process cache), cheapest-affordable-price selection preferring onion when Tor is up, OpenAI-shape chat completion with string-encoded tool_calls[] parsed once at the edge, Cashu payment via the existing auto_pay_token primitive, screen_outbound wired in"
|
||||
- "assistant/mod.rs: AssistantBudget (allowance_sats/spent_sats, persisted 0600 under data_dir/assistant/budget.json, default-empty on a fresh node), payment_policy()/record_spend(), typed BudgetExhausted error"
|
||||
- "assistant/loop_.rs: run_loop downcasts BudgetExhausted out of a backend Err and stops the turn with a plain-language message — no retry, no re-price, no partial spend"
|
||||
- "assistant/backends/mod.rs: select_backend completes D-04's chain (Ollama -> Claude -> Routstr), select_backend now takes &RpcHandler; new BackendId::Routstr"
|
||||
- "api/rpc/assistant_chat.rs: assistant.budget-get / assistant.budget-set RPCs through the existing assistant.* dispatcher arm; nostr_tor_proxy() accessor"
|
||||
- "assistant/egress.rs: message_is_turn_own extended for the OpenAI wire shape (role:\"system\", role:\"tool\", tool_calls as a content-sibling field) — a bug-fix needed for Routstr's screen_outbound call to work correctly at all"
|
||||
affects: [13-14, 13-15]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Typed, anyhow-downcastable error (BudgetExhausted) as the signal a caller distinguishes from an ordinary transport error, rather than a string-sentinel bail — matches this codebase's existing preference for typed verdicts (EgressVerdict) over string matching"
|
||||
- "Budget-derived PaymentPolicy computed once per turn at select_backend time (before any model output exists), never re-derived mid-turn from anything the model or a discovered provider produced — this is what makes D-05's ceiling arithmetic rather than negotiable"
|
||||
- "Cross-provider wire-shape adapters (Ollama's/Routstr's OpenAI-ish shape vs Claude's Messages API shape) each own their own message_to_wire; shared enforcement code (egress.rs's G-B2) had to learn BOTH shapes explicitly rather than assuming Claude's"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- core/archipelago/src/assistant/backends/routstr.rs
|
||||
modified:
|
||||
- core/archipelago/src/assistant/backends/mod.rs
|
||||
- core/archipelago/src/assistant/mod.rs
|
||||
- core/archipelago/src/assistant/loop_.rs
|
||||
- core/archipelago/src/assistant/egress.rs
|
||||
- core/archipelago/src/api/rpc/assistant_chat.rs
|
||||
|
||||
key-decisions:
|
||||
- "Task 1 checkpoint resolved by the operator (via the orchestrator's AskUserQuestion, 2026-08-05): proceed-docs-with-probe-first — see the dedicated section below for the full rationale and quoted Gate verdict"
|
||||
- "select_provider drops the 'requested model' framing RESEARCH.md's phrasing implied — Routstr has no operator-configured target model the way Ollama (OLLAMA_DEFAULT_MODEL)/Claude (CLAUDE_MODEL) do, and CONTEXT.md explicitly delegates 'Routstr provider selection strategy' to Claude's discretion. Implemented as: search every (provider, model) pair every discovered provider advertises and pick the globally cheapest one under the remaining budget, preferring an onion endpoint when Tor is up — never pinned to one hardcoded model name a real provider might not even offer"
|
||||
- "select_backend's Routstr wiring was deliberately sequenced into Task 3's commit, not Task 2's, even though Task 2's own <action> text says 'insert the Routstr leg into select_backend' — Task 2's own acceptance criteria never grep for this wiring, and the real wiring needs AssistantBudget (a Task 3 deliverable) to be correct; wiring it twice (a placeholder in Task 2, then the real version in Task 3) would have been pure churn. Task 2's commit instead registers `pub mod routstr;` and ships the fully-tested adapter standalone"
|
||||
- "egress.rs's message_is_turn_own (G-B2's mechanical turn-minimality allowlist) was written in 13-12 only against Claude's wire shape (system as a top-level field, tool results wrapped in role:\"user\" arrays) — the first time an OpenAI-shape body (Routstr's) was ever passed through screen_outbound, the pre-existing `_ => false` fail-closed arm would have silently stripped the system prompt AND every tool-result message out of every Routstr request, corrupting the model's own context on every multi-turn call. Fixed in Task 2's own commit (Rule 1 — a real bug in code this task's own stated behavior depends on) by adding explicit \"system\"/\"tool\"-role handling and an assistant tool_calls-as-sibling-field check, with 4 new regression tests pinning both wire shapes"
|
||||
- "record_spend is called immediately once auto_pay_token returns Some(token) — BEFORE the chat HTTP request is even attempted — because the Cashu proofs are already committed to that token at that point (auto_pay_token's own implementation mints/melts inside build_payment_token), regardless of whether the subsequent HTTP call to the provider itself succeeds. Recording spend only after a successful HTTP response would have under-counted a real payment whose delivery failed"
|
||||
- "AssistantBudget carries only allowance_sats/spent_sats, not a duplicated accepted_mints list — accepted_mints is read fresh from the existing wallet::ecash::load_accepted_mints(data_dir) primitive at select_backend time instead, avoiding a second, driftable mints source (mirrors 13-10's own history.rs decision to avoid a second driftable tool-category list)"
|
||||
|
||||
requirements-completed: [AIUI-01]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "RoutstrBackend implements the Backend trait: Nostr kind-38421 provider discovery (process-cached, 5-min TTL, empty list never an error), cheapest-affordable-price selection preferring an onion endpoint when Tor is up, an OpenAI-shaped non-streaming chat request carrying tools[] and an explicit generation cap, string-encoded tool_calls[] arguments parsed exactly once at this adapter's edge, each tool_calls[] id echoed back in the result turn, payment attached via the existing auto_pay_token primitive using the header spelling 13-ROUTSTR-FINDINGS.md recorded, and screen_outbound run before any body leaves the node"
|
||||
requirement: AIUI-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "assistant::backends::routstr::tests (17 tests): discovery_parses_endpoints_models_and_pricing_from_a_fixture_event, malformed_provider_event_is_skipped_not_a_panic, no_provider_found_falls_through_not_errors, send_with_zero_providers_returns_a_clean_error_not_a_panic, select_provider_picks_cheapest_affordable_price, select_provider_excludes_prices_over_the_remaining_budget, select_provider_prefers_onion_endpoint_when_tor_is_up, request_is_openai_shaped_non_streaming_with_tools_and_explicit_cap, openai_string_arguments_are_parsed_once_at_the_edge, tool_calls_response_maps_to_backend_turn_tool_calls_with_parsed_arguments, tool_call_id_is_echoed_back_in_the_result_turn, payment_token_is_attached_via_the_documented_header, over_budget_price_declines_without_a_token_ever_being_built, secret_shaped_content_never_reaches_the_stub, non_success_status_fails_loudly, response_missing_choices_fails_loudly_not_silently, unreachable_endpoint_returns_transport_error_not_panic"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "egress.rs's G-B2 turn-minimality check correctly recognizes the OpenAI wire shape (system message, role:\"tool\" results, tool_calls as a content-sibling field) instead of silently stripping Routstr's own system prompt and tool-result context out of every request"
|
||||
requirement: AIUI-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "assistant::egress::tests::openai_shape_system_message_is_turn_own, openai_shape_tool_role_result_is_turn_own_and_unrelated_ones_are_truncated, openai_shape_ungranted_tool_call_is_not_turn_own (plus all 9 pre-existing egress:: tests still passing, 12/12 total)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "D-04's chain is complete (Ollama -> Claude -> Routstr) and D-05's ceiling is a hard arithmetic stop: spending is silent within the allowance, a zero allowance never selects Routstr at all, a declined payment stops the loop with a plain-language message with no retry/re-price/partial-spend, and the ceiling is provably not a function of anything model-influenced"
|
||||
requirement: AIUI-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "assistant::tests::zero_budget_stops_loop_without_retry (S-12), zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output, injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass; fault-injection re-verification below"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~4h (spike-adjusted; dominated by cargo compile time under heavy shared-box contention from a concurrent agent session — individual full-crate compiles ran 13-15 minutes each)
|
||||
completed: 2026-08-06
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 13 Plan 13: Routstr Backend — D-04's Third Leg, D-05's Arithmetic Budget Ceiling Summary
|
||||
|
||||
**RoutstrBackend (Nostr provider discovery, OpenAI-shape chat, Cashu payment via the existing `auto_pay_token` primitive) completes the D-04 chain (Ollama -> Claude -> Routstr), gated by `AssistantBudget`'s hard, operator-set, arithmetic ceiling (D-05) that a prompt-injected model can never widen.**
|
||||
|
||||
## Task 1: Decision Resolution (resolved, not re-asked)
|
||||
|
||||
**Chosen option:** `proceed-docs-with-probe-first`
|
||||
|
||||
**Rationale (one sentence, per the plan's own acceptance criteria):** Routstr was named by the operator directly at the operator's explicit request (nostr-first preference, an operator-requested feature per CONTEXT.md D-04), and money-safety rests on `PaymentPolicy`'s hard cap upstream of the model rather than on the wire-protocol guess being correct — so proceeding against the docs with a fail-loud capability probe delivers the requested capability without accepting an unbounded-risk guess, while `defer-with-residual` would have dropped a capability the operator asked for by name for a risk that D-05's own architecture already contains.
|
||||
|
||||
**COVERAGE.md's `## Gate` section, quoted verbatim:**
|
||||
|
||||
> `13-03` ran. No live provider was reachable (`13-ROUTSTR-FINDINGS.md`). Per this file's own prior instruction, that means **13-13 may not proceed directly** — its first task must be a `checkpoint:decision`. This is already true of `13-13-PLAN.md` as written: Task 1 is `type="checkpoint:decision" gate="blocking"` with exactly the three options this situation calls for (`proceed-observed`, `proceed-docs-with-probe-first`, `defer-with-residual`), and its own acceptance criteria require it to read this `## Gate` section and quote it. No edit to `13-13-PLAN.md` was needed or made by this plan — 13-03's job was to produce the evidence that checkpoint reads, not to alter the checkpoint itself.
|
||||
|
||||
**Claims that remain unverified** (per `13-ROUTSTR-FINDINGS.md`'s verdict table — **0 of 9 claims were CONFIRMED**, all NOT OBSERVED except relay reachability itself):
|
||||
|
||||
| # | Claim | Verdict |
|
||||
|---|-------|---------|
|
||||
| 1 | Provider-announcement event kind is `38421` | NOT OBSERVED |
|
||||
| 2 | `d` tag value is `routstr-provider` | NOT OBSERVED |
|
||||
| 3 | Event content carries an `endpoints` field (http/onion) | NOT OBSERVED |
|
||||
| 4 | Event content carries a `models` field | NOT OBSERVED |
|
||||
| 5 | Event content carries a `pricing` field | NOT OBSERVED |
|
||||
| 6 | Payment header is `Authorization: Bearer cashuA…` and/or `X-Cashu:` | NOT OBSERVED |
|
||||
| 7 | `POST /v1/chat/completions` is OpenAI-compatible, non-streaming as the primary mode | NOT OBSERVED |
|
||||
| 8 | `tool_calls[].function.arguments` arrives as a JSON-encoded string | NOT OBSERVED |
|
||||
| 9 | Default relay list is a reasonable place to find providers | Relay **reachability confirmed** (all 3 connected); provider announcement there — NOT OBSERVED |
|
||||
|
||||
Every one of rows 1-8 is implemented in `assistant/backends/routstr.rs` exactly as the docs cite (`ROUTSTR_KIND = 38421`, `d` tag `"routstr-provider"`, `endpoints`/`models`/`pricing` fields on `RoutstrProvider`, `Authorization: Bearer cashuA…` header, non-streaming `POST /v1/chat/completions`, string-encoded `function.arguments` parsed once in `parse_openai_tool_calls`) — none of it independently confirmed against a live provider this session. The capability-probe requirement Task 1's decision imposed is implemented as the FIRST real HTTP call this code makes against any live provider: `send_paid_request` bails loudly (with the real HTTP status/body, or "no 'choices' array" if the response shape doesn't match) rather than silently returning an empty or wrong answer if any of these docs-based guesses turns out wrong on first contact with a real provider.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~4h wall-clock across two commits (session crash-recovered once mid-way through Task 3's final test run — no work was lost; the crash occurred during an idle `cargo test` wait, not mid-edit), dominated by `cargo test`/`cargo check` compiles taking 13-15 minutes each under heavy shared-box contention from a concurrent agent session (per this phase's own recurring gotcha)
|
||||
- **Tasks:** 3/3 (Task 1 checkpoint pre-resolved by the operator; Tasks 2 and 3 both `type="auto" tdd="true"`)
|
||||
- **Files modified:** 6 (1 created: `backends/routstr.rs`; 5 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `RoutstrBackend` (`assistant/backends/routstr.rs`) implements the `Backend` trait: `discover_providers` subscribes for kind-`38421` provider-announcement events over the node's existing Tor-proxy-aware Nostr client (`nostr_discovery::build_nostr_client` — never a second relay client), cached process-wide with a 5-minute TTL so a relay round trip never happens on every chat turn; `select_provider` picks the globally cheapest affordable `(provider, model)` price across every discovered provider (no fixed target model — Routstr's provider-selection strategy is explicitly Claude's discretion per CONTEXT.md), preferring an onion endpoint when Tor is up; `attach_payment` calls the existing budget-capped `auto_pay_token` verbatim (zero Cashu/BDHKE code written here — grep-verified); `parse_openai_tool_calls` parses the one string-encoded `function.arguments` shape exactly once at this adapter's edge, the ONLY backend with this gotcha; `send_paid_request` builds the OpenAI-shaped, non-streaming, `tools[]`-carrying, explicit-`ROUTSTR_MAX_TOKENS`-capped request, runs `screen_outbound` (G-B1/G-B2) before anything leaves the node, and fails loudly (capability probe) on a non-success status or an unexpected response shape.
|
||||
- `AssistantBudget` (`assistant/mod.rs`) persists the operator's prepaid allowance and running spend under `data_dir/assistant/budget.json`, 0600, mirroring `Grants::load`/`save` exactly — a fresh or corrupt file defaults to a ZERO allowance (D-16's "default closed" applied to money). `payment_policy()` builds the `PaymentPolicy` the Routstr leg pays against from ONLY these two persisted fields, computed once at `select_backend` time (before any model output exists for the turn) — nothing model/tool/provider-influenced can ever widen it. `record_spend()` persists a successful payment and raises a one-time owner notice the first time spend crosses 80% of the allowance (AI-SPEC §7b).
|
||||
- `BudgetExhausted` (typed, `anyhow`-downcastable) is the signal `loop_.rs`'s `run_loop` distinguishes from an ordinary transport error: on a downcast match, the loop returns `Ok` with a plain-language stop message ("I've reached the prepaid spending limit... stopping here rather than retrying, re-pricing, or partially spending") instead of retrying, re-pricing, falling through to a different provider at a different price, or erroring out in a way that would read as a crash. **Verified to actually matter, not merely asserted:** the terminating `return` was temporarily replaced with `continue` and `zero_budget_stops_loop_without_retry` was re-run — it went RED (the backend was retried 8× to `MAX_TURNS` and the turn errored with "assistant loop exceeded MAX_TURNS without a final answer" instead of stopping cleanly); the fix was restored and the suite re-confirmed green. Full observed failure output is in "Fault-Injection Verification" below.
|
||||
- `select_backend` (`backends/mod.rs`) completes D-04's full chain: Ollama first (unchanged from 13-10), Claude second, Routstr third — reached only when Ollama isn't selectable AND the operator's allowance is nonzero (a zero allowance returns Claude alone, never selecting-then-declining a paid backend). `select_backend` now takes `&RpcHandler` (was `&Path`) so it can also read the Tor-proxy config for the onion-preference decision; new `BackendId::Routstr` variant.
|
||||
- `assistant.budget-get`/`assistant.budget-set` RPCs (`api/rpc/assistant_chat.rs`) route through the existing single `assistant.*` dispatcher arm — `dispatcher.rs` untouched, grep-verified. `budget-set` only ever writes `allowance_sats` (never `spent_sats`), so raising the allowance after an exhaustion stop widens the remainder without resetting the period's spend history.
|
||||
- **Bug found and fixed (Rule 1):** `egress.rs`'s `message_is_turn_own` (13-12's G-B2 turn-minimality check) was written only against Claude's wire shape. The first OpenAI-shape body this function was ever asked to screen (Routstr's, and structurally also Ollama's if it ever called `screen_outbound`, which it deliberately never does) would have hit the pre-existing `_ => false` fail-closed arm for `role:"system"` and `role:"tool"` messages — silently stripping the system prompt and every tool-result message out of the outbound request on every multi-turn Routstr call. Fixed with explicit `"system"`/`"tool"`-role handling plus an assistant `tool_calls`-as-sibling-field check, pinned by 3 new regression tests (all 12 `egress::` tests green).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 2: Discover a provider, speak OpenAI, attach ecash** — `a3521e5e` (feat). `assistant::backends::` 30/30 pass in this commit's own tree state (includes `egress::`'s OpenAI-shape fix, verified separately at 12/12).
|
||||
2. **Task 3: The ceiling is arithmetic — spend silently, then stop and ask** — `8ba60412` (feat). `assistant::` 91/91 pass; full crate suite 1235/1235 (2 pre-existing ignored, unrelated) at this commit's tree state.
|
||||
|
||||
**Plan metadata:** this commit (`docs(13-13): complete Routstr backend / D-05 budget ceiling plan`)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `core/archipelago/src/assistant/backends/routstr.rs` (new) — `RoutstrBackend`, `RoutstrProvider`, `discover_providers`, `select_provider`, `attach_payment`, `parse_openai_tool_calls`, `message_to_wire`, `ROUTSTR_KIND`, `ROUTSTR_MAX_TOKENS`, `DISCOVERY_TIMEOUT`
|
||||
- `core/archipelago/src/assistant/backends/mod.rs` — `pub mod routstr;`, `select_backend` signature (`&RpcHandler`) and completed D-04 wiring, `BackendId::Routstr`
|
||||
- `core/archipelago/src/assistant/mod.rs` — `AssistantBudget`, `BudgetExhausted`, `AssistantCounters::note_budget_burn`/`budget_burn_events`, `chat()`'s `select_backend` call site updated, 4 new Task-3 tests
|
||||
- `core/archipelago/src/assistant/loop_.rs` — `run_loop`'s `backend.send()` match arm downcasts `BudgetExhausted`
|
||||
- `core/archipelago/src/assistant/egress.rs` — `message_is_turn_own` OpenAI-shape fix (system/tool roles, tool_calls sibling field), 3 new regression tests
|
||||
- `core/archipelago/src/api/rpc/assistant_chat.rs` — `handle_assistant_budget_get`/`handle_assistant_budget_set`, `nostr_tor_proxy()` accessor
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in frontmatter — Task 1's `proceed-docs-with-probe-first` selection, `select_provider`'s no-fixed-model design (Claude's Discretion per CONTEXT.md), the deliberate sequencing of `select_backend`'s real Routstr wiring into Task 3 rather than a throwaway Task 2 placeholder, the `egress.rs` bug fix, `record_spend`'s pay-before-request-attempt timing, and keeping `accepted_mints` out of `AssistantBudget` to avoid a second driftable source.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] `egress.rs`'s `message_is_turn_own` silently stripped OpenAI-shape system/tool messages**
|
||||
- **Found during:** Task 2, while wiring `screen_outbound` into `routstr.rs`'s `send_paid_request`
|
||||
- **Issue:** The G-B2 turn-minimality allowlist (13-12) was written and tested only against Claude's wire shape. Routstr's OpenAI-compatible body sends the system prompt as its own `role:"system"` message (Claude sends it as a top-level field) and tool results as `role:"tool"` messages (Claude wraps them in `role:"user"` arrays) — both fell into the function's `_ => false` fail-closed default, meaning `assert_turn_minimal` would truncate the system prompt and every tool result out of EVERY Routstr request, corrupting the model's context on every multi-turn call.
|
||||
- **Fix:** Added explicit `"system"` (always turn-own — the node's own static prompt) and `"tool"`-role (checked against `this_turn_tool_results`, same allowlist Claude's shape uses) handling, plus a check for OpenAI-shape `tool_calls` as a sibling field to `content` (which is `null` on those turns, not an array) — the pre-existing plain-string/null fallback for `"assistant"` would otherwise have let an ungranted tool call slip through unverified.
|
||||
- **Files modified:** `core/archipelago/src/assistant/egress.rs`.
|
||||
- **Verification:** 3 new tests (`openai_shape_system_message_is_turn_own`, `openai_shape_tool_role_result_is_turn_own_and_unrelated_ones_are_truncated`, `openai_shape_ungranted_tool_call_is_not_turn_own`); full `egress::` suite 12/12 green.
|
||||
- **Committed in:** `a3521e5e` (Task 2 commit).
|
||||
|
||||
**2. [Sequencing, not a rule-taxonomy deviation] `select_backend`'s Routstr wiring moved from Task 2's commit to Task 3's**
|
||||
- **Found during:** Task 2, implementing the plan's own `<action>` text ("Insert the Routstr leg into select_backend after Claude")
|
||||
- **Issue:** Wiring `select_backend` correctly requires `AssistantBudget` (zero-allowance skip, budget-derived `PaymentPolicy`) — a Task 3 deliverable. Wiring a throwaway placeholder in Task 2 (e.g. a hardcoded zero-budget `PaymentPolicy`) just to satisfy the plan's literal task boundary would have been pure churn, immediately overwritten by Task 3's real version.
|
||||
- **Resolution:** Task 2's commit registers `pub mod routstr;` and ships the fully standalone-tested `RoutstrBackend` adapter (none of Task 2's own acceptance criteria grep for `select_backend` wiring); Task 3's commit does the real, budget-gated wiring atomically alongside `AssistantBudget` itself. Both files (`backends/mod.rs`, `backends/routstr.rs`) are already in the plan's own top-level `files_modified` list, so no file falls outside the plan's declared scope — only the task attribution of one specific hunk shifted.
|
||||
- **Files modified:** `core/archipelago/src/assistant/backends/mod.rs` (Task 3), `core/archipelago/src/assistant/backends/routstr.rs` (Task 3's edit to the payment-decline arm and spend-recording, on top of Task 2's own commit).
|
||||
- **Verification:** Full D-04 chain (`select_backend`) tested end-to-end in Task 3's own commit via `zero_allowance_never_selects_routstr`.
|
||||
- **Committed in:** `8ba60412` (Task 3 commit).
|
||||
|
||||
**3. [Rule 3 - Blocking] `nostr_tor_proxy()` accessor added to `assistant_chat.rs` outside Task 3's minimal RPC-handler scope**
|
||||
- **Found during:** Task 3, wiring `select_backend`'s onion-preference decision
|
||||
- **Issue:** `select_backend` needed the node's configured `ARCHIPELAGO_NOSTR_TOR_PROXY` value to decide whether `RoutstrBackend` should prefer onion endpoints, but `RpcHandler.config` is a private field only reachable from within `api::rpc` — the same structural reason `data_dir()`'s own accessor exists in this file already.
|
||||
- **Fix:** Added a minimal `pub(crate) fn nostr_tor_proxy(&self) -> Option<String>` accessor, matching `data_dir()`'s exact precedent in the same file.
|
||||
- **Files modified:** `core/archipelago/src/api/rpc/assistant_chat.rs` (already in the plan's `files_modified` list — no out-of-scope file touched).
|
||||
- **Verification:** `select_backend` compiles and `zero_allowance_never_selects_routstr`/full suite pass using it.
|
||||
- **Committed in:** `8ba60412` (Task 3 commit).
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed bug (Rule 1 — a real, structurally-necessary bug in code this plan's own stated behavior depends on), 1 minimal accessor addition (Rule 3 — the minimal wiring needed for the plan's own stated intent to compile and hold), 1 task-boundary sequencing clarification (not a rule-taxonomy deviation — no scope change, only which commit owns which hunk of an already-in-scope file). No architectural changes; no scope creep.
|
||||
**Impact on plan:** All three are necessary for the plan's own stated guarantees to hold at runtime; none touch a file the threat model assigns a mitigation to beyond what was already planned.
|
||||
|
||||
## Fault-Injection Verification (Task 3 acceptance criterion)
|
||||
|
||||
Per the plan's explicit instruction: "Temporarily make the `None` branch continue instead of terminate and confirm `zero_budget_stops_loop_without_retry` goes red; restore it and record the observed failure in the summary."
|
||||
|
||||
**Fault injected:** in `loop_.rs`'s `run_loop`, replaced `return Ok((stop_message, history));` inside the `BudgetExhausted` downcast arm with `continue;` (i.e., the loop keeps asking the same exhausted backend again instead of stopping).
|
||||
|
||||
**Observed RED failure:**
|
||||
```
|
||||
thread 'assistant::tests::zero_budget_stops_loop_without_retry' panicked at archipelago/src/assistant/mod.rs:1422:14:
|
||||
a budget-exhausted stop must be Ok, not a crash-shaped Err: assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever
|
||||
|
||||
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1236 filtered out; finished in 0.06s
|
||||
```
|
||||
The backend was retried repeatedly (`continue` re-enters the `for turn_idx in 0..MAX_TURNS` loop) until `MAX_TURNS` was exhausted, at which point `run_loop` itself bails with its own generic "exceeded MAX_TURNS" error — demonstrating both that the terminating branch is load-bearing (removing it produces retries, not a stop) and that the test genuinely exercises it (a no-op change would not have gone red).
|
||||
|
||||
**Restored** (`return Ok((stop_message, history));` reinstated) and reconfirmed green: `zero_budget_stops_loop_without_retry ... ok`, full `assistant::` suite 91/91, full crate suite 1235/1235.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Shared-box compute contention (recurring, per this phase's own gotcha).** Every `cargo test`/`cargo check` compile took 13-15 minutes under a concurrently-running agent session on the same 4-core box. Managed entirely via foreground blocking waits (`ps -p <pid>` polling loops), never `run_in_background`-and-end-turn.
|
||||
- **Session crash mid-Task-3, recovered cleanly.** The executing session died on an expired API login while idly waiting on a `cargo test` compile (not mid-edit) — Task 2's commit (`a3521e5e`) was already pushed and intact; Task 3's edits were uncommitted-but-present in the tree exactly as left. No cargo process was still running post-crash (checked via `pgrep`), so the recovery proceeded directly to re-running the verification suite rather than needing to kill/restart a stale build.
|
||||
- **Header-name case sensitivity in the HTTP stub test.** `hyper`'s captured-header iteration canonicalizes header names to lowercase; `PAYMENT_HEADER = "Authorization"` (capitalized, matching the docs-cited spelling used on the wire) needed a case-insensitive lookup in `payment_token_is_attached_via_the_documented_header`'s assertion — fixed by lowercasing the lookup key, not the constant itself (the wire spelling stays exactly as `13-ROUTSTR-FINDINGS.md` cites it).
|
||||
- **Accidental BIP39-heuristic collision in two new egress test fixtures.** The first draft of `openai_shape_tool_role_result_is_turn_own_and_unrelated_ones_are_truncated`/`openai_shape_ungranted_tool_call_is_not_turn_own`'s synthetic JSON bodies happened to contain 12+ consecutive short lowercase "words" (from JSON keys/role names), coincidentally tripping G-B1's unrelated BIP39-length-word-run heuristic and returning `BlockFallBackLocal` instead of the `Truncate` these tests were actually about. Fixed by calling `assert_turn_minimal` (G-B2 only) directly in these two tests instead of the full `screen_outbound` pipeline — isolating exactly what each test claims to prove, rather than padding the fixture text to dodge an unrelated heuristic.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan's own code — no external service configuration is required to build or test it. **Operationally**, an operator who wants the Routstr leg to ever actually be reached needs to set a nonzero allowance via `assistant.budget-set` (defaults to zero/closed on every node, per D-16's "default closed" philosophy applied to money) — this is expected first-use configuration, not a gap in this plan.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- D-04's backend chain is now fully complete (Ollama -> Claude -> Routstr) and D-05's budget ceiling is a real, hard, arithmetic stop — provably not a function of model output, and demonstrated (not merely asserted) to actually terminate the loop via the fault-injection exercise above.
|
||||
- **The Routstr leg is entirely unverified against a live provider** (13-ROUTSTR-FINDINGS.md: 0/9 claims confirmed) — this is a named, accepted residual per Task 1's decision, not a silent gap. The capability-probe design means the first real contact with a live provider will surface any wrong guess (header spelling, event shape, response shape) as a loud, actionable error rather than a silent misbehavior or a spent-money failure — but genuinely exercising that path end-to-end needs a live Routstr provider to become reachable, which was not available in this or the 13-03 spike's environment. A future session with real provider access should re-run the capability probe on-device and update `13-ROUTSTR-FINDINGS.md`'s verdict table from NOT OBSERVED to CONFIRMED/DIFFERS as appropriate.
|
||||
- No live wallet/mint payment was ever exercised in this plan's tests (by design — no real cashu/money spent, per the executor's own gotchas); the "declines without touching the wallet" path IS proven (mirrors `swarm::payment`'s own existing test precedent), but a real successful payment + real spend recording has not been observed on a live node.
|
||||
- `AssistantBudget`'s persisted state has no UI surface yet beyond the two new RPC methods (`assistant.budget-get`/`assistant.budget-set`) — a natural, small follow-up for whichever plan builds the AI-permissions/notifications UI (13-12-SUMMARY.md flagged the analogous gap for `AssistantCounters::notices()`).
|
||||
|
||||
---
|
||||
*Phase: 13-aiui-functional-conversational-node-control-and-content-surf*
|
||||
*Completed: 2026-08-06*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All referenced files found (`backends/routstr.rs`, this SUMMARY). Both task commit hashes
|
||||
(`a3521e5e`, `8ba60412`) verified present in `git log --oneline --all`.
|
||||
Reference in New Issue
Block a user