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:
co-authored by
Claude Fable 5
parent
a3521e5eeb
commit
8ba6041251
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user