mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(agent): raise output limit and allow 3 recoveries (#5475)
## Summary Raise the built-in output and recovery defaults so long-running agents have more room to finish useful work instead of terminating after repeated 32,768-token reasoning-only responses. - Raise `BUZZ_AGENT_MAX_OUTPUT_TOKENS` from 32,768 to 65,536 - Raise the finite output-truncation recovery allowance from 2 to 3 via `BUZZ_AGENT_MAX_TOKEN_RECOVERIES`; `0` still disables recovery - Strengthen the recovery prompt so the model stops prolonged reasoning, uses tools immediately, and builds scripts or artifacts in small verifiable steps - Preserve the safety invariant that incomplete truncated tool calls are discarded and never executed - Keep proactive handoff independently at 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS` (180,000 tokens with the 200,000 default), regardless of the output allowance - Add request-loop and configuration regressions for exact-N recovery, disabled recovery, successful tool-first recovery, discarded truncated calls, and finite round bounds `BUZZ_AGENT_MAX_OUTPUT_TOKENS` remains an explicit per-agent deployment setting. Operators should configure it at or below the served model's output limit; this PR does not perform live provider capability discovery or automatic clamping. **Risk:** Medium — this increases the default request size and permits one additional recovery attempt by default. Recovery remains finite and bounded by `BUZZ_AGENT_MAX_ROUNDS`. Deployments whose served model rejects 65,536 output tokens must set a lower per-agent value. Current output limits - model - output token max - DeepSeek V4 Flash - 384,000 tokens - Qwen 3.8 (Max) - 131,072 tokens - GLM 5.2 - 131,072 tokens - GPT 5.6 - 128,000 tokens - Claude Opus 5 - 128,000 tokens - Gemini 3.6 Flash - 65,536 tokens - Kimi K3 (Moonshot)- 131,072 tokens ### Related issue None found. Originating benchmark analysis: `buzz://message?channel=c3252dd2-0142-4e01-88c7-a2183c3960a5&id=91e991aab5fd49094583c3937477f6c12db57a41d86edf7fd4745d0d57d10017` ### Testing - `cargo fmt --all -- --check` - `cargo test -p buzz-agent` — 595 passed, 0 failed, 0 ignored at `bd6de557b367850f50325bafdd3c046131942bef` - `cargo clippy -p buzz-agent --all-targets -- -D warnings` - Previously failing `cancelled_turn_with_usage_emits_notification_before_response` passed alone and in the full rerun - Push hooks passed: organization guard, branch skew, Rust tests, and Desktop Tauri checks ### Update — 2026-08-11 Per review feedback, the recovery default is 3. The OpenRouter live `/models` output-cap discovery, cache, request clamp, and related tests/documentation were removed. Per-agent output configuration is now the sole output-cap mechanism. Proactive handoff and its pre-usage byte fallback now depend only on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`; with the 200,000 default, the handoff threshold is 180,000 regardless of `BUZZ_AGENT_MAX_OUTPUT_TOKENS`. Generated with Brainy Bumble ### Targeted validation — 2026-08-11 Ran the exact PR binary once on each of the 11 benchmark tasks causally affected by the previous 32,768-token ceiling, using OpenRouter with `deepseek/deepseek-v4-flash-0731` pinned to Fireworks and maximum reasoning effort. Relay-429 collection failures were excluded and rerun at concurrency 2. - **6/11 passed:** `circuit-fibsqrt`, `feal-linear-cryptanalysis`, `model-extraction-relu-logits`, `path-tracing`, `schemelike-metacircular-eval`, and `sqlite-db-truncate` - **5/11 reached the benchmark deadline:** `adaptive-rejection-sampler`, `dna-assembly`, `path-tracing-reverse`, `regex-chess`, and `write-compressor` - `regex-chess` reached exactly 65,536 output tokens, triggered one output-limit recovery, and then reached the deadline. This directly confirms that the larger ceiling and recovery path were active, but not that recovery guarantees completion. For context, ten of these tasks were 0/5 in the historical baseline; `sqlite-db-truncate`, the clean control, was 4/5. This is targeted one-attempt-per-task validation rather than a statistically powered comparison. The result should not be attributed solely to the recovery default of 3: this PR also raises the output ceiling and strengthens recovery behavior, and OpenRouter routing conditions may differ from the historical direct-Fireworks runs. Generated with Brainy Bumble --------- Signed-off-by: Atish Patel <atish@squareup.com> Signed-off-by: Brainy Bumble <0ed7657b57c0e8a9f5288390dd6c8d5d0a3a06abe9b01b9006814f52077d6cdf@buzz.block.builderlab.xyz> Signed-off-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz> Co-authored-by: Brainy Bumble <0ed7657b57c0e8a9f5288390dd6c8d5d0a3a06abe9b01b9006814f52077d6cdf@buzz.block.builderlab.xyz> Co-authored-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -153,7 +153,8 @@ Everything is environment variables. No flags, no config files. (We are a subpro
|
||||
| `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
|
||||
| `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
|
||||
| `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. |
|
||||
| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `32768` | Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. |
|
||||
| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `65536` | Desired per-call ceiling. Set this at or below the served model's output limit for each agent deployment. Proactive handoff is independently based on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. |
|
||||
| `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` | `10` | Max context handoffs per session before falling back to truncation. |
|
||||
| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). |
|
||||
|
||||
@@ -31,12 +31,7 @@ const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support
|
||||
/// its output-token limit. This is a user message rather than a synthetic tool
|
||||
/// result because truncation can happen without a tool call (and an unpaired
|
||||
/// tool result is invalid on every provider wire format).
|
||||
const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response exceeded the model's output token limit and was truncated. Any incomplete tool call was not run. Continue the task, breaking the work or tool call into smaller steps and keeping the response concise.";
|
||||
|
||||
/// A provider can repeatedly spend its entire output allowance without making
|
||||
/// progress, while `max_rounds` is unbounded by default. Keep the in-turn rescue
|
||||
/// finite so a persistently truncating model eventually surfaces `max_tokens`.
|
||||
const MAX_TOKENS_RECOVERIES_PER_RUN: u32 = 2;
|
||||
const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response reached the model's output token limit and was truncated. Any incomplete tool calls were discarded and were not run. Stop prolonged internal reasoning now. Use the available tools immediately: write a script or artifact to a file and run it in small, verifiable steps instead of emitting the entire solution inline. Continue the task concisely from the preserved text.";
|
||||
|
||||
/// Remove image blocks that the provider has explicitly rejected while keeping
|
||||
/// their surrounding tool result (and therefore the tool-call/result pairing)
|
||||
@@ -667,9 +662,10 @@ impl RunCtx<'_> {
|
||||
tool_calls: Vec::new(),
|
||||
reasoning_details: response.reasoning_details,
|
||||
});
|
||||
if max_tokens_recoveries >= MAX_TOKENS_RECOVERIES_PER_RUN {
|
||||
if max_tokens_recoveries >= self.cfg.max_token_recoveries {
|
||||
tracing::warn!(
|
||||
recoveries = max_tokens_recoveries,
|
||||
max_recoveries = self.cfg.max_token_recoveries,
|
||||
"provider repeatedly hit output token limit; recovery budget exhausted"
|
||||
);
|
||||
return Ok(StopReason::MaxTokens);
|
||||
@@ -677,8 +673,7 @@ impl RunCtx<'_> {
|
||||
max_tokens_recoveries = max_tokens_recoveries.saturating_add(1);
|
||||
tracing::warn!(
|
||||
recovery = max_tokens_recoveries,
|
||||
max_recoveries = MAX_TOKENS_RECOVERIES_PER_RUN,
|
||||
discarded_tool_calls = response.tool_calls.len(),
|
||||
max_recoveries = self.cfg.max_token_recoveries,
|
||||
"provider hit output token limit; asking model to continue in smaller steps"
|
||||
);
|
||||
self.history
|
||||
|
||||
@@ -781,6 +781,10 @@ pub struct Config {
|
||||
pub system_prompt: String,
|
||||
pub max_rounds: u32,
|
||||
pub max_output_tokens: u32,
|
||||
/// Maximum number of retries after a provider returns a successful but
|
||||
/// output-truncated response. Zero disables truncation recovery. This is
|
||||
/// independent of `max_rounds`, which still bounds all successful calls.
|
||||
pub max_token_recoveries: u32,
|
||||
pub llm_timeout: Duration,
|
||||
pub tool_timeout: Duration,
|
||||
pub mcp_init_timeout: Duration,
|
||||
@@ -931,7 +935,8 @@ impl Config {
|
||||
anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
|
||||
openai_api,
|
||||
max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?,
|
||||
max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 32_768)?,
|
||||
max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?,
|
||||
max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?,
|
||||
llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?),
|
||||
tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 660)?),
|
||||
mcp_init_timeout: Duration::from_secs(parse_env(
|
||||
@@ -983,6 +988,7 @@ impl Config {
|
||||
openai_api: OpenAiApi::Chat,
|
||||
max_rounds: 0,
|
||||
max_output_tokens: 1,
|
||||
max_token_recoveries: 0,
|
||||
llm_timeout: Duration::from_secs(30),
|
||||
tool_timeout: Duration::from_secs(30),
|
||||
mcp_init_timeout: Duration::from_secs(30),
|
||||
|
||||
@@ -59,8 +59,7 @@ impl RunCtx<'_> {
|
||||
}
|
||||
if *handoff_attempts >= self.cfg.max_handoffs {
|
||||
let projected = self.projected_handoff_input_tokens();
|
||||
let threshold =
|
||||
token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens);
|
||||
let threshold = token_threshold(self.cfg.max_context_tokens);
|
||||
tracing::warn!(
|
||||
session_id = self.session_id,
|
||||
reason = "preflight",
|
||||
@@ -246,7 +245,7 @@ impl RunCtx<'_> {
|
||||
match *self.last_request_input_tokens {
|
||||
Some(_) => {
|
||||
self.projected_handoff_input_tokens()
|
||||
>= token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens)
|
||||
>= token_threshold(self.cfg.max_context_tokens)
|
||||
}
|
||||
None => {
|
||||
let bytes: usize = self
|
||||
@@ -257,7 +256,6 @@ impl RunCtx<'_> {
|
||||
bytes
|
||||
> byte_fallback_threshold(
|
||||
self.cfg.max_context_tokens,
|
||||
self.cfg.max_output_tokens,
|
||||
self.cfg.max_history_bytes,
|
||||
)
|
||||
}
|
||||
@@ -495,28 +493,20 @@ fn estimate_tokens_from_bytes(bytes: usize) -> u64 {
|
||||
(bytes as u64).div_ceil(CONSERVATIVE_BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
/// Input-token count at which to hand off. Caps at the configured fraction of
|
||||
/// the window and also leaves room for `max_output_tokens`, so input + output
|
||||
/// can't together exceed the window. Free function so the policy math is unit
|
||||
/// testable without constructing a [`RunCtx`].
|
||||
fn token_threshold(max_context_tokens: u64, max_output_tokens: u32) -> u64 {
|
||||
/// Input-token count at which to hand off. Uses 90% of the configured context
|
||||
/// window, independent of the request's output allowance. Free function so the
|
||||
/// policy math is unit testable without constructing a [`RunCtx`].
|
||||
fn token_threshold(max_context_tokens: u64) -> u64 {
|
||||
// Integer math: handoff threshold is 90%, i.e. window * 9 / 10.
|
||||
let fractional = max_context_tokens / 10 * 9;
|
||||
let output_reserved = max_context_tokens.saturating_sub(u64::from(max_output_tokens));
|
||||
fractional.min(output_reserved)
|
||||
max_context_tokens / 10 * 9
|
||||
}
|
||||
|
||||
/// Conservative byte cap used only before any usage is known. Maps the token
|
||||
/// threshold to bytes at the conservative bytes/token ratio (so the cap is
|
||||
/// small and the handoff fires early), clamped to the configured byte budget
|
||||
/// so it can only ever be more conservative than the old byte-only behavior.
|
||||
fn byte_fallback_threshold(
|
||||
max_context_tokens: u64,
|
||||
max_output_tokens: u32,
|
||||
max_history_bytes: usize,
|
||||
) -> usize {
|
||||
let derived = token_threshold(max_context_tokens, max_output_tokens)
|
||||
.saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN);
|
||||
fn byte_fallback_threshold(max_context_tokens: u64, max_history_bytes: usize) -> usize {
|
||||
let derived = token_threshold(max_context_tokens).saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN);
|
||||
let byte_cap = max_history_bytes / 10 * 9;
|
||||
usize::try_from(derived).unwrap_or(usize::MAX).min(byte_cap)
|
||||
}
|
||||
@@ -605,36 +595,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_uses_fraction_when_output_is_small() {
|
||||
// 200k window, 1k output. fractional = 0.9*200000 = 180000;
|
||||
// output_reserved = 200000-1000 = 199000; min = 180000.
|
||||
assert_eq!(token_threshold(200_000, 1_000), 180_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_reserves_output_headroom() {
|
||||
// Large output relative to window: the output-reserve term dominates,
|
||||
// keeping input+output within the window.
|
||||
// 100k window, 40k output: fractional=90k, reserved=60k -> 60k.
|
||||
assert_eq!(token_threshold(100_000, 40_000), 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_saturates_when_output_exceeds_window() {
|
||||
// Degenerate (config validation forbids this, but math must not panic):
|
||||
// reserved saturates to 0, so threshold is 0 -> always hand off.
|
||||
assert_eq!(token_threshold(1000, 5000), 0);
|
||||
fn token_threshold_is_independent_of_output_allowance() {
|
||||
// Handoff always begins at 90% of the input context budget, including
|
||||
// when the request's output allowance grows or exceeds the window.
|
||||
assert_eq!(token_threshold(200_000), 180_000);
|
||||
assert_eq!(token_threshold(100_000), 90_000);
|
||||
assert_eq!(token_threshold(1_000), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_fallback_is_conservative_and_capped() {
|
||||
// Derived = token_threshold * 1 (1 byte/token upper bound). For
|
||||
// 200k/1k: 180000 bytes, well under a 16 MiB byte budget, so derived
|
||||
// wins (early handoff).
|
||||
let t = byte_fallback_threshold(200_000, 1_000, 16 * 1024 * 1024);
|
||||
// 200k window: 180000 bytes, well under a 16 MiB byte budget, so the
|
||||
// derived threshold wins (early handoff).
|
||||
let t = byte_fallback_threshold(200_000, 16 * 1024 * 1024);
|
||||
assert_eq!(t, 180_000);
|
||||
// With a tiny byte budget the cap wins -> never exceeds it (window*90%).
|
||||
let capped = byte_fallback_threshold(200_000, 1_000, 8192);
|
||||
let capped = byte_fallback_threshold(200_000, 8192);
|
||||
assert_eq!(capped, 8192 / 10 * 9);
|
||||
}
|
||||
|
||||
|
||||
@@ -1135,7 +1135,7 @@ fn map_stop(s: Option<&str>) -> ProviderStop {
|
||||
match s {
|
||||
Some("end_turn" | "stop") => ProviderStop::EndTurn,
|
||||
Some("tool_use" | "tool_calls") => ProviderStop::ToolUse,
|
||||
Some("max_tokens" | "length") => ProviderStop::MaxTokens,
|
||||
Some("max_tokens" | "length" | "model_context_window_exceeded") => ProviderStop::MaxTokens,
|
||||
Some("refusal" | "content_filter") => ProviderStop::Refusal,
|
||||
_ => ProviderStop::Other,
|
||||
}
|
||||
@@ -2589,6 +2589,7 @@ mod tests {
|
||||
system_prompt: "system".into(),
|
||||
max_rounds: 10,
|
||||
max_output_tokens: 1024,
|
||||
max_token_recoveries: 3,
|
||||
llm_timeout: Duration::from_secs(10),
|
||||
tool_timeout: Duration::from_secs(10),
|
||||
mcp_init_timeout: Duration::from_secs(10),
|
||||
@@ -3104,6 +3105,17 @@ mod tests {
|
||||
assert!(r.tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_context_window_exhaustion_is_truncation() {
|
||||
let v = serde_json::json!({
|
||||
"stop_reason": "model_context_window_exceeded",
|
||||
"content": [{"type": "text", "text": "partial text"}],
|
||||
});
|
||||
let r = parse_anthropic(v).unwrap();
|
||||
assert_eq!(r.stop, ProviderStop::MaxTokens);
|
||||
assert_eq!(r.text, "partial text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_anthropic_tool_use_is_discarded_not_rejected() {
|
||||
let v = serde_json::json!({
|
||||
|
||||
@@ -687,11 +687,11 @@ mod tests {
|
||||
// Regression: a single ~3.1M-base64-byte `view_image` result on an
|
||||
// otherwise-empty history must NOT exceed the default pre-usage
|
||||
// handoff cap. The gate's byte-fallback threshold with the shipped
|
||||
// defaults (max_context_tokens=200_000, max_output_tokens=32_768) is
|
||||
// min(200_000*9/10, 200_000-32_768) = 167_232 "bytes". Before the fix
|
||||
// defaults (max_context_tokens=200_000) is 200_000*9/10 = 180_000
|
||||
// "bytes". Before the fix
|
||||
// this item counted ~3.1M and tripped instantly.
|
||||
let item = image_item(3_118_884);
|
||||
const DEFAULT_PRE_USAGE_THRESHOLD: usize = 167_232;
|
||||
const DEFAULT_PRE_USAGE_THRESHOLD: usize = 180_000;
|
||||
assert!(
|
||||
item.context_pressure_bytes() <= DEFAULT_PRE_USAGE_THRESHOLD,
|
||||
"one image charged {} bytes of context pressure, over the {} threshold",
|
||||
|
||||
@@ -2314,6 +2314,27 @@ fn reply_guard_rejects_unparseable_toggle() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_token_recoveries_rejects_unparseable_value() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent"))
|
||||
.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
||||
.env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", "unbounded")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.expect("run buzz-agent");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"invalid recovery budget was accepted"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("BUZZ_AGENT_MAX_TOKEN_RECOVERIES"),
|
||||
"expected offending key in config error: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A prompt large enough that the recovery ladder's halving stays above
|
||||
/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs.
|
||||
///
|
||||
@@ -2494,9 +2515,11 @@ async fn max_tokens_recovers_in_turn_without_running_partial_tool_call() {
|
||||
);
|
||||
assert!(
|
||||
serialized.contains("output token limit")
|
||||
&& serialized.contains("smaller steps")
|
||||
&& serialized.contains("tool call"),
|
||||
"retry lacks actionable truncation feedback: {retry}"
|
||||
&& serialized.contains("Stop prolonged internal reasoning")
|
||||
&& serialized.contains("Use the available tools immediately")
|
||||
&& serialized.contains("write a script or artifact")
|
||||
&& serialized.contains("small, verifiable steps"),
|
||||
"retry lacks the tool-first truncation directive: {retry}"
|
||||
);
|
||||
assert!(
|
||||
!serialized.contains("partial-call") && !serialized.contains("tool_call_id"),
|
||||
@@ -2542,7 +2565,14 @@ async fn repeated_max_tokens_is_bounded() {
|
||||
.map(|_| openai_max_tokens("still truncated", json!([])))
|
||||
.collect();
|
||||
let llm = spawn_capturing_llm(responses).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_ROUNDS", "0")]).await;
|
||||
let mut h = Harness::spawn_with_env(
|
||||
&llm.url,
|
||||
&[
|
||||
("BUZZ_AGENT_MAX_ROUNDS", "0"),
|
||||
("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", "2"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let sid = init_session(&mut h, json!([])).await;
|
||||
let prompt_id = h
|
||||
.send(
|
||||
@@ -2561,6 +2591,93 @@ async fn repeated_max_tokens_is_bounded() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// The default value is the exact number of retries: three recoveries produce
|
||||
/// four truncating requests, then surface `max_tokens` without another call.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn default_max_tokens_recovery_budget_is_exact() {
|
||||
let responses = (0..5)
|
||||
.map(|_| openai_max_tokens("truncated", json!([])))
|
||||
.collect();
|
||||
let llm = spawn_capturing_llm(responses).await;
|
||||
let mut h = Harness::spawn(&llm.url).await;
|
||||
let sid = init_session(&mut h, json!([])).await;
|
||||
let prompt_id = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await;
|
||||
assert_eq!(reply["result"]["stopReason"], "max_tokens", "{reply}");
|
||||
let requests = llm.captured.lock().await;
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert_eq!(
|
||||
requests[0]["max_completion_tokens"], 65_536,
|
||||
"default output ceiling must be carried on the actual request path"
|
||||
);
|
||||
drop(requests);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Zero means disabled, not unlimited: the first truncated response is terminal
|
||||
/// and no recovery directive is sent.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn zero_max_token_recoveries_disables_retry() {
|
||||
let llm = spawn_capturing_llm(vec![
|
||||
openai_max_tokens("truncated", json!([])),
|
||||
openai_text("must not be requested"),
|
||||
])
|
||||
.await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", "0")]).await;
|
||||
let sid = init_session(&mut h, json!([])).await;
|
||||
let prompt_id = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await;
|
||||
assert_eq!(reply["result"]["stopReason"], "max_tokens", "{reply}");
|
||||
assert_eq!(llm.captured.lock().await.len(), 1);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// A successful recovery may proceed directly to a real tool call. This pins
|
||||
/// that only tool calls from the truncated response are discarded.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn max_tokens_recovery_can_proceed_to_tool_call() {
|
||||
let llm = spawn_capturing_llm(vec![
|
||||
openai_max_tokens(
|
||||
"partial",
|
||||
json!([{"id":"discard-me","type":"function","function":{"name":"dev__shell","arguments":"{\"command\":\"false\"}"}}]),
|
||||
),
|
||||
openai_tool_call("kept-call", "fake__tool_0", json!({})),
|
||||
openai_text("done"),
|
||||
])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&llm.url).await;
|
||||
let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await;
|
||||
let prompt_id = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await;
|
||||
assert_eq!(reply["result"]["stopReason"], "end_turn", "{reply}");
|
||||
let requests = llm.captured.lock().await;
|
||||
assert_eq!(requests.len(), 3);
|
||||
let wire = requests[1].to_string();
|
||||
assert!(
|
||||
!wire.contains("discard-me"),
|
||||
"discarded call leaked: {wire}"
|
||||
);
|
||||
assert!(requests[2].to_string().contains("kept-call"));
|
||||
drop(requests);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// A successful recovery must actually send the recovered completion, even when
|
||||
/// `max_rounds` is finite. `round` is incremented BEFORE the completion that
|
||||
/// gets rejected, so a naive `continue` after recovery re-enters the loop with
|
||||
|
||||
Reference in New Issue
Block a user