feat(13-13): D-05 prepaid budget — arithmetic ceiling, hard stop, D-04 chain complete

Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated),
and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop
a prompt-injected model can never cross.

- assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats,
  persisted 0600 under data_dir/assistant/budget.json, mirroring
  Grants::load/save exactly — a missing/corrupt file defaults to a ZERO
  allowance, D-16's "default closed" applied to money). `payment_policy()`
  builds a `PaymentPolicy` from ONLY these two persisted fields — no
  parameter accepts anything model/tool/provider-influenced, which is what
  makes the ceiling arithmetic rather than a policy an injected model
  could argue with. `record_spend()` persists a successful payment and
  raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed
  `BudgetExhausted` error (downcastable via anyhow) is the signal
  `loop_.rs` distinguishes from an ordinary transport error.
- assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the
  backend's `Err` and returns `Ok` with a plain-language stop message —
  no retry, no re-price, no partial spend, no fall-through to a different
  provider at a different price. Verified to actually matter: temporarily
  replaced the terminating `return` with `continue` and confirmed
  `zero_budget_stops_loop_without_retry` goes red (the backend gets
  retried 8x to MAX_TURNS and the turn errors instead of stopping
  cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the
  observed failure).
- assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler`
  (was `&Path`) to also read the Tor-proxy config; completes the D-04
  chain — Routstr never selected when the operator's allowance is zero
  (Claude alone instead), otherwise chained as Claude's fallback
  (Ollama -> Claude -> Routstr, each leg reached only when the priors are
  unavailable). New `BackendId::Routstr` variant.
- assistant/backends/routstr.rs: the payment-decline arm now returns the
  typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the
  plan's own "handled in Task 3" note); a successful payment records spend
  against the persisted budget immediately (the Cashu proofs are already
  committed at that point, regardless of whether the subsequent chat HTTP
  call itself succeeds).
- api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set`
  RPCs (routed through the existing single `assistant.` dispatcher arm —
  dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for
  select_backend's onion-preference decision.

Named tests (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.
Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing
ignored, unrelated). dispatcher.rs and Cargo.toml untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 05:33:27 -04:00
co-authored by Claude Fable 5
parent a3521e5eeb
commit 8ba6041251
5 changed files with 530 additions and 27 deletions
@@ -32,6 +32,8 @@ impl RpcHandler {
"assistant.confirm-tool" => self.handle_assistant_confirm_tool(params).await,
"assistant.history" => self.handle_assistant_history(session_token).await,
"assistant.clear-history" => self.handle_assistant_clear_history(session_token).await,
"assistant.budget-get" => self.handle_assistant_budget_get().await,
"assistant.budget-set" => self.handle_assistant_budget_set(params).await,
other => anyhow::bail!("no such assistant method: {other}"),
}
}
@@ -188,6 +190,48 @@ impl RpcHandler {
Ok(serde_json::json!({ "categories": grants_categories_json(&grants) }))
}
/// assistant.budget-get — the operator's current Routstr allowance
/// (D-05): the total sats authorized this period, the amount spent so
/// far, and the remainder. Never touched by anything model-influenced —
/// this is purely a read of the persisted [`crate::assistant::AssistantBudget`].
async fn handle_assistant_budget_get(self: &Arc<Self>) -> Result<serde_json::Value> {
let budget = crate::assistant::AssistantBudget::load(self.data_dir()).await;
Ok(serde_json::json!({
"allowance_sats": budget.allowance_sats,
"spent_sats": budget.spent_sats,
"remaining_sats": budget.remaining_sats(),
}))
}
/// assistant.budget-set — the operator sets (or raises) the prepaid
/// Routstr allowance. Params: `{ "allowance_sats": u64 }`. Only the
/// allowance changes here; `spent_sats` is left untouched, so raising
/// the allowance after an exhaustion stop (D-05's "offering to top up")
/// simply widens the remainder rather than resetting the period's
/// spend history. This is the ONLY place `AssistantBudget.allowance_sats`
/// is ever written — never from a tool call, never from model output
/// (D-05: the ceiling is operator-set, full stop).
async fn handle_assistant_budget_set(
self: &Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let allowance_sats = params
.get("allowance_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing allowance_sats"))?;
let mut budget = crate::assistant::AssistantBudget::load(self.data_dir()).await;
budget.allowance_sats = allowance_sats;
budget.save(self.data_dir()).await?;
Ok(serde_json::json!({
"allowance_sats": budget.allowance_sats,
"spent_sats": budget.spent_sats,
"remaining_sats": budget.remaining_sats(),
}))
}
/// assistant.chat — a single chat turn from the authenticated local
/// operator. Params: `{ "text": string }`. Returns `{ "text": string }`.
async fn handle_assistant_chat(
@@ -283,6 +327,15 @@ impl RpcHandler {
pub(crate) fn data_dir(&self) -> &std::path::Path {
&self.config.data_dir
}
/// Read-only accessor for the node's configured Nostr Tor-proxy address
/// (`ARCHIPELAGO_NOSTR_TOR_PROXY`) — used by `assistant::backends::select_backend`
/// to decide whether the Routstr leg should prefer an onion endpoint.
/// Owned `Option<String>` (not a borrow) so the caller can carry it
/// into a `RoutstrBackend` that outlives this borrow of `self`.
pub(crate) fn nostr_tor_proxy(&self) -> Option<String> {
self.config.nostr_tor_proxy.clone()
}
}
/// Shared shape for `assistant.grants-get` and `assistant.grants-set`'s
+50 -16
View File
@@ -3,12 +3,11 @@
//! 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};
use crate::api::rpc::RpcHandler;
pub mod claude;
pub mod ollama;
@@ -32,14 +31,20 @@ pub trait Backend: Send + Sync {
}
/// D-04's identified backends — used for tracing which backend answered a
/// given turn. Grows a `Routstr` variant once 13-13 lands it. Deliberately
/// NOT carried into `ChatMessage`/`history.rs` (outside this plan's file
/// scope; per-turn backend attribution in the persisted transcript is a
/// natural follow-up, not required by any of 13-10's behaviors).
/// given turn. Deliberately NOT carried into `ChatMessage`/`history.rs`
/// (outside this plan's file scope; per-turn backend attribution in the
/// persisted transcript is a natural follow-up, not required by any of
/// 13-10's behaviors).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendId {
Ollama,
Claude,
/// 13-13: the third D-04 leg. Not currently returned as the "primary"
/// id by `select_backend` (mirroring the existing convention that the
/// returned id names the primary attempt, not necessarily which leg of
/// a `FallbackChain` actually answers) — kept as its own variant for
/// future tracing/observability parity with `Ollama`/`Claude`.
Routstr,
}
impl std::fmt::Display for BackendId {
@@ -47,6 +52,7 @@ impl std::fmt::Display for BackendId {
match self {
BackendId::Ollama => write!(f, "ollama"),
BackendId::Claude => write!(f, "claude"),
BackendId::Routstr => write!(f, "routstr"),
}
}
}
@@ -95,18 +101,20 @@ fn ollama_is_selectable(detected: bool, tool_capable: bool) -> bool {
detected && tool_capable
}
/// D-04's backend chain: local Ollama first (node data never leaves the
/// node when a local model is available and tool-capable), then Claude.
/// The Routstr slot (13-13) inserts as a third leg without changing the
/// `Backend` trait or this function's shape — the architectural commitment
/// the 13-01 tracer proved and this plan fills the first real leg of.
/// D-04's complete backend chain: local Ollama first (node data never
/// leaves the node when a local model is available and tool-capable),
/// Claude second, and Routstr — a Nostr-discovered, Cashu-paid provider —
/// third, reached only when the first two are unavailable AND the operator
/// has actually authorized spending (D-05: a ZERO allowance means Routstr
/// is never even selected, not selected-and-then-declined).
///
/// Reuses `detect_ollama()` — the SAME probe `mesh.assistant-status`
/// reports — rather than writing a second one. Ollama being unreachable OR
/// its configured model being reachable-but-not-tool-capable both fall
/// through to Claude, each with a logged reason — never a silent,
/// tools-free degrade.
pub async fn select_backend(data_dir: &Path) -> (Box<dyn Backend>, BackendId) {
pub async fn select_backend(handler: &RpcHandler) -> (Box<dyn Backend>, BackendId) {
let data_dir = handler.data_dir();
let (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await;
let model = ollama::OLLAMA_DEFAULT_MODEL;
let tool_capable = if detected {
@@ -145,10 +153,36 @@ pub async fn select_backend(data_dir: &Path) -> (Box<dyn Backend>, BackendId) {
"Ollama is reachable but its configured model ({model}) is not tool-capable"
));
}
(
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())),
BackendId::Claude,
)
// D-04's third leg: Routstr, reached only when Ollama isn't selectable
// — and only when the operator has actually authorized spending. The
// budget is read fresh HERE, once, at the start of the turn — the
// policy `RoutstrBackend` pays against for the WHOLE turn is fixed at
// this point, before any model output exists yet (D-05's "not a
// function of model output" property).
let budget = crate::assistant::AssistantBudget::load(data_dir).await;
if budget.allowance_sats == 0 {
tracing::info!("D-04: Routstr allowance is zero — Claude alone, Routstr not selected");
return (
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())),
BackendId::Claude,
);
}
let policy = budget.payment_policy();
let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir)
.await
.map(|m| m.mints)
.unwrap_or_default();
let tor_proxy = handler.nostr_tor_proxy();
let routstr_backend =
routstr::RoutstrBackend::new(data_dir.to_path_buf(), policy, accepted_mints, tor_proxy);
let chain = FallbackChain {
primary: Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())),
primary_id: BackendId::Claude,
secondary: Box::new(routstr_backend),
};
(Box::new(chain), BackendId::Claude)
}
#[cfg(test)]
@@ -412,10 +412,12 @@ impl RoutstrBackend {
// D-05: pay via the existing budget-capped primitive — never
// hand-rolled here (T-13-89). A `None` return means the price
// exceeds the remaining allowance, or the wallet/mint declined;
// Task 2's own contract stops at returning a clear `Err` here —
// Task 3 upgrades this exact arm to a typed signal `loop_.rs`
// downcasts to stop the turn cleanly instead of retrying.
// exceeds the remaining allowance, or the wallet/mint declined.
// 13-13 Task 3: this is a TYPED signal (`crate::assistant::BudgetExhausted`),
// not a plain string bail — `loop_.rs` downcasts it out of the
// generic `Err` to stop the turn cleanly (no retry, no re-price,
// no partial spend) rather than treating it like an ordinary
// transport error that might be worth another attempt.
let token = match attach_payment(
&self.data_dir,
&self.policy,
@@ -426,13 +428,32 @@ impl RoutstrBackend {
{
Some(t) => t,
None => {
anyhow::bail!(
"Routstr payment declined for {price_sats} sats — over the remaining \
prepaid allowance, or the wallet/mint could not pay"
);
return Err(crate::assistant::BudgetExhausted {
remaining_sats: remaining_budget,
quoted_price_sats: price_sats,
}
.into());
}
};
// D-05/S-12: the payment already went through at this point — a
// Cashu token was built and its proofs already committed by
// `attach_payment`/`auto_pay_token`, regardless of whether the
// chat HTTP call below succeeds. Record it against the operator's
// persisted allowance NOW, before attempting the request, not
// after — reloaded fresh from disk (rather than mutating
// `self.policy`'s turn-start snapshot) so concurrent spend from
// elsewhere is never clobbered by a stale in-memory copy. A
// failure to persist is logged, never surfaced as a chat error —
// the payment already happened either way.
let mut budget = crate::assistant::AssistantBudget::load(&self.data_dir).await;
if let Err(e) = budget.record_spend(&self.data_dir, price_sats).await {
tracing::warn!(
error = %e,
"routstr: failed to persist the budget spend (the payment itself already succeeded)"
);
}
self.send_paid_request(&model, &endpoint, &token, system, tools, history)
.await
}
@@ -701,7 +722,10 @@ mod tests {
})
}
fn backend_for(base_url: &str, budget_sats: u64) -> RoutstrBackend {
/// `_base_url` is accepted (not read) purely so call sites read as
/// "a backend pointed at this stub" even though `send_paid_request`'s
/// tests pass the endpoint explicitly as its own argument.
fn backend_for(_base_url: &str, budget_sats: u64) -> RoutstrBackend {
RoutstrBackend::new(
std::env::temp_dir(),
PaymentPolicy::with_budget(budget_sats, 5),
+38 -1
View File
@@ -65,7 +65,44 @@ pub async fn run_loop(
}
for turn_idx in 0..MAX_TURNS {
match backend.send(system, tools, &history).await? {
let turn = match backend.send(system, tools, &history).await {
Ok(t) => t,
Err(e) => {
// D-05/S-12/T-13-85: the Routstr leg's payment primitive
// declined this specific price against the operator's
// remaining prepaid allowance — arithmetic, upstream of
// anything the model influenced. Downcasting out of the
// generic `Err` (rather than string-matching) is what lets
// this be distinguished from an ordinary transport error
// reliably. Stop HERE: no retry, no re-price, no partial
// spend, and no falling through to a different provider at
// a different price for this turn — a retry loop against a
// budget ceiling is exactly the "prompt-injection-driven
// tool-call loop overspends" failure mode this guards.
// Exhaustion is designed behaviour (AI-SPEC §7b), so this
// returns Ok with a plain-language stop message, never an
// Err that would read as a crash.
if let Some(exhausted) = e.downcast_ref::<crate::assistant::BudgetExhausted>() {
let stop_message = format!(
"I've reached the prepaid spending limit for cloud inference this \
period ({} sats remaining, this request needed {} sats) — stopping \
here rather than retrying, re-pricing, or partially spending. Raise \
the allowance in AI settings if you'd like to continue.",
exhausted.remaining_sats, exhausted.quoted_price_sats
);
history.push(ChatMessage {
role: Role::Assistant,
text: Some(stop_message.clone()),
tool_calls: vec![],
tool_results: vec![],
});
ctx.counters.note_turns_used((turn_idx + 1) as u64);
return Ok((stop_message, history));
}
return Err(e);
}
};
match turn {
BackendTurn::Text(answer) => {
history.push(ChatMessage {
role: Role::Assistant,
+356 -1
View File
@@ -98,6 +98,9 @@ struct AssistantCountersInner {
cloud_escalation_while_local_up: u64,
blocked_egress: u64,
max_turns_reached: u64,
/// 13-13 Task 3 / D-05: how many times a successful Routstr payment was
/// recorded against the operator's prepaid allowance this session.
budget_burn_events: u64,
/// Grant-refusal timestamps this window, split by whether untrusted
/// content was present in context at the time (T-13-83) — this is
/// what tells a probing attack apart from ordinary misconfiguration.
@@ -271,6 +274,29 @@ impl AssistantCounters {
);
}
/// 13-13 Task 3 / D-05: a Routstr payment just succeeded and was
/// recorded against the operator's prepaid allowance. Purely a counter
/// bump — the 80%-threshold owner notice itself is raised by
/// [`AssistantBudget::record_spend`], which calls this alongside it,
/// since only that method has both the before/after spend figures the
/// notice's threshold check needs.
pub(crate) fn note_budget_burn(&self) {
self.inner
.lock()
.expect("assistant counters mutex poisoned")
.budget_burn_events += 1;
}
/// How many Routstr payments have been recorded this session — for
/// tests/observability; not exposed over RPC by this plan.
#[cfg(test)]
pub(crate) fn budget_burn_events(&self) -> u64 {
self.inner
.lock()
.expect("assistant counters mutex poisoned")
.budget_burn_events
}
/// G-B1: an outbound cloud request was blocked before it left this
/// node because it appeared to contain secret-shaped material.
/// Structurally should be unreachable (G-S5/S-11); if it ever fires,
@@ -290,6 +316,164 @@ impl AssistantCounters {
}
}
const BUDGET_FILE: &str = "assistant/budget.json";
/// D-05's cross-mint swap-fee tolerance for the Routstr leg — mirrors
/// `swarm::payment`'s existing `max_fee_sats` semantics (ignored once the
/// wallet already holds the provider's mint). Small and fixed: nothing
/// about this figure is operator-configurable in this phase, matching
/// every other `PaymentPolicy` caller in this codebase, which each
/// hardcode their own fee tolerance rather than exposing a second knob.
const ROUTSTR_MAX_FEE_SATS: u64 = 5;
/// AI-SPEC §7b: crossing this fraction of the allowance raises an owner
/// notice (informational, not a warning of failure) — exhaustion itself is
/// a separate, informational STOP handled by `loop_.rs`'s `BudgetExhausted`
/// downcast, never an error here.
const BUDGET_NOTICE_THRESHOLD_PERCENT: u128 = 80;
/// D-05: the operator-set prepaid allowance for the Routstr leg of D-04 —
/// an operator-set ceiling, never a function of anything the model, a
/// tool call, or a discovered provider emits. [`AssistantBudget::payment_policy`]
/// reads only these two persisted fields — nothing else — which is what
/// makes the ceiling arithmetic rather than a policy an injected model
/// could ever argue with.
///
/// Persisted under `data_dir/assistant/budget.json`, 0600, following
/// [`grants::Grants`]'s own persistence convention exactly. A missing or
/// unreadable file is [`AssistantBudget::default_empty`] — a ZERO
/// allowance, D-16's "default closed" philosophy applied to money: Routstr
/// spends nothing until the operator deliberately sets an allowance (see
/// `backends::select_backend`'s zero-allowance check, which never even
/// selects Routstr in that case).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AssistantBudget {
/// Total sats the operator has authorized for this period. Zero means
/// Routstr is never selected at all — not selected-and-then-declined.
pub allowance_sats: u64,
/// Sats spent against `allowance_sats` so far this period. Only ever
/// incremented by [`AssistantBudget::record_spend`], which is called
/// exclusively AFTER `auto_pay_token` already returned `Some(token)` —
/// this field never decides whether to pay, it only accounts for a
/// payment that already happened.
pub spent_sats: u64,
}
impl AssistantBudget {
/// D-16's philosophy applied to spending: a fresh node authorizes
/// nothing.
pub fn default_empty() -> Self {
Self {
allowance_sats: 0,
spent_sats: 0,
}
}
/// Load the persisted budget for this node. A missing or corrupt file
/// is `default_empty()` — never an error, and never anything other
/// than a zero allowance (mirrors `Grants::load`'s own fail-safe
/// default).
pub async fn load(data_dir: &Path) -> Self {
let path = data_dir.join(BUDGET_FILE);
let Ok(content) = tokio::fs::read_to_string(&path).await else {
return Self::default_empty();
};
serde_json::from_str(&content).unwrap_or_else(|_| Self::default_empty())
}
/// Persist this budget, 0600 (mirrors `Grants::save`'s convention).
pub async fn save(&self, data_dir: &Path) -> Result<()> {
let dir = data_dir.join("assistant");
tokio::fs::create_dir_all(&dir).await?;
let path = data_dir.join(BUDGET_FILE);
let content = serde_json::to_string_pretty(self)?;
tokio::fs::write(&path, &content).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
/// The arithmetic ceiling this period — `allowance_sats` minus
/// `spent_sats`, saturating (never negative, never overflows).
pub fn remaining_sats(&self) -> u64 {
self.allowance_sats.saturating_sub(self.spent_sats)
}
/// D-05: build the `PaymentPolicy` the Routstr leg pays against, from
/// THIS persisted state alone. No parameter here accepts anything a
/// model, a tool call, or a discovered provider produced — the
/// function signature has nothing else to read, which is what makes
/// the ceiling arithmetic rather than a policy an injected model could
/// argue with (`PaymentPolicy::affords` is upstream of every
/// model-influenced value by construction).
pub fn payment_policy(&self) -> crate::swarm::payment::PaymentPolicy {
crate::swarm::payment::PaymentPolicy::with_budget(
self.remaining_sats(),
ROUTSTR_MAX_FEE_SATS,
)
}
/// Whether spend has crossed `BUDGET_NOTICE_THRESHOLD_PERCENT` of the
/// allowance. `u128` multiplication avoids any overflow at realistic
/// sat values while staying exact — no floating point.
fn crossed_80_percent(&self) -> bool {
self.allowance_sats > 0
&& (self.spent_sats as u128) * 100
>= (self.allowance_sats as u128) * BUDGET_NOTICE_THRESHOLD_PERCENT
}
/// D-05/S-12: record a successful Routstr payment against this budget
/// and persist it. Called ONLY after `auto_pay_token` returned
/// `Some(token)` (i.e. the payment already happened) — this never
/// decides whether to pay, only accounts for a payment that already
/// went through. Raises an owner notice the FIRST time this crosses
/// 80% of the allowance (AI-SPEC §7b); exhaustion itself is a
/// separate, informational stop handled entirely by `loop_.rs`'s
/// `BudgetExhausted` downcast, never an error here.
pub async fn record_spend(&mut self, data_dir: &Path, price_sats: u64) -> Result<()> {
let was_below_threshold = !self.crossed_80_percent();
self.spent_sats = self.spent_sats.saturating_add(price_sats);
self.save(data_dir).await?;
global_counters().note_budget_burn();
if was_below_threshold && self.crossed_80_percent() {
global_counters().owner_notice(
OwnerNoticeKind::Info,
format!(
"Routstr spending has crossed {BUDGET_NOTICE_THRESHOLD_PERCENT}% of this \
period's prepaid allowance ({} of {} sats spent).",
self.spent_sats, self.allowance_sats
),
);
}
Ok(())
}
}
/// D-05/S-12: the Routstr payment primitive declined this specific price
/// against the operator's remaining prepaid allowance. Distinguishable
/// from an ordinary transport error via `anyhow::Error::downcast_ref` so
/// `loop_.rs` can terminate the turn with a plain-language stop message —
/// never a retry, a re-price, a partial spend, or a fall-through to a
/// different provider at a different price for this same turn (T-13-85).
#[derive(Debug)]
pub struct BudgetExhausted {
pub remaining_sats: u64,
pub quoted_price_sats: u64,
}
impl std::fmt::Display for BudgetExhausted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Routstr quoted {} sats but only {} sats remain in this period's prepaid allowance",
self.quoted_price_sats, self.remaining_sats
)
}
}
impl std::error::Error for BudgetExhausted {}
/// The process-wide counters instance, shared by every production call
/// site that has no `ToolExecCtx`/`AssistantCounters` handle threaded to
/// it already (e.g. `backends/claude.rs`, which implements the
@@ -548,7 +732,7 @@ pub async fn chat(
let grants = caller.granted_categories(handler.data_dir()).await;
let visible_tools = registry.visible_to(&grants);
let (backend, backend_id) = backends::select_backend(handler.data_dir()).await;
let (backend, backend_id) = backends::select_backend(&handler).await;
tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn");
let system_prompt = build_system_prompt(&visible_tools);
@@ -1177,4 +1361,175 @@ mod tests {
let (answer, _history) = loop_task.await.expect("join").expect("run_loop");
assert_eq!(answer, "Done — restarted as requested.");
}
// -----------------------------------------------------------------
// 13-13 Task 3: D-05's arithmetic ceiling — spend silently within the
// allowance, then stop and ask. No retry, no re-price, no partial
// spend, and the ceiling can never be a function of anything the
// model emits.
// -----------------------------------------------------------------
/// A backend that always errors with `BudgetExhausted` — standing in
/// for the Routstr leg's own `send()` when the quoted price exceeds
/// the remaining allowance. Counts how many times it was actually
/// called, so "no retry" is asserted directly rather than inferred.
struct BudgetExhaustedBackend {
calls: Arc<std::sync::atomic::AtomicUsize>,
remaining_sats: u64,
quoted_price_sats: u64,
}
#[async_trait::async_trait]
impl backends::Backend for BudgetExhaustedBackend {
async fn send(
&self,
_system: &str,
_tools: &[tools::ToolDef],
_history: &[tools::ChatMessage],
) -> Result<backends::BackendTurn> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(BudgetExhausted {
remaining_sats: self.remaining_sats,
quoted_price_sats: self.quoted_price_sats,
}
.into())
}
}
/// S-12: a `BudgetExhausted` error from the backend stops the loop
/// with a plain-language message — `run_loop` returns `Ok`, not an
/// `Err` that would read as a crash, and never calls `send()` a second
/// time (no retry).
#[tokio::test]
async fn zero_budget_stops_loop_without_retry() {
let (handler, _tmp) = test_rpc_handler().await;
let ctx = ToolExecCtx::new(
tools::registry(),
CallerScope::LocalOperator {
session_id: "s".to_string(),
},
handler,
);
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let backend = BudgetExhaustedBackend {
calls: calls.clone(),
remaining_sats: 0,
quoted_price_sats: 50,
};
let (answer, _history) = loop_::run_loop(&backend, "sys", &[], vec![], &ctx)
.await
.expect("a budget-exhausted stop must be Ok, not a crash-shaped Err");
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"the backend must be called exactly once — no retry against the ceiling"
);
assert!(
answer.to_lowercase().contains("allowance") || answer.to_lowercase().contains("limit"),
"the stop message must explain why in plain language: {answer}"
);
}
/// D-05: a fresh node's `AssistantBudget` defaults to a zero
/// allowance, and `select_backend` must never select Routstr in that
/// case — the operator sees Claude alone (or Claude's own error)
/// rather than a paid backend chosen and then declined at the payment
/// step.
#[tokio::test]
async fn zero_allowance_never_selects_routstr() {
let (handler, _tmp) = test_rpc_handler().await;
let budget = AssistantBudget::load(handler.data_dir()).await;
assert_eq!(
budget.allowance_sats, 0,
"a fresh node must default to a zero allowance"
);
let (_backend, id) = backends::select_backend(&handler).await;
assert_ne!(
id,
backends::BackendId::Routstr,
"a zero allowance must never select Routstr"
);
}
/// D-05: `payment_policy()`'s ceiling is computed ONLY from the
/// persisted `AssistantBudget` fields — never from anything a model, a
/// tool call, or a discovered provider could influence. Reloading
/// independently of any "turn" context produces the identical ceiling,
/// and no price a hostile provider could quote is ever judged
/// affordable beyond what was persisted before the turn began.
#[tokio::test]
async fn ceiling_is_not_a_function_of_model_output() {
let (handler, _tmp) = test_rpc_handler().await;
let budget = AssistantBudget {
allowance_sats: 1_000,
spent_sats: 300,
};
budget.save(handler.data_dir()).await.expect("save");
let reloaded = AssistantBudget::load(handler.data_dir()).await;
let policy = reloaded.payment_policy();
assert_eq!(
policy.budget_sats, 700,
"the ceiling must be allowance minus spent, computed only from operator-set state"
);
// No price an attacker-controlled (self-published Nostr) provider
// event could quote ever widens what `affords()` will accept —
// the function's only inputs are the persisted fields above.
assert!(
!policy.affords(u64::MAX),
"no model/provider-influenced price can ever be judged affordable beyond the persisted remaining allowance"
);
assert!(policy.affords(700));
assert!(!policy.affords(701));
}
/// EV-17: a scripted injection-driven retry loop against a
/// near-exhausted allowance terminates on the FIRST decline with zero
/// overspend — the persisted `spent_sats` figure never moves, because
/// `BudgetExhausted` only ever fires when `auto_pay_token` already
/// returned `None` (no wallet operation occurred at all), and
/// `run_loop` stops before ever asking the backend a second time.
#[tokio::test]
async fn injection_loop_against_low_budget_does_not_overspend() {
let (handler, _tmp) = test_rpc_handler().await;
let budget = AssistantBudget {
allowance_sats: 10,
spent_sats: 9, // 1 sat remaining
};
budget.save(handler.data_dir()).await.expect("save");
let ctx = ToolExecCtx::new(
tools::registry(),
CallerScope::LocalOperator {
session_id: "s".to_string(),
},
handler.clone(),
);
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let backend = BudgetExhaustedBackend {
calls: calls.clone(),
remaining_sats: 1,
quoted_price_sats: 50,
};
let (answer, _history) = loop_::run_loop(&backend, "sys", &[], vec![], &ctx)
.await
.expect("run_loop must stop gracefully, not error");
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"an injection-driven retry loop against a near-exhausted allowance must not cause repeated payment attempts"
);
let reloaded = AssistantBudget::load(handler.data_dir()).await;
assert_eq!(
reloaded.spent_sats, 9,
"a declined payment must never touch spent_sats — zero overspend"
);
assert!(
answer.to_lowercase().contains("allowance") || answer.to_lowercase().contains("limit"),
"the loop must terminate with the stop message: {answer}"
);
}
}