mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
> On 8 tasks matched by name across the two runs, cost fell $8.36 → $1.77 (4.71×) and wall-clock 12,423 s → 1,085 s (11.45×). ## Summary Two independent, self-contained fixes to `buzz-agent`/`buzz-acp`, split out of the benchmark branch so they can land while the harness work continues: 1. **Request and surface Anthropic prompt caching.** buzz never sent a `cache_control` breakpoint, so on the Databricks Anthropic route `cache_read_input_tokens` was **structurally always 0** and the ~10× cache-read discount was never claimed. This teaches `anthropic_body()` to mark the cacheable prefix, and plumbs the cache split end-to-end so accounting can price it. 2. **Pass proxy + TLS-trust env into MCP tool subprocesses**, so agent tools on a proxy-only host stop reporting a live network as offline. ## Why the caching gap matters The Anthropic Messages API does **not** cache unless the request carries a `cache_control` breakpoint, and the Databricks AI Gateway — a third-party proxy in front of the model, in the same category as Bedrock/Vertex — does **not** auto-cache (only the first-party Anthropic API and Claude-on-AWS do zero-config caching). So every request was billed cold. Measured live against the Databricks gateway (`databricks-claude-opus-5`, 2026-07-28), the same call with and without a single `cache_control` marker: | Run | `input_tokens` | `cache_creation` | `cache_read` | latency | |---|---|---|---|---| | No `cache_control`, two byte-identical calls | 121,625 | 0 | **0** | ~9.3 s | | With one marker — cold (write) | 4 | 121,625 | 0 | 9.3 s | | With one marker — warm (read) | 4 | 0 | **121,625** | **4.5 s** | One marker moved 121,625 tokens from full-price input to a 0.1× cache read and roughly halved latency (a clean, isolated ~2.07× prefill speedup on this single-threaded microbenchmark). The gateway honours `cache_control`; buzz simply never sent it. At fleet scale this was a real budget item. Across matched Terminal-Bench solo sweeps (89 tasks, `-n 20`, before the fix), the two OpenAI-route models independently landed at ~86–87% cache reads — the expected shape for an agentic loop, where system + tools + append-only history repeat every turn — while the Anthropic route returned a hard 0% on every receipt: | Condition | Route | Input tokens | Cache reads | Cost | Cost if uncached | Discount | |---|---|---|---|---|---|---| | luna (`gpt-5-6`) | OpenAI | 20,320,818 | **17.7M (87.0%)** | $6.96 | $22.87 | **3.28×** | | sol (`gpt-5-6`) | OpenAI | 22,312,290 | **19.2M (85.9%)** | $37.07 | $123.35 | **3.33×** | | opus (`claude-opus-5`) | Anthropic | 12,459,822 | **0 (0.0%)** | $81.31 | $81.31 | **1.00×** | Applying luna's measured 87% read rate to the opus token counts at list prices (`input $5/M`, `cached_input $0.5/M`, `output $25/M`) puts the opus run at **~$32.53 vs the $81.31 actually paid — a ~60% overspend on those 49 trials (~$89 on a full sweep)**. That is an upper bound (it prices every cached token at the 0.1× read rate and ignores the 1.25× write premium), and the opus discount is structurally smaller than luna/sol's because opus emits ~3.5× more uncacheable output per trial, which sets a floor on what caching can recover. There is also a plausible **second-order effect**: Databricks appears to meter its per-minute rate limit on *uncached* input tokens, so the missing cache also cost rate-limit headroom — the opus endpoint lost 63% of its trials to fatal 429s while running alone at one-third of a GPT endpoint's raw throughput. This is a hypothesis, not a proven mechanism (the only zero-cache condition is also the only Anthropic endpoint), but it is the reading that explains the throttling with one rule instead of two. ## Post-fix results (provisional — first trials of an in-flight re-run) On 8 tasks matched by name across the two runs, cost fell **$8.36 → $1.77 (4.71×)** and wall-clock **12,423 s → 1,085 s (11.45×)**. | Metric | before (`4a955a858`) | after (`3bef1f6a`) | |---|---|---| | Cache reads as % of input | **0.0%** | **78.7%** (still climbing toward the ~86% steady state) | | `cost_usd_no_cache_discount / cost_usd` | **1.00×** | **2.18×** (tracking the projected ~2.5×) | | Trials with a fatal 429 (same `-n 20`) | **63%** | **15–19%** | To be clear about attribution: **~2× of that is the clean prefill saving from caching itself**; the rest is second-order — cached requests burn far less rate-limit budget, so they stall less and redo less destroyed work. The 11.45× is a system-level result specific to this throttled workspace, not a caching benchmark. Quality held (7/8 solved in each run). A controlled low-`-n` A/B (neither arm hitting a 429), which the `BUZZ_AGENT_PROMPT_CACHING` opt-out exists to enable, is still owed before this becomes a published claim. ## What changed ### 1. Request caching (`llm.rs`, `config.rs`) `anthropic_body()` emits ephemeral `cache_control` breakpoints, gated by `BUZZ_AGENT_PROMPT_CACHING` (**default on**, `=0` to opt out): - **Static prefix** — marker on the `system` block. Prefix order is `tools → system → messages`, so this single marker caches **tools + system** together. Byte-identical on every turn of a run, and survives a context handoff (system/tools come from cfg/mcp, not `self.history`). - **Rolling tail + leapfrog** — marker on the last block of the last **two** messages. The append-only history re-reads the prior turn's prefix from cache; marking two messages (not one) keeps consecutive breakpoints inside Anthropic's **20-block lookback window** even as tool parallelism rises, avoiding a silent full-price miss. An empty system prompt stays a bare string (Anthropic rejects empty text blocks), and below-threshold prefixes are silently not cached, so the flag is safe on by default. ### 2. Surface the cache split end-to-end — the plumbing (`types.rs`, `llm.rs`, `agent.rs`, `lib.rs`, `usage.rs`, `acp.rs`) This is the part that makes gaps like the one above **visible** instead of silent. A consumer that prices all of `input_tokens` at the full rate can't tell a route that's caching from one that isn't — the total looks right either way. So: - `LlmResponse` gains `cached_input_tokens` (a **subset** of `input_tokens`, never an addition); `parse_anthropic` / `parse_openai` / `parse_responses` each populate it. - A `usage_first()` helper reads the cache count wherever a provider hides it — flat `cache_read_input_tokens` (Anthropic), `prompt_tokens_details.cached_tokens` (OpenAI chat), `input_tokens_details.cached_tokens` (Responses) — taking the **first present value, never a sum**. Reading only flat keys is exactly why the OpenAI route's nested `cached_tokens` had *also* been going unclaimed: `prompt_tokens` is already inclusive, so the total looked correct while the discount silently went unreported. - The per-turn/per-session accumulators and the goose `usage_update` payload now carry `accumulatedCachedInputTokens`; `buzz-acp` deserializes it (`serde` default `0` for goose, which doesn't send it) and logs `cached=<n>`. ### 3. Fix a Databricks MLflow-route double-count (`llm.rs`) The Databricks MLflow route reports the flat Anthropic-spelled `cache_read_input_tokens` *alongside* an already-inclusive `prompt_tokens`, so the old code summed them and nearly doubled the count — inflating both the context-budget gate and cost. `openai_chat_input_tokens()` now reads `prompt_tokens` alone. Verified on a live `databricks-glm-5-2` response where `prompt_tokens + completion == total` proves inclusivity. (Anthropic's native route genuinely *excludes* the cache fields and is still summed — the two never collide, because `claude*` models route to the Anthropic path.) ### 4. Proxy + TLS-trust passthrough into MCP tools (`mcp.rs`) — independent fix `buzz-agent` `env_clear()`s each MCP child, and the allowlist carried no proxy/TLS vars. On a proxy-only host that doesn't degrade the tools, it **blinds** them: apt, curl, pip, git connect directly, the egress firewall resets the socket, and the agent reports "Connection reset by peer" — indistinguishable from a genuinely offline task. Adds both spellings of `HTTP(S)_PROXY`/`NO_PROXY`/`ALL_PROXY` (curl/git read lowercase; Go/Python read uppercase; libcurl ignores uppercase `HTTP_PROXY`) plus `SSL_CERT_FILE`/`SSL_CERT_DIR` for TLS-terminating proxies that present their own CA. ## Testing - `cargo fmt --all -- --check`, `cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent -p buzz-acp` — **all green** (632 + 299 lib tests plus integration suites, 0 failures). New tests cover: the three breakpoints and the disabled/empty-system/single-message edge cases; nested-vs-flat cache parsing for all three routes; the Databricks inclusive-`prompt_tokens` fix; wire deserialization of `accumulatedCachedInputTokens`; and the proxy/TLS passthrough allowlist. - Pre-push lefthook suite green (branch-skew, rust-tests, test, desktop-check/test/tauri). ## Relationship to the benchmark branch These are the non-`benchmarks/` changes from `benchmark/harness-accounting-and-solo`, lifted onto a clean base off `main` so they can merge independently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1205 lines
43 KiB
Rust
1205 lines
43 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use arc_swap::ArcSwap;
|
|
use rmcp::model::CallToolRequestParams;
|
|
use rmcp::service::{RoleClient, RunningService};
|
|
use rmcp::transport::TokioChildProcess;
|
|
use rmcp::ServiceError;
|
|
use rmcp::ServiceExt;
|
|
use serde_json::{Map, Value};
|
|
use tokio::process::Command;
|
|
use tokio::sync::watch;
|
|
use tokio::sync::Mutex as AsyncMutex;
|
|
|
|
use crate::config::{Config, HookServers};
|
|
use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult, ToolResultContent};
|
|
|
|
const SEP: &str = "__";
|
|
const MAX_NAME_LEN: usize = 128;
|
|
const MAX_QNAME_LEN: usize = 64;
|
|
const MAX_TOOLS_PER_SESSION: usize = 128;
|
|
const MAX_DESCRIPTION_BYTES: usize = 1024;
|
|
const MAX_SCHEMA_BYTES: usize = 4096;
|
|
const MARKER_FIELD_MAX: usize = 256;
|
|
pub const MAX_MCP_SERVERS: usize = 16;
|
|
const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024;
|
|
|
|
/// Byte budgets for a single tool result. `total` bounds everything the
|
|
/// result may occupy in history (text + images); `text` bounds the text
|
|
/// portion alone, since text is where runaway outputs (build logs, file
|
|
/// dumps) live while images are legitimately large and self-limiting.
|
|
#[derive(Clone, Copy)]
|
|
pub struct ResultBudget {
|
|
pub total: usize,
|
|
pub text: usize,
|
|
}
|
|
|
|
const PASSTHROUGH_ENV: &[&str] = &[
|
|
// Core
|
|
"PATH",
|
|
"HOME",
|
|
"TERM",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"TMPDIR",
|
|
"XDG_CONFIG_HOME",
|
|
// SSH — required for git clone/push over SSH (git@github.com:...)
|
|
"SSH_AUTH_SOCK",
|
|
"SSH_AGENT_PID",
|
|
// Git — operator-configured helpers and transport overrides
|
|
"GIT_ASKPASS",
|
|
"GIT_SSH_COMMAND",
|
|
"GIT_CONFIG_GLOBAL",
|
|
// Proxy — on a host whose only route out is a CONNECT proxy, dropping
|
|
// these does not degrade the tools, it blinds them: apt, curl, pip and git
|
|
// all connect directly instead, and the egress firewall resets the socket.
|
|
// The agent then reports "Connection reset by peer" and concludes the
|
|
// environment has no network, which is indistinguishable in the transcript
|
|
// from a task that is genuinely offline.
|
|
//
|
|
// Both cases are needed. curl and git read the lowercase spellings, most
|
|
// Go and Python tooling reads the uppercase ones, and libcurl deliberately
|
|
// ignores uppercase HTTP_PROXY (CGI ambiguity), so keeping only one form
|
|
// silently breaks half the toolchain.
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
"all_proxy",
|
|
// TLS trust — a proxy that terminates TLS presents its own CA, and an
|
|
// image whose trust store does not carry it fails every https fetch with a
|
|
// verification error. Same class of failure as the proxy vars: the parent
|
|
// was configured correctly and the child could not see it.
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
// Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then
|
|
// removes it from its own env (children never see it). BUZZ_PRIVATE_KEY
|
|
// and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a
|
|
// non-secret signed ownership attestation needed by portable owner-scoped
|
|
// CLI operations; MCP subprocesses are trusted like the agent runtime.
|
|
"NOSTR_PRIVATE_KEY",
|
|
"BUZZ_PRIVATE_KEY",
|
|
"BUZZ_RELAY_URL",
|
|
"BUZZ_AUTH_TAG",
|
|
// Agent display name — dev-mcp uses it as the git author name. On the
|
|
// Desktop path this arrives via the wire `mcpServers[].env` declaration
|
|
// (which wins here anyway); the allowlist entry covers ACP clients that
|
|
// spawn buzz-agent without declaring it.
|
|
"BUZZ_ACP_DISPLAY_NAME",
|
|
];
|
|
|
|
// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
|
|
// std::env::temp_dir() consults — without them it falls back to C:\Windows,
|
|
// which child processes can't write to (PermissionDenied). USERPROFILE is the
|
|
// always-set floor. APPDATA carries child-tool config (git, etc.).
|
|
#[cfg(windows)]
|
|
const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "APPDATA"];
|
|
|
|
/// Environment retained by `spawn_one()` after `env_clear()` on Windows.
|
|
/// Shell resolver keys are shared with Doctor through the public contract.
|
|
#[cfg(windows)]
|
|
fn windows_child_passthrough_env() -> impl Iterator<Item = &'static str> {
|
|
PASSTHROUGH_ENV_WINDOWS
|
|
.iter()
|
|
.copied()
|
|
.chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied())
|
|
}
|
|
|
|
type Client = RunningService<RoleClient, ()>;
|
|
|
|
#[derive(Clone)]
|
|
struct ServerSpec {
|
|
name: String,
|
|
command: String,
|
|
args: Vec<String>,
|
|
env: Vec<(String, String)>,
|
|
cwd: String,
|
|
}
|
|
|
|
enum ClientState {
|
|
Healthy {
|
|
client: Arc<Client>,
|
|
pgid: Option<u32>,
|
|
tools: Arc<Vec<String>>,
|
|
},
|
|
Dead {
|
|
attempts: u32,
|
|
next_retry: Instant,
|
|
reason: String,
|
|
// Preserved from the last Healthy state so tools() filtering stays accurate while dead.
|
|
tools: Arc<Vec<String>>,
|
|
},
|
|
}
|
|
|
|
struct Server {
|
|
name: String,
|
|
spec: ServerSpec,
|
|
client: ArcSwap<ClientState>,
|
|
restart_lock: AsyncMutex<()>,
|
|
}
|
|
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
if let ClientState::Healthy { pgid: Some(p), .. } = &**self.client.load() {
|
|
killpg(*p, &self.name, "drop");
|
|
}
|
|
}
|
|
}
|
|
|
|
enum RestartCheck {
|
|
Healthy,
|
|
Ready {
|
|
attempt_n: u32,
|
|
prev_tools: Arc<Vec<String>>,
|
|
},
|
|
}
|
|
|
|
fn check_restart_state(server: &Server, max_attempts: u32) -> Result<RestartCheck, AgentError> {
|
|
match &**server.client.load() {
|
|
ClientState::Healthy { .. } => Ok(RestartCheck::Healthy),
|
|
ClientState::Dead { attempts, .. } if *attempts >= max_attempts => {
|
|
Err(AgentError::Mcp(format!(
|
|
"The MCP server '{}' is unavailable (exhausted). Its tools have been removed for this session.",
|
|
server.name
|
|
)))
|
|
}
|
|
ClientState::Dead { next_retry, reason, .. } if Instant::now() < *next_retry => {
|
|
Err(AgentError::Mcp(format!(
|
|
"server '{}' is recovering (last error: {reason}). Try again later or use a different tool.",
|
|
server.name
|
|
)))
|
|
}
|
|
ClientState::Dead { attempts, tools, .. } => Ok(RestartCheck::Ready {
|
|
attempt_n: attempts + 1,
|
|
prev_tools: tools.clone(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
struct Entry {
|
|
server_idx: usize,
|
|
bare: String,
|
|
}
|
|
|
|
pub struct McpRegistry {
|
|
by_qname: HashMap<String, Entry>,
|
|
defs: Vec<ToolDef>,
|
|
servers: Vec<Arc<Server>>,
|
|
max_attempts: u32,
|
|
backoff_base: Duration,
|
|
backoff_max: Duration,
|
|
init_timeout: Duration,
|
|
/// Consecutive hook timeout count per server. Kill on second consecutive.
|
|
hook_timeouts: std::sync::Mutex<HashMap<String, u32>>,
|
|
}
|
|
|
|
impl McpRegistry {
|
|
pub async fn spawn_all(
|
|
cfg: &Config,
|
|
servers: &[McpServerStdio],
|
|
cwd: &str,
|
|
) -> Result<Self, AgentError> {
|
|
if servers.len() > MAX_MCP_SERVERS {
|
|
return Err(AgentError::Mcp(format!(
|
|
"too many MCP servers: {} > {MAX_MCP_SERVERS}",
|
|
servers.len()
|
|
)));
|
|
}
|
|
let mut reg = Self {
|
|
by_qname: HashMap::new(),
|
|
defs: Vec::new(),
|
|
servers: Vec::new(),
|
|
|
|
max_attempts: cfg.mcp_max_restart_attempts.max(1),
|
|
backoff_base: Duration::from_millis(cfg.mcp_restart_base_ms.max(1)),
|
|
backoff_max: Duration::from_millis(cfg.mcp_restart_max_ms.max(1)),
|
|
init_timeout: cfg.mcp_init_timeout,
|
|
hook_timeouts: std::sync::Mutex::new(HashMap::new()),
|
|
};
|
|
|
|
let mut seen_names = HashSet::new();
|
|
for s in servers {
|
|
if !valid_name(&s.name) || s.name.contains("__") {
|
|
return Err(AgentError::Mcp(format!("invalid server name: {}", s.name)));
|
|
}
|
|
if !seen_names.insert(s.name.clone()) {
|
|
return Err(AgentError::Mcp(format!(
|
|
"duplicate server name: {}",
|
|
s.name
|
|
)));
|
|
}
|
|
let spec = ServerSpec {
|
|
name: s.name.clone(),
|
|
command: s.command.clone(),
|
|
args: s.args.clone(),
|
|
env: s
|
|
.env
|
|
.iter()
|
|
.map(|e| (e.name.clone(), e.value.clone()))
|
|
.collect(),
|
|
cwd: cwd.to_owned(),
|
|
};
|
|
let (client, pgid, tool_names, raw_tools) = spawn_one(&spec, reg.init_timeout).await?;
|
|
let server_idx = reg.servers.len();
|
|
let server = Arc::new(Server {
|
|
name: spec.name.clone(),
|
|
spec,
|
|
client: ArcSwap::from_pointee(ClientState::Healthy {
|
|
client: Arc::new(client),
|
|
pgid,
|
|
tools: Arc::new(tool_names),
|
|
}),
|
|
restart_lock: AsyncMutex::new(()),
|
|
});
|
|
reg.servers.push(server);
|
|
|
|
for t in raw_tools {
|
|
if reg.defs.len() >= MAX_TOOLS_PER_SESSION {
|
|
return Err(AgentError::Mcp(format!(
|
|
"too many tools (>{MAX_TOOLS_PER_SESSION})"
|
|
)));
|
|
}
|
|
let bare = t.name.to_string();
|
|
if !valid_name(&bare) || bare.contains("__") {
|
|
return Err(AgentError::Mcp(format!("invalid tool name: {bare}")));
|
|
}
|
|
let qname = format!("{}{SEP}{}", s.name, bare);
|
|
if qname.len() > MAX_QNAME_LEN {
|
|
return Err(AgentError::Mcp(format!(
|
|
"qualified tool name too long: {} ({} > {MAX_QNAME_LEN})",
|
|
qname,
|
|
qname.len()
|
|
)));
|
|
}
|
|
if reg.by_qname.contains_key(&qname) {
|
|
return Err(AgentError::Mcp(format!("duplicate tool: {qname}")));
|
|
}
|
|
reg.defs.push(ToolDef {
|
|
name: qname.clone(),
|
|
description: clamp(
|
|
t.description.as_deref().unwrap_or("").to_owned(),
|
|
MAX_DESCRIPTION_BYTES,
|
|
),
|
|
input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())),
|
|
});
|
|
reg.by_qname.insert(qname, Entry { server_idx, bare });
|
|
}
|
|
}
|
|
Ok(reg)
|
|
}
|
|
|
|
pub fn server_of(&self, qname: &str) -> Option<&str> {
|
|
self.by_qname
|
|
.get(qname)
|
|
.map(|e| self.servers[e.server_idx].name.as_str())
|
|
}
|
|
|
|
pub fn has(&self, qname: &str) -> bool {
|
|
self.by_qname.contains_key(qname)
|
|
}
|
|
|
|
/// True if `qname` resolves to a hidden hook tool (bare name starts
|
|
/// with `_`). Used to reject hook calls coming from the LLM path —
|
|
/// hooks are only callable via `call_hooks`.
|
|
pub fn is_hook(&self, qname: &str) -> bool {
|
|
self.by_qname
|
|
.get(qname)
|
|
.map(|e| e.bare.starts_with('_'))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn tools(&self) -> Vec<ToolDef> {
|
|
self.defs
|
|
.iter()
|
|
.filter(|d| {
|
|
let entry = match self.by_qname.get(&d.name) {
|
|
Some(e) => e,
|
|
None => return false,
|
|
};
|
|
// Bare names starting with `_` are hooks — invisible to the LLM.
|
|
if entry.bare.starts_with('_') {
|
|
return false;
|
|
}
|
|
let server = &self.servers[entry.server_idx];
|
|
match &**server.client.load() {
|
|
ClientState::Healthy { tools, .. } => tools.iter().any(|t| t == &entry.bare),
|
|
ClientState::Dead {
|
|
attempts, tools, ..
|
|
} => *attempts < self.max_attempts && tools.iter().any(|t| t == &entry.bare),
|
|
}
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Call every tool whose bare name equals `hook_name` across all
|
|
/// allowlisted servers in parallel, bounded by `timeout`. Returns
|
|
/// `(server_name, text)` pairs in **config order** (deterministic),
|
|
/// dropping empty/whitespace-only responses, errors and timeouts.
|
|
/// Hooks are fail-open and must never block the agent.
|
|
pub async fn call_hooks(
|
|
self: &Arc<Self>,
|
|
hook_name: &str,
|
|
input: &Value,
|
|
timeout: Duration,
|
|
allowed: &HookServers,
|
|
) -> Vec<(String, String)> {
|
|
if allowed.is_disabled() {
|
|
return Vec::new();
|
|
}
|
|
// Walk servers in registration order so the result is deterministic
|
|
// regardless of HashMap iteration order or task completion order.
|
|
let mut targets: Vec<(usize, String, String)> = Vec::new();
|
|
for (idx, server) in self.servers.iter().enumerate() {
|
|
if !allowed.allows(&server.name) {
|
|
continue;
|
|
}
|
|
let qname = format!("{}{SEP}{}", server.name, hook_name);
|
|
if self.by_qname.contains_key(&qname) {
|
|
targets.push((idx, server.name.clone(), qname));
|
|
}
|
|
}
|
|
if targets.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let mut set = tokio::task::JoinSet::new();
|
|
for (idx, server_name, qname) in targets {
|
|
let reg = Arc::clone(self);
|
|
let args = input.clone();
|
|
set.spawn(async move {
|
|
// Hooks are intentionally non-cancellable: they are
|
|
// already bounded by their own timeout and are fail-open.
|
|
// Session cancel should not interrupt hook evaluation.
|
|
let (_dummy_tx, mut dummy_cancel) = watch::channel(false);
|
|
let res = tokio::time::timeout(
|
|
timeout,
|
|
reg.call(
|
|
&qname,
|
|
"hook",
|
|
&args,
|
|
ResultBudget {
|
|
total: MAX_HOOK_RESULT_BYTES,
|
|
text: MAX_HOOK_RESULT_BYTES,
|
|
},
|
|
&mut dummy_cancel,
|
|
),
|
|
)
|
|
.await;
|
|
drop(_dummy_tx);
|
|
(idx, server_name, res)
|
|
});
|
|
}
|
|
let mut indexed: Vec<(usize, String, String)> = Vec::new();
|
|
while let Some(joined) = set.join_next().await {
|
|
// fail-open: drop join errors, timeouts, call errors,
|
|
// empty/whitespace-only text. On timeout, also kill the server
|
|
// process group so a wedged hook can't poison the next regular
|
|
// tool call. The registry's lazy restart handles the rest.
|
|
match joined {
|
|
Ok((idx, server_name, Ok(Ok(r)))) => {
|
|
// Success — reset consecutive timeout counter.
|
|
if let Ok(mut counts) = self.hook_timeouts.lock() {
|
|
counts.remove(&server_name);
|
|
}
|
|
if !r.is_error && !r.text().trim().is_empty() {
|
|
indexed.push((idx, server_name, r.text()));
|
|
}
|
|
}
|
|
Ok((_idx, server_name, Err(_elapsed))) => {
|
|
// Kill only on second consecutive timeout.
|
|
let count = {
|
|
let mut counts =
|
|
self.hook_timeouts.lock().unwrap_or_else(|e| e.into_inner());
|
|
let c = counts.entry(server_name.clone()).or_insert(0);
|
|
*c += 1;
|
|
*c
|
|
};
|
|
if count >= 2 {
|
|
tracing::warn!(
|
|
"hook: killing server '{}' after {} consecutive timeouts",
|
|
server_name,
|
|
count
|
|
);
|
|
self.kill_server(&server_name, "hook timeout (consecutive)");
|
|
if let Ok(mut counts) = self.hook_timeouts.lock() {
|
|
counts.remove(&server_name);
|
|
}
|
|
} else {
|
|
tracing::warn!("hook: server '{}' timed out ({}/2)", server_name, count);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
indexed.sort_by_key(|(idx, _, _)| *idx);
|
|
indexed
|
|
.into_iter()
|
|
.map(|(_, name, text)| (name, text))
|
|
.collect()
|
|
}
|
|
|
|
/// Kill the server's process group and mark it dead. Idempotent:
|
|
/// if the server is already Dead (or unknown), this is a no-op.
|
|
/// Counts as one attempt toward the restart budget so that a
|
|
/// pathological server (starts fine, deadlocks on every call)
|
|
/// eventually exhausts.
|
|
pub fn kill_server(&self, name: &str, reason: &str) {
|
|
let server = match self.servers.iter().find(|s| s.name == name) {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
let current = server.client.load_full();
|
|
let (pgid, tools) = match &*current {
|
|
ClientState::Dead { .. } => return,
|
|
ClientState::Healthy { pgid, tools, .. } => (*pgid, tools.clone()),
|
|
};
|
|
let dead = Arc::new(ClientState::Dead {
|
|
attempts: 1,
|
|
next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
|
|
reason: reason.to_owned(),
|
|
tools,
|
|
});
|
|
// CAS so we don't clobber a concurrent restart that already
|
|
// transitioned the state. If the swap fails, the kill below is
|
|
// still safe — the pgid we read belonged to a process we observed
|
|
// as Healthy, and killpg on an already-reaped pgid is a no-op.
|
|
let prev = server.client.compare_and_swap(¤t, dead);
|
|
if Arc::ptr_eq(&prev, ¤t) {
|
|
if let Some(p) = pgid {
|
|
killpg(p, &server.name, "kill_server");
|
|
}
|
|
tracing::error!(
|
|
"MCP server '{}' killed and marked dead (reason={reason})",
|
|
server.name
|
|
);
|
|
}
|
|
}
|
|
|
|
fn kill_and_mark_dead_if_current(
|
|
&self,
|
|
server: &Server,
|
|
failed_client: &Arc<Client>,
|
|
reason: &str,
|
|
) {
|
|
let current = server.client.load_full();
|
|
match &*current {
|
|
ClientState::Healthy {
|
|
client,
|
|
pgid,
|
|
tools,
|
|
} if Arc::ptr_eq(client, failed_client) => {
|
|
if let Some(p) = *pgid {
|
|
killpg(p, &server.name, "call_failed");
|
|
}
|
|
let dead = Arc::new(ClientState::Dead {
|
|
attempts: 1,
|
|
next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
|
|
reason: reason.to_owned(),
|
|
tools: tools.clone(),
|
|
});
|
|
let _ = server.client.compare_and_swap(¤t, dead);
|
|
tracing::error!(
|
|
"MCP server '{}' killed and marked dead (reason={reason})",
|
|
server.name
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub async fn call(
|
|
&self,
|
|
qname: &str,
|
|
provider_id: &str,
|
|
arguments: &Value,
|
|
budget: ResultBudget,
|
|
cancel: &mut watch::Receiver<bool>,
|
|
) -> Result<ToolResult, AgentError> {
|
|
let entry = self
|
|
.by_qname
|
|
.get(qname)
|
|
.ok_or_else(|| AgentError::Mcp(format!("unknown tool {qname}")))?;
|
|
let server = self.servers[entry.server_idx].clone();
|
|
|
|
let state = server.client.load();
|
|
if let ClientState::Healthy { client, tools, .. } = &**state {
|
|
if !tools.iter().any(|t| t == &entry.bare) {
|
|
return Err(AgentError::Mcp(format!(
|
|
"tool '{qname}': no longer available; the MCP server restarted with a different tool set."
|
|
)));
|
|
}
|
|
let client = client.clone();
|
|
drop(state);
|
|
return self
|
|
.do_call(
|
|
&server,
|
|
&client,
|
|
&entry.bare,
|
|
qname,
|
|
provider_id,
|
|
arguments,
|
|
budget,
|
|
cancel,
|
|
)
|
|
.await;
|
|
}
|
|
drop(state);
|
|
|
|
self.maybe_restart(&server).await?;
|
|
let state = server.client.load();
|
|
let client = match &**state {
|
|
ClientState::Healthy { client, tools, .. } => {
|
|
if !tools.iter().any(|t| t == &entry.bare) {
|
|
return Err(AgentError::Mcp(format!(
|
|
"tool '{qname}': no longer available; the MCP server restarted with a different tool set."
|
|
)));
|
|
}
|
|
client.clone()
|
|
}
|
|
ClientState::Dead { reason, .. } => {
|
|
return Err(AgentError::Mcp(format!(
|
|
"tool '{qname}': server '{}' restart failed: {reason}",
|
|
server.name
|
|
)));
|
|
}
|
|
};
|
|
drop(state);
|
|
self.do_call(
|
|
&server,
|
|
&client,
|
|
&entry.bare,
|
|
qname,
|
|
provider_id,
|
|
arguments,
|
|
budget,
|
|
cancel,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn do_call(
|
|
&self,
|
|
server: &Server,
|
|
client: &Arc<Client>,
|
|
bare: &str,
|
|
qname: &str,
|
|
provider_id: &str,
|
|
arguments: &Value,
|
|
budget: ResultBudget,
|
|
cancel: &mut watch::Receiver<bool>,
|
|
) -> Result<ToolResult, AgentError> {
|
|
let arg_obj = match arguments {
|
|
Value::Object(m) => Some(m.clone()),
|
|
Value::Null => None,
|
|
_ => {
|
|
return Err(AgentError::Mcp(format!(
|
|
"tool {qname} arguments must be a JSON object"
|
|
)))
|
|
}
|
|
};
|
|
let mut params = CallToolRequestParams::default();
|
|
params.name = bare.to_owned().into();
|
|
params.arguments = arg_obj;
|
|
|
|
use rmcp::model::{CallToolRequest, ClientRequest, ServerResult};
|
|
use rmcp::service::PeerRequestOptions;
|
|
|
|
let req = ClientRequest::CallToolRequest(CallToolRequest::new(params));
|
|
let mut handle = client
|
|
.peer()
|
|
.send_cancellable_request(req, PeerRequestOptions::no_options())
|
|
.await
|
|
.map_err(|e| AgentError::Mcp(format!("call {qname}: {e}")))?;
|
|
|
|
// Early cancel check — watch::changed() only fires on NEW writes.
|
|
if *cancel.borrow() {
|
|
fire_and_forget_cancel(handle, qname);
|
|
return Err(AgentError::Cancelled);
|
|
}
|
|
|
|
// Poll the inner oneshot directly so we can still own `handle` in
|
|
// the cancel branch (await_response would move it).
|
|
let raw: Result<ServerResult, ServiceError> = tokio::select! {
|
|
biased;
|
|
_ = cancel.changed() => {
|
|
fire_and_forget_cancel(handle, qname);
|
|
return Err(AgentError::Cancelled);
|
|
}
|
|
r = &mut handle.rx => match r {
|
|
Ok(inner) => inner,
|
|
Err(_) => Err(ServiceError::TransportClosed),
|
|
},
|
|
};
|
|
|
|
let res = match raw {
|
|
Ok(ServerResult::CallToolResult(r)) => r,
|
|
Ok(_) => {
|
|
return Err(AgentError::Mcp(format!(
|
|
"call {qname}: unexpected response type"
|
|
)))
|
|
}
|
|
Err(e) => {
|
|
if is_transport_error(&e) {
|
|
self.kill_and_mark_dead_if_current(
|
|
server,
|
|
client,
|
|
&format!("call failed: {e}"),
|
|
);
|
|
return Err(AgentError::Mcp(format!("call {qname}: {e}")));
|
|
}
|
|
// Application-level JSON-RPC error (e.g. -32602 invalid params).
|
|
// Server is healthy — it correctly rejected bad input. Return to LLM.
|
|
return Ok(ToolResult {
|
|
provider_id: provider_id.to_owned(),
|
|
content: vec![ToolResultContent::Text(clamp(
|
|
format!("Tool call rejected: {e}"),
|
|
budget.text,
|
|
))],
|
|
is_error: true,
|
|
});
|
|
}
|
|
};
|
|
let content = tool_result_content(&res.content, budget.total, budget.text);
|
|
Ok(ToolResult {
|
|
provider_id: provider_id.to_owned(),
|
|
content,
|
|
is_error: res.is_error.unwrap_or(false),
|
|
})
|
|
}
|
|
|
|
async fn maybe_restart(&self, server: &Server) -> Result<(), AgentError> {
|
|
match check_restart_state(server, self.max_attempts)? {
|
|
RestartCheck::Healthy => return Ok(()),
|
|
RestartCheck::Ready { .. } => {}
|
|
}
|
|
|
|
let _guard = server.restart_lock.lock().await;
|
|
|
|
let (attempt_n, prev_tools) = match check_restart_state(server, self.max_attempts)? {
|
|
RestartCheck::Healthy => return Ok(()),
|
|
RestartCheck::Ready {
|
|
attempt_n,
|
|
prev_tools,
|
|
} => (attempt_n, prev_tools),
|
|
};
|
|
|
|
let started = Instant::now();
|
|
tracing::info!(
|
|
"MCP server '{}' restarting (attempt {attempt_n}/{})",
|
|
server.name,
|
|
self.max_attempts
|
|
);
|
|
match spawn_one(&server.spec, self.init_timeout).await {
|
|
Ok((client, pgid, tool_names, _raw_tools)) => {
|
|
server.client.store(Arc::new(ClientState::Healthy {
|
|
client: Arc::new(client),
|
|
pgid,
|
|
tools: Arc::new(tool_names),
|
|
}));
|
|
|
|
tracing::info!(
|
|
"MCP server '{}' restarted in {}ms (attempt {attempt_n})",
|
|
server.name,
|
|
started.elapsed().as_millis()
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let reason = format!("restart failed: {e}");
|
|
let permanent = attempt_n >= self.max_attempts;
|
|
let next_retry = if permanent {
|
|
Instant::now() + Duration::from_secs(86_400)
|
|
} else {
|
|
Instant::now() + backoff(attempt_n, self.backoff_base, self.backoff_max)
|
|
};
|
|
server.client.store(Arc::new(ClientState::Dead {
|
|
attempts: attempt_n,
|
|
next_retry,
|
|
reason: reason.clone(),
|
|
tools: prev_tools,
|
|
}));
|
|
|
|
tracing::error!(
|
|
"MCP server '{}' restart failed (attempt {attempt_n}/{}, permanent={permanent}): {reason}",
|
|
server.name, self.max_attempts
|
|
);
|
|
Err(AgentError::Mcp(reason))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn spawn_one(
|
|
spec: &ServerSpec,
|
|
timeout: Duration,
|
|
) -> Result<(Client, Option<u32>, Vec<String>, Vec<rmcp::model::Tool>), AgentError> {
|
|
let mut cmd = Command::new(&spec.command);
|
|
cmd.args(&spec.args);
|
|
cmd.env_clear();
|
|
for k in PASSTHROUGH_ENV {
|
|
if let Ok(v) = std::env::var(k) {
|
|
cmd.env(k, v);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
for k in windows_child_passthrough_env() {
|
|
if let Ok(v) = std::env::var(k) {
|
|
cmd.env(k, v);
|
|
}
|
|
}
|
|
for (k, v) in &spec.env {
|
|
cmd.env(k, v);
|
|
}
|
|
cmd.current_dir(&spec.cwd);
|
|
cmd.stderr(std::process::Stdio::inherit());
|
|
|
|
#[cfg(unix)]
|
|
cmd.process_group(0);
|
|
|
|
configure_no_window(&mut cmd);
|
|
|
|
let transport = TokioChildProcess::new(cmd)
|
|
.map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
|
|
let pgid = transport.id();
|
|
|
|
struct PgidGuard {
|
|
pgid: Option<u32>,
|
|
name: String,
|
|
}
|
|
impl Drop for PgidGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(p) = self.pgid.take() {
|
|
killpg(p, &self.name, "spawn_dropped");
|
|
}
|
|
}
|
|
}
|
|
let mut guard = PgidGuard {
|
|
pgid,
|
|
name: spec.name.clone(),
|
|
};
|
|
|
|
let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await {
|
|
Ok(Ok(c)) => c,
|
|
Ok(Err(e)) => {
|
|
return Err(AgentError::Mcp(format!("init {}: {e}", spec.name)));
|
|
}
|
|
Err(_) => {
|
|
return Err(AgentError::Mcp(timeout_msg("init", &spec.name, timeout)));
|
|
}
|
|
};
|
|
|
|
let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await {
|
|
Ok(Ok(t)) => t,
|
|
Ok(Err(e)) => {
|
|
return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name)));
|
|
}
|
|
Err(_) => {
|
|
return Err(AgentError::Mcp(timeout_msg(
|
|
"list_tools",
|
|
&spec.name,
|
|
timeout,
|
|
)));
|
|
}
|
|
};
|
|
let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
|
|
guard.pgid = None;
|
|
Ok((client, pgid, names, tools))
|
|
}
|
|
|
|
/// Send `notifications/cancelled` to the MCP server, fire-and-forget.
|
|
/// Per MCP spec, cancellation notifications are best-effort; we never
|
|
/// block the agent on slow server stdio.
|
|
fn fire_and_forget_cancel(
|
|
handle: rmcp::service::RequestHandle<rmcp::service::RoleClient>,
|
|
qname: &str,
|
|
) {
|
|
let qname_owned = qname.to_owned();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = handle.cancel(Some("session cancelled".into())).await {
|
|
tracing::debug!("cancel notification failed for {qname_owned}: {e}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Returns `true` for errors indicating the MCP server process is dead or
|
|
/// unreachable. Returns `false` for application-level JSON-RPC errors where
|
|
/// the server is healthy but rejected the request (e.g. invalid params).
|
|
fn is_transport_error(e: &ServiceError) -> bool {
|
|
matches!(
|
|
e,
|
|
ServiceError::TransportSend(_)
|
|
| ServiceError::TransportClosed
|
|
| ServiceError::Timeout { .. }
|
|
| ServiceError::UnexpectedResponse
|
|
)
|
|
}
|
|
|
|
fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
|
|
let shift = attempt.saturating_sub(1).min(20);
|
|
let scaled = base.saturating_mul(1u32 << shift);
|
|
let capped = scaled.min(max);
|
|
let ms = capped.as_millis() as u64;
|
|
let jitter_pct = jitter_percent();
|
|
let jittered = (ms as i64) + ((ms as i64) * jitter_pct / 100);
|
|
Duration::from_millis(jittered.max(0) as u64)
|
|
}
|
|
|
|
fn jitter_percent() -> i64 {
|
|
let mut buf = [0u8; 1];
|
|
let _ = getrandom::fill(&mut buf);
|
|
((buf[0] as i64) % 41) - 20
|
|
}
|
|
|
|
fn timeout_msg(stage: &str, name: &str, t: Duration) -> String {
|
|
format!("{stage} {name}: timeout after {}s", t.as_secs())
|
|
}
|
|
|
|
fn cap_schema(qname: &str, schema: Value) -> Value {
|
|
let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0);
|
|
if size <= MAX_SCHEMA_BYTES {
|
|
return schema;
|
|
}
|
|
tracing::warn!(
|
|
"tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object"
|
|
);
|
|
Value::Object(Map::new())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn killpg(pgid: u32, name: &str, stage: &str) {
|
|
use nix::sys::signal::{killpg as nix_killpg, Signal};
|
|
use nix::unistd::Pid;
|
|
let result = nix_killpg(Pid::from_raw(pgid as i32), Signal::SIGKILL);
|
|
tracing::info!(
|
|
"killpg MCP {name} ({stage}) pgid={pgid} ok={}",
|
|
result.is_ok()
|
|
);
|
|
}
|
|
#[cfg(not(unix))]
|
|
fn killpg(_pgid: u32, name: &str, stage: &str) {
|
|
tracing::info!("relying on Drop to kill MCP {name} ({stage})");
|
|
}
|
|
|
|
fn valid_name(s: &str) -> bool {
|
|
!s.is_empty()
|
|
&& s.len() <= MAX_NAME_LEN
|
|
&& s.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
|
}
|
|
|
|
pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str {
|
|
if s.len() <= max {
|
|
return s;
|
|
}
|
|
let mut cut = max;
|
|
while cut > 0 && !s.is_char_boundary(cut) {
|
|
cut -= 1;
|
|
}
|
|
&s[..cut]
|
|
}
|
|
|
|
/// Byte allowance reserved for the elision marker inside [`truncate_middle`].
|
|
/// The marker is ~80 bytes; the slack keeps the arithmetic safely one-sided.
|
|
const ELISION_MARKER_ALLOWANCE: usize = 128;
|
|
|
|
/// Truncate `s` to at most `max` bytes by eliding the *middle*, keeping the
|
|
/// head and tail. Tool output puts its conclusion at the end (test summaries,
|
|
/// error trailers) and its identity at the start; head-only truncation loses
|
|
/// the part the model needs most. The marker reports how much was elided so
|
|
/// the model knows to re-run a narrower command rather than trust the gap.
|
|
pub(crate) fn truncate_middle(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
return s.to_owned();
|
|
}
|
|
let keep = max.saturating_sub(ELISION_MARKER_ALLOWANCE);
|
|
if keep == 0 {
|
|
// Budget too small for head + marker + tail; degrade to a head cut.
|
|
return truncate_at_boundary(s, max).to_owned();
|
|
}
|
|
let head = truncate_at_boundary(s, keep.div_ceil(2));
|
|
let mut tail_start = s.len() - keep / 2;
|
|
while tail_start < s.len() && !s.is_char_boundary(tail_start) {
|
|
tail_start += 1;
|
|
}
|
|
let tail = &s[tail_start..];
|
|
let elided = s.len() - head.len() - tail.len();
|
|
format!(
|
|
"{head}\n[... {elided} of {} bytes elided from tool result ...]\n{tail}",
|
|
s.len()
|
|
)
|
|
}
|
|
|
|
/// Assemble tool-result content under two budgets: `max_bytes` bounds the
|
|
/// whole result (text + images), `max_text_bytes` bounds the text portion
|
|
/// alone. Images are large by nature and pass through whole or get elided
|
|
/// with a marker; text is middle-elided so the head (what ran) and tail
|
|
/// (how it ended) both survive. Every elision leaves an inline marker.
|
|
fn tool_result_content(
|
|
blocks: &[rmcp::model::Content],
|
|
max_bytes: usize,
|
|
max_text_bytes: usize,
|
|
) -> Vec<ToolResultContent> {
|
|
use rmcp::model::RawContent;
|
|
let mut out = Vec::new();
|
|
let mut text = String::new();
|
|
let mut used = 0usize; // total bytes emitted (text + images)
|
|
let mut text_used = 0usize; // text bytes emitted
|
|
let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned();
|
|
|
|
// Flush accumulated text, middle-eliding to whatever budget remains.
|
|
let flush_text = |out: &mut Vec<ToolResultContent>,
|
|
text: &mut String,
|
|
used: &mut usize,
|
|
text_used: &mut usize| {
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let budget = max_text_bytes
|
|
.saturating_sub(*text_used)
|
|
.min(max_bytes.saturating_sub(*used));
|
|
let kept = truncate_middle(&std::mem::take(text), budget);
|
|
*used = used.saturating_add(kept.len());
|
|
*text_used = text_used.saturating_add(kept.len());
|
|
if !kept.is_empty() {
|
|
out.push(ToolResultContent::Text(kept));
|
|
}
|
|
};
|
|
|
|
let append = |text: &mut String, s: &str| {
|
|
if !text.is_empty() {
|
|
text.push('\n');
|
|
}
|
|
text.push_str(s);
|
|
};
|
|
|
|
for c in blocks {
|
|
match &c.raw {
|
|
RawContent::Text(t) => append(&mut text, &t.text),
|
|
RawContent::Image(i) => {
|
|
flush_text(&mut out, &mut text, &mut used, &mut text_used);
|
|
let image_bytes = i.data.len().saturating_add(i.mime_type.len());
|
|
if used.saturating_add(image_bytes) <= max_bytes {
|
|
used = used.saturating_add(image_bytes);
|
|
out.push(ToolResultContent::Image {
|
|
data: i.data.clone(),
|
|
mime_type: i.mime_type.clone(),
|
|
});
|
|
} else {
|
|
append(
|
|
&mut text,
|
|
&format!(
|
|
"[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]",
|
|
short(&i.mime_type),
|
|
i.data.len()
|
|
),
|
|
);
|
|
}
|
|
}
|
|
RawContent::Audio(a) => append(
|
|
&mut text,
|
|
&format!(
|
|
"[audio elided: {}, {} bytes]",
|
|
short(&a.mime_type),
|
|
a.data.len()
|
|
),
|
|
),
|
|
RawContent::ResourceLink(r) => {
|
|
append(&mut text, &format!("[resource: {}]", short(&r.uri)));
|
|
}
|
|
RawContent::Resource(_) => append(&mut text, "[resource elided]"),
|
|
}
|
|
}
|
|
flush_text(&mut out, &mut text, &mut used, &mut text_used);
|
|
out
|
|
}
|
|
|
|
/// Suppress the console window that Windows otherwise allocates for every
|
|
/// console-subsystem child process spawned from a GUI (non-console) parent.
|
|
/// No-op on non-Windows platforms.
|
|
fn configure_no_window(cmd: &mut Command) {
|
|
#[cfg(windows)]
|
|
{
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(not(windows))]
|
|
let _ = cmd;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod content_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn passthrough_includes_buzz_owner_attestation() {
|
|
assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG"));
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_carries_proxy_configuration_to_tools() {
|
|
// On a proxy-only host this is the difference between an agent that can
|
|
// install a package and one that reports the network is down. Both
|
|
// spellings: libcurl ignores uppercase HTTP_PROXY, and Go/Python
|
|
// tooling largely ignores the lowercase set.
|
|
for var in [
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
"all_proxy",
|
|
] {
|
|
assert!(
|
|
PASSTHROUGH_ENV.contains(&var),
|
|
"{var} must survive env_clear() or every MCP tool loses the proxy"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_carries_tls_trust_to_tools() {
|
|
// A TLS-terminating proxy presents its own CA; without these the child
|
|
// rejects every https fetch even though the proxy itself is reachable.
|
|
for var in ["SSL_CERT_FILE", "SSL_CERT_DIR"] {
|
|
assert!(
|
|
PASSTHROUGH_ENV.contains(&var),
|
|
"{var} must survive env_clear() or https fails inside tools"
|
|
);
|
|
}
|
|
}
|
|
use rmcp::model::Content;
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_passthrough_includes_shell_resolution_vars() {
|
|
// Temp directories and every resolver key must survive `env_clear()`.
|
|
for var in ["TMP", "TEMP", "USERPROFILE"] {
|
|
assert!(
|
|
PASSTHROUGH_ENV_WINDOWS.contains(&var),
|
|
"{var} must pass through for Windows child processes"
|
|
);
|
|
}
|
|
let child_env: Vec<_> = windows_child_passthrough_env().collect();
|
|
for var in crate::WINDOWS_SHELL_RESOLUTION_ENV {
|
|
assert!(
|
|
child_env.contains(var),
|
|
"{var} must pass through so the MCP shell resolver matches Doctor"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_result_content_preserves_images() {
|
|
let blocks = vec![
|
|
Content::text("header"),
|
|
Content::image("aW1n", "image/png"),
|
|
Content::text("tail"),
|
|
];
|
|
let out = tool_result_content(&blocks, 1024, 1024);
|
|
assert_eq!(out.len(), 3);
|
|
assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "header"));
|
|
assert!(matches!(
|
|
&out[1],
|
|
ToolResultContent::Image { data, mime_type }
|
|
if data == "aW1n" && mime_type == "image/png"
|
|
));
|
|
assert!(matches!(&out[2], ToolResultContent::Text(t) if t == "tail"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_result_content_elides_images_over_budget() {
|
|
let blocks = vec![Content::image("a".repeat(300), "image/png")];
|
|
let out = tool_result_content(&blocks, 256, 256);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(matches!(&out[0], ToolResultContent::Text(t) if t.contains("image elided")));
|
|
}
|
|
|
|
#[test]
|
|
fn oversized_text_is_middle_elided() {
|
|
let mut body = String::new();
|
|
for i in 0..5000 {
|
|
body.push_str(&format!("line {i}\n"));
|
|
}
|
|
let blocks = vec![Content::text(body.clone())];
|
|
let out = tool_result_content(&blocks, 1024 * 1024, 4096);
|
|
assert_eq!(out.len(), 1);
|
|
let ToolResultContent::Text(t) = &out[0] else {
|
|
panic!("expected text");
|
|
};
|
|
assert!(t.len() <= 4096, "text exceeds budget: {}", t.len());
|
|
assert!(t.starts_with("line 0\n"), "head lost");
|
|
assert!(t.ends_with("line 4999\n"), "tail lost");
|
|
assert!(
|
|
t.contains("bytes elided from tool result"),
|
|
"missing elision marker"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn text_within_budget_is_untouched() {
|
|
let blocks = vec![Content::text("short output")];
|
|
let out = tool_result_content(&blocks, 1024 * 1024, 4096);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "short output"));
|
|
}
|
|
|
|
#[test]
|
|
fn image_passes_whole_even_when_text_budget_is_small() {
|
|
let big_text = "x".repeat(10_000);
|
|
let img = "a".repeat(100_000);
|
|
let blocks = vec![
|
|
Content::text(big_text),
|
|
Content::image(img.clone(), "image/png"),
|
|
];
|
|
let out = tool_result_content(&blocks, 8 * 1024 * 1024, 4096);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(matches!(&out[0], ToolResultContent::Text(t) if t.len() <= 4096));
|
|
assert!(matches!(
|
|
&out[1],
|
|
ToolResultContent::Image { data, .. } if data == &img
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_middle_respects_max_and_boundaries() {
|
|
let s = "é".repeat(60_000); // 2-byte chars stress boundary handling
|
|
for max in [200usize, 1024, 50 * 1024] {
|
|
let out = super::truncate_middle(&s, max);
|
|
assert!(out.len() <= max, "max={max} got {}", out.len());
|
|
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
|
|
}
|
|
assert_eq!(super::truncate_middle("ok", 1024), "ok");
|
|
}
|
|
|
|
#[test]
|
|
fn configure_no_window_is_a_noop_on_non_windows() {
|
|
// Cross-host: calling configure_no_window must not panic on any OS.
|
|
// On non-Windows the body is a cfg-gated no-op and the argument is
|
|
// consumed as `let _ = cmd`, so the only assertion is "didn't crash".
|
|
let mut cmd = Command::new("true");
|
|
configure_no_window(&mut cmd);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn configure_no_window_compiles_and_applies_flag_on_windows() {
|
|
// On Windows, creation_flags(0x0800_0000) must be accepted without panicking.
|
|
// The call is a setter with no getter on tokio::process::Command, so the
|
|
// regression test confirms the flag is SET by checking the std inner command.
|
|
let mut cmd = Command::new("cmd.exe");
|
|
configure_no_window(&mut cmd);
|
|
// std::process::Command on Windows does have as_inner / get_creation_flags via
|
|
// CommandExt — but tokio wraps it; we verify by ensuring the call compiles and
|
|
// the resulting spawn wouldn't OOM (build+flag-set is the full contract here).
|
|
// The real protection is the cfg-gated production path in spawn_one().
|
|
}
|
|
}
|