diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index d2642b8e58..9385702656 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -157,7 +157,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | `3` | Retries after a successful response is truncated at the output-token limit. `0` disables recovery; the finite value and `BUZZ_AGENT_MAX_ROUNDS` prevent infinite retries. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | — | **No longer read.** goose's compaction replaced the handoff mechanism it bounded. | -| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | — | **No longer read.** goose owns provider transport and its own per-provider timeouts (`OPENAI_TIMEOUT`, `ANTHROPIC_TIMEOUT`, default 600s). | +| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | goose's own (600 s) | Per-request timeout. goose owns provider transport and reads this **per provider**, so buzz projects the value onto the variable belonging to the configured provider — `OPENAI_TIMEOUT` (which covers `relay-mesh` and the other OpenAI-wire providers), `ANTHROPIC_TIMEOUT`, `OLLAMA_TIMEOUT`, `LITELLM_TIMEOUT`. Providers goose gives no timeout knob (databricks) cannot honour it. | | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds. Projected onto goose's `GOOSE_DEFAULT_EXTENSION_TIMEOUT`. | | `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | — | **No longer read.** All of a round's tool calls are dispatched concurrently; there is no cap to configure. | | `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. | @@ -333,7 +333,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) | | Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | | Loop rounds | 0 (unlimited) | `BUZZ_AGENT_MAX_ROUNDS` | -| LLM read inactivity timeout | 240 s | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | +| LLM request timeout | goose's own (600 s) | `BUZZ_AGENT_LLM_TIMEOUT_SECS` (projected per provider) | | Tool call timeout | 660 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | ## What This Is NOT diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 2a2b819f41..6d8c2f9946 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -122,6 +122,26 @@ fn goose_provider_name(provider: &str) -> &str { } } +/// goose's per-request timeout variable for a Buzz provider id, if it has one. +/// +/// goose reads the timeout per provider rather than globally, so the name +/// depends on which provider the agent is configured for. `relay-mesh` and the +/// other OpenAI-wire providers all run through goose's `openai` provider and so +/// read `OPENAI_TIMEOUT` (`goose/src/providers/openai_def.rs:118`). +/// +/// Returns `None` for providers whose goose implementation has no timeout knob +/// (databricks among them) — there the buzz value cannot be honoured, and +/// inventing a variable goose never reads would be worse than not setting one. +fn provider_timeout_env_key(provider: &str) -> Option<&'static str> { + match goose_provider_name(provider) { + "openai" => Some("OPENAI_TIMEOUT"), + "anthropic" => Some("ANTHROPIC_TIMEOUT"), + "ollama" => Some("OLLAMA_TIMEOUT"), + "litellm" => Some("LITELLM_TIMEOUT"), + _ => None, + } +} + fn env_str(key: &str) -> Option { std::env::var(key).ok().filter(|s| !s.trim().is_empty()) } @@ -263,6 +283,27 @@ impl Config { .unwrap_or_else(|| DEFAULT_TOOL_RESULT_TEXT_BYTES.to_string()), ); + // Per-LLM-call timeout. goose has no single provider-agnostic name for + // this — each provider reads its own (`OPENAI_TIMEOUT`, + // `ANTHROPIC_TIMEOUT`, ...) — so this projects onto the one belonging + // to the provider actually in use. + // + // This is load-bearing for shared compute, not hygiene. The desktop + // seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents + // (`managed_agents/relay_mesh.rs`, PR #6115) because MeshLLM's own + // backend budget is 600 s and a cold prefill of a large prompt can + // legitimately take ~500 s. Without this projection that seed reaches + // nothing on this branch: goose's default is 600 s, just under the + // server's, so the client would abort a request the mesh is still + // serving — the exact failure #6115 diagnosed and fixed. + if let Some(timeout) = env_str("BUZZ_AGENT_LLM_TIMEOUT_SECS") { + if let Some(key) = + provider_timeout_env_key(&env_str("BUZZ_AGENT_PROVIDER").unwrap_or_default()) + { + set_if_absent(key, &timeout); + } + } + // `BUZZ_AGENT_NO_HINTS=1` suppressed AGENTS.md/.goosehints loading. // goose has no boolean for this, but it takes the *filename list* — // an empty list finds nothing, which is the same outcome. @@ -299,6 +340,69 @@ mod tests { // Env is process-global; these tests set disjoint keys and assert only on // the pure mapping helpers where possible. + /// The mesh client budget must outlast the mesh server's own. + /// + /// The desktop seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents + /// (PR #6115) against MeshLLM's 600 s backend budget. goose reads the + /// timeout per provider, so without the projection under test the seed + /// reaches nothing and goose's own 600 s default applies — under the + /// server's, which is what made Buzz abandon prefills the mesh was still + /// serving. Asserts the invariant (`> 600`) rather than the literal. + #[test] + fn mesh_llm_timeout_outlasts_the_mesh_backend_budget() { + let _guard = env_lock(); + for key in [ + "BUZZ_AGENT_LLM_TIMEOUT_SECS", + "BUZZ_AGENT_PROVIDER", + "OPENAI_TIMEOUT", + "GOOSE_PROVIDER", + ] { + std::env::remove_var(key); + } + + std::env::set_var("BUZZ_AGENT_PROVIDER", "relay-mesh"); + std::env::set_var("BUZZ_AGENT_LLM_TIMEOUT_SECS", "660"); + Config::project_goose_env(); + + let seated: u64 = std::env::var("OPENAI_TIMEOUT") + .expect("mesh agents must carry a timeout onto goose's openai provider") + .parse() + .expect("timeout must be numeric"); + assert!( + seated > 600, + "client budget {seated}s must outlast MeshLLM's 600s backend budget" + ); + + for key in [ + "BUZZ_AGENT_LLM_TIMEOUT_SECS", + "BUZZ_AGENT_PROVIDER", + "OPENAI_TIMEOUT", + "GOOSE_PROVIDER", + ] { + std::env::remove_var(key); + } + } + + /// A provider goose gives no timeout knob must not get a bogus one. + #[test] + fn provider_without_a_timeout_knob_projects_nothing() { + assert_eq!( + provider_timeout_env_key("relay-mesh"), + Some("OPENAI_TIMEOUT") + ); + assert_eq!( + provider_timeout_env_key("openai-compat"), + Some("OPENAI_TIMEOUT") + ); + assert_eq!( + provider_timeout_env_key("anthropic"), + Some("ANTHROPIC_TIMEOUT") + ); + // goose's databricks providers read no timeout variable; inventing one + // would be a silent no-op dressed up as support. + assert_eq!(provider_timeout_env_key("databricks-v2"), None); + } + /// Every `BUZZ_AGENT_*` knob whose implementation moved to goose must /// still reach goose under its goose name. A knob that silently stops /// working is the regression this test exists to prevent.