Files
archy/core/archipelago/src/assistant/backends/claude.rs
T
archipelagoandClaude Fable 5 fde7b1572d feat(13-12): G-B1/G-B2 cloud-egress secret scan and turn-minimality screen
assistant/egress.rs: screen_outbound(body, ctx) -> EgressVerdict runs on
every request body about to leave this node for a cloud backend. G-B1
scan_secret_shapes checks for macaroon-shaped hex runs, BIP39-length word
runs, ecash/Nostr-key-shaped strings, and the literal contents of files
under data_dir/secrets — a hit fails closed (BlockFallBackLocal), logging
only the match's kind, never the value. G-B2 assert_turn_minimal checks the
outbound body against a mechanical allowlist of this turn's own fields (the
user's turn, this turn's granted tool names, this turn's own tool results);
an unrelated earlier tool result or content wrapped for a different turn is
truncated out rather than eyeballed. An unparsable/ambiguous body also fails
closed. MAX_OUTBOUND_CONTEXT_CHARS caps body size independent of minimality.

Wired into backends/claude.rs's send() before the outbound HTTP request (on
a block, send() errors before anything is sent — Rule 3, outside this
task's originally-declared file list but structurally required to give
screen_outbound a real caller); never wired into ollama.rs — nothing leaves
the node on that leg, so paying the scan cost would be pointless.

mod.rs: AssistantCounters/OwnerNotice — grant refusals, validation
failures, turns-per-request, untrusted-content-present,
cloud-escalation-while-local-up, blocked-egress and MAX_TURNS-reached
counters, each raising an owner_notice() at its own AI-SPEC §7b threshold.
Local and owner-facing only: no exporter, no /metrics, no OTLP anywhere in
assistant/ or rate_limit.rs. backends/mod.rs's select_backend raises a
cloud-escalation-while-local-up notice when Ollama is reachable but its
configured model isn't tool-capable (Rule 3, same file-scope reasoning).

9/9 assistant::egress:: tests pass in this task's own isolated state
(Task 1's 56 plus these 9 — ToolExecCtx's counters field and its loop_.rs
call sites are Task 3's own commit, since nothing in this task's behavior
needs them yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:22:15 -04:00

237 lines
8.6 KiB
Rust

//! The Claude leg of the D-04 backend chain — Anthropic Messages API with
//! `tools`/`tool_use`/`tool_result`. Modeled on
//! `mesh/listener/assist.rs::call_claude`'s HTTP client construction and
//! `api/rpc/mesh/assistant.rs`'s key-path convention, but NOT extended
//! in place: this is a new, tool-calling-capable request/response shape,
//! and its constants are new (AI-SPEC §3 Pitfall 6 — the mesh constants are
//! airtime-tuned for LoRa and must not be reused here).
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Backend, BackendTurn};
use crate::assistant::egress::{self, EgressVerdict};
use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef};
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
/// Kept in sync with `mesh/listener/assist.rs::CLAUDE_DEFAULT_MODEL` —
/// cheap and already proven fast enough; D-07 makes backend choice a
/// privacy/cost decision, not a capability-need one, so there is no reason
/// to default to a stronger model here.
const CLAUDE_MODEL: &str = "claude-haiku-4-5-20251001";
/// New, separate constant for the AIUI path's multi-turn tool loop (which
/// may include a network round trip) — deliberately NOT reusing the mesh
/// module's LoRa-airtime-tuned HTTP timeout constant (60s), which is sized
/// for a different transport entirely (AI-SPEC §3 Pitfall 6).
const ASSISTANT_HTTP_TIMEOUT: Duration = Duration::from_secs(180);
/// Raised from mesh's `512` — `tool_use` content blocks and multi-turn
/// reasoning need more headroom. Never left unbounded.
const ASSISTANT_MAX_TOKENS: u32 = 2048;
pub struct ClaudeBackend {
data_dir: PathBuf,
}
impl ClaudeBackend {
pub fn new(data_dir: PathBuf) -> Self {
Self { data_dir }
}
}
#[async_trait]
impl Backend for ClaudeBackend {
async fn send(
&self,
system: &str,
tools: &[ToolDef],
history: &[ChatMessage],
) -> Result<BackendTurn> {
// SAME key path `api/rpc/mesh/assistant.rs` probes — do not
// introduce a second key location (D-01, one key ledger).
let key = tokio::fs::read_to_string(self.data_dir.join("secrets/claude-api-key"))
.await
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
let key = key.trim();
if key.is_empty() {
anyhow::bail!("Claude API key is empty");
}
let messages: Vec<Value> = history.iter().filter_map(message_to_wire).collect();
let claude_tools: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
})
.collect();
let mut body = json!({
"model": CLAUDE_MODEL,
"max_tokens": ASSISTANT_MAX_TOKENS,
"system": system,
"messages": messages,
"stream": false,
});
if !claude_tools.is_empty() {
body["tools"] = json!(claude_tools);
// AI-SPEC §3 Pitfall 5: every tool_use.id from one assistant
// turn needs a matching tool_result before the next request.
// Disabling parallel tool use sidesteps that bookkeeping —
// D-06's tools are one deliberate action at a time anyway.
body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true});
}
// G-B1/G-B2: screen the outbound body before it ever leaves this
// node — the Claude leg is a cloud backend (unlike Ollama, which
// never calls this at all — see egress.rs's module doc). Fails
// closed: on a block, this call returns an Err and nothing was
// sent.
let egress_ctx = egress::EgressContext::from_turn(
history,
&tools.iter().map(|t| t.name).collect::<Vec<_>>(),
&self.data_dir.join("secrets"),
)
.await;
match egress::screen_outbound(&body.to_string(), &egress_ctx) {
EgressVerdict::Allow => {}
EgressVerdict::Truncate(truncated) => {
if let Ok(v) = serde_json::from_str::<Value>(&truncated) {
body = v;
}
}
EgressVerdict::BlockFallBackLocal => {
crate::assistant::global_counters().note_blocked_egress();
anyhow::bail!(
"outbound request to Claude blocked before it left this node — it appeared \
to contain secret-shaped material (G-B1). Falling back to the local backend."
);
}
}
let client = reqwest::Client::builder()
.timeout(ASSISTANT_HTTP_TIMEOUT)
.build()?;
let resp = client
.post(CLAUDE_URL)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let txt = resp.text().await.unwrap_or_default();
anyhow::bail!(
"Claude API HTTP {}: {}",
status,
txt.chars().take(180).collect::<String>()
);
}
let json: Value = resp.json().await?;
let blocks = json
.get("content")
.and_then(|c| c.as_array())
.cloned()
.unwrap_or_default();
let mut tool_calls = Vec::new();
let mut text = String::new();
for block in &blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
let id = block
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
tool_calls.push(ToolCall {
id,
name,
arguments,
});
}
Some("text") => {
if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
text.push_str(t);
}
}
_ => {}
}
}
if !tool_calls.is_empty() {
Ok(BackendTurn::ToolCalls(tool_calls))
} else {
Ok(BackendTurn::Text(text))
}
}
}
/// Map one internal `ChatMessage` onto an Anthropic Messages API turn.
/// `Role::System` returns `None` — the system prompt is sent via the
/// top-level `system` field, not as a message in the array.
fn message_to_wire(msg: &ChatMessage) -> Option<Value> {
match msg.role {
Role::System => None,
Role::User => Some(json!({
"role": "user",
"content": msg.text.clone().unwrap_or_default(),
})),
Role::Assistant => {
if !msg.tool_calls.is_empty() {
let blocks: Vec<Value> = msg
.tool_calls
.iter()
.map(|c| {
json!({
"type": "tool_use",
"id": c.id,
"name": c.name,
"input": c.arguments,
})
})
.collect();
Some(json!({"role": "assistant", "content": blocks}))
} else {
Some(json!({
"role": "assistant",
"content": msg.text.clone().unwrap_or_default(),
}))
}
}
Role::Tool => {
let blocks: Vec<Value> = msg
.tool_results
.iter()
.map(|r| {
json!({
"type": "tool_result",
"tool_use_id": r.call_id,
"content": r.content,
"is_error": r.is_error,
})
})
.collect();
// Anthropic's tool_result blocks travel back as a "user" turn.
Some(json!({"role": "user", "content": blocks}))
}
}
}