From e5f73cd27c53e868295814ff899576cf06ef6869 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 18 Aug 2026 12:09:57 +1000 Subject: [PATCH] fix(buzz-agent): carry BUZZ_AGENT_LLM_TIMEOUT_SECS onto goose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #6115 seats a 660 s client budget for mesh agents in the desktop (`managed_agents/relay_mesh.rs`) because MeshLLM's own backend budget is 600 s and a cold prefill of a large agent prompt can legitimately take ~500 s. On this branch that seed reached nothing: buzz-agent no longer has a request loop, and `project_goose_env` had no projection for the variable, so goose's own default applied instead. goose's default is 600 s (`DEFAULT_PROVIDER_TIMEOUT_SECS`) — just *under* the mesh server's, which is the same wrong-side-of-the-server ordering #6115 diagnosed. So the bug #6115 fixes would have come back on merge, silently and only on shared compute. goose reads the timeout per provider rather than globally, so this projects onto the variable belonging to the configured provider. `relay-mesh` maps to goose's `openai` provider and so reads `OPENAI_TIMEOUT` (`goose/src/providers/openai_def.rs:118`). Providers goose gives no timeout knob — databricks — return `None` rather than getting a variable goose never reads. Two tests. The mesh one asserts the invariant (`> 600`) rather than the literal, and I verified it fails with the projection removed and the test kept, reconstructing that baseline from the file rather than `git stash`. README corrected: it claimed this knob was "no longer read", and its limits table still listed the pre-goose 240 s default. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/README.md | 4 +- crates/buzz-agent/src/config.rs | 104 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) 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.