mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(buzz-agent): cap tool-result text at 50 KiB with middle elision (#952)
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
2846a96ed2
commit
84f499cb6e
@@ -153,6 +153,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
|
||||
| `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. |
|
||||
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
|
||||
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
|
||||
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
|
||||
|
||||
|
||||
## Providers
|
||||
@@ -234,7 +235,8 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP
|
||||
| History window | 1 MiB | `BUZZ_AGENT_MAX_HISTORY_BYTES` |
|
||||
| LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` |
|
||||
| LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` |
|
||||
| Tool result body | 256 KiB | `MAX_TOOL_RESULT_BYTES` |
|
||||
| Tool result body (total, incl. images) | 8 MiB | `MAX_TOOL_RESULT_BYTES` |
|
||||
| Tool result text | 50 KiB | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` |
|
||||
| MCP servers / session | 16 | `MAX_MCP_SERVERS` |
|
||||
| Tools / session | 128 | `MAX_TOOLS_PER_SESSION` |
|
||||
| Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` |
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_
|
||||
use crate::handoff::HandoffOutcome;
|
||||
use crate::llm::Llm;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::mcp::ResultBudget;
|
||||
|
||||
use crate::types::{
|
||||
AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
|
||||
@@ -304,6 +305,10 @@ impl RunCtx<'_> {
|
||||
let wire = self.wire.clone();
|
||||
let session_id = self.session_id.to_owned();
|
||||
let timeout = self.cfg.tool_timeout;
|
||||
let budget = ResultBudget {
|
||||
total: MAX_TOOL_RESULT_BYTES,
|
||||
text: self.cfg.max_tool_result_text_bytes,
|
||||
};
|
||||
let cancel = self.cancel.clone();
|
||||
let sem = Arc::clone(&sem);
|
||||
set.spawn(async move {
|
||||
@@ -317,7 +322,7 @@ impl RunCtx<'_> {
|
||||
}
|
||||
};
|
||||
emit_in_progress(&wire, &session_id, &call).await;
|
||||
let outcome = invoke_tool_inner(&mcp, &call, timeout, cancel).await;
|
||||
let outcome = invoke_tool_inner(&mcp, &call, timeout, budget, cancel).await;
|
||||
match &outcome {
|
||||
InvokeOutcome::Done(result) => {
|
||||
emit_completed(&wire, &session_id, &call, result).await;
|
||||
@@ -419,6 +424,7 @@ async fn invoke_tool_inner(
|
||||
mcp: &Arc<McpRegistry>,
|
||||
call: &ToolCall,
|
||||
tool_timeout: std::time::Duration,
|
||||
budget: ResultBudget,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
) -> InvokeOutcome {
|
||||
if *cancel.borrow() {
|
||||
@@ -430,7 +436,7 @@ async fn invoke_tool_inner(
|
||||
&call.name,
|
||||
&call.provider_id,
|
||||
&call.arguments,
|
||||
MAX_TOOL_RESULT_BYTES,
|
||||
budget,
|
||||
&mut cancel,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,16 @@ use std::time::Duration;
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
|
||||
/// Total per-result byte ceiling (text + images). Sized for image-bearing
|
||||
/// results — view_image can legitimately return multi-MiB base64 payloads.
|
||||
/// Text is governed by the much smaller `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
|
||||
pub const MAX_TOOL_RESULT_BYTES: usize = 8 * 1024 * 1024;
|
||||
/// Default cap on the *text* portion of a single tool result. Oversized text
|
||||
/// is middle-elided before it enters history; without this, one fat `cat`
|
||||
/// burns the context window and forces a lossy handoff. 50 KiB matches the
|
||||
/// shell-output caps in sprout-dev-mcp, goose, and pi; codex defaults to
|
||||
/// 10 KB. Tunable via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
|
||||
pub const DEFAULT_TOOL_RESULT_TEXT_BYTES: usize = 50 * 1024;
|
||||
pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
|
||||
|
||||
pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
|
||||
@@ -56,6 +65,11 @@ pub struct Config {
|
||||
pub max_sessions: usize,
|
||||
pub max_line_bytes: usize,
|
||||
pub max_history_bytes: usize,
|
||||
/// Per-tool-result cap on text content. Oversized text is middle-elided
|
||||
/// (head + tail kept) before entering history. Images are exempt — they
|
||||
/// are bounded by [`MAX_TOOL_RESULT_BYTES`] and accounted separately.
|
||||
/// Set via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
|
||||
pub max_tool_result_text_bytes: usize,
|
||||
/// Provider context window in tokens used to gate handoff. The handoff
|
||||
/// fires when the previous request's (cache-summed) input tokens cross the
|
||||
/// handoff threshold for this budget, before the next request can exceed
|
||||
@@ -163,6 +177,10 @@ impl Config {
|
||||
max_sessions: parse_env("BUZZ_AGENT_MAX_SESSIONS", usize::MAX)?,
|
||||
max_line_bytes: parse_env("BUZZ_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?,
|
||||
max_history_bytes: parse_env("BUZZ_AGENT_MAX_HISTORY_BYTES", 16 * 1024 * 1024)?,
|
||||
max_tool_result_text_bytes: parse_env(
|
||||
"BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES",
|
||||
DEFAULT_TOOL_RESULT_TEXT_BYTES,
|
||||
)?,
|
||||
max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?,
|
||||
max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?,
|
||||
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
|
||||
@@ -178,6 +196,7 @@ impl Config {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
const MIN_HISTORY_BYTES: usize = 4096;
|
||||
const MIN_LINE_BYTES: usize = 1024;
|
||||
const MIN_TOOL_RESULT_TEXT_BYTES: usize = 1024;
|
||||
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
if self.max_output_tokens < 1 {
|
||||
@@ -205,6 +224,13 @@ impl Config {
|
||||
"config: BUZZ_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}"
|
||||
));
|
||||
}
|
||||
if self.max_tool_result_text_bytes < MIN_TOOL_RESULT_TEXT_BYTES
|
||||
|| self.max_tool_result_text_bytes > MAX_TOOL_RESULT_BYTES
|
||||
{
|
||||
return Err(format!(
|
||||
"config: BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES must be in {MIN_TOOL_RESULT_TEXT_BYTES}..={MAX_TOOL_RESULT_BYTES}"
|
||||
));
|
||||
}
|
||||
if self.llm_timeout < MIN_TIMEOUT {
|
||||
return Err("config: BUZZ_AGENT_LLM_TIMEOUT_SECS must be >= 1".into());
|
||||
}
|
||||
|
||||
@@ -924,6 +924,7 @@ mod tests {
|
||||
max_sessions: 1,
|
||||
max_line_bytes: 1024 * 1024,
|
||||
max_history_bytes: 16 * 1024 * 1024,
|
||||
max_tool_result_text_bytes: 50 * 1024,
|
||||
max_context_tokens: 200_000,
|
||||
max_handoffs: 1,
|
||||
max_parallel_tools: 1,
|
||||
|
||||
+154
-75
@@ -26,6 +26,16 @@ 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",
|
||||
@@ -322,7 +332,10 @@ impl McpRegistry {
|
||||
&qname,
|
||||
"hook",
|
||||
&args,
|
||||
MAX_HOOK_RESULT_BYTES,
|
||||
ResultBudget {
|
||||
total: MAX_HOOK_RESULT_BYTES,
|
||||
text: MAX_HOOK_RESULT_BYTES,
|
||||
},
|
||||
&mut dummy_cancel,
|
||||
),
|
||||
)
|
||||
@@ -454,7 +467,7 @@ impl McpRegistry {
|
||||
qname: &str,
|
||||
provider_id: &str,
|
||||
arguments: &Value,
|
||||
max_bytes: usize,
|
||||
budget: ResultBudget,
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
) -> Result<ToolResult, AgentError> {
|
||||
let entry = self
|
||||
@@ -480,7 +493,7 @@ impl McpRegistry {
|
||||
qname,
|
||||
provider_id,
|
||||
arguments,
|
||||
max_bytes,
|
||||
budget,
|
||||
cancel,
|
||||
)
|
||||
.await;
|
||||
@@ -513,7 +526,7 @@ impl McpRegistry {
|
||||
qname,
|
||||
provider_id,
|
||||
arguments,
|
||||
max_bytes,
|
||||
budget,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
@@ -528,7 +541,7 @@ impl McpRegistry {
|
||||
qname: &str,
|
||||
provider_id: &str,
|
||||
arguments: &Value,
|
||||
max_bytes: usize,
|
||||
budget: ResultBudget,
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
) -> Result<ToolResult, AgentError> {
|
||||
let arg_obj = match arguments {
|
||||
@@ -596,13 +609,13 @@ impl McpRegistry {
|
||||
provider_id: provider_id.to_owned(),
|
||||
content: vec![ToolResultContent::Text(clamp(
|
||||
format!("Tool call rejected: {e}"),
|
||||
max_bytes,
|
||||
budget.text,
|
||||
))],
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
let content = tool_result_content(&res.content, max_bytes);
|
||||
let content = tool_result_content(&res.content, budget.total, budget.text);
|
||||
Ok(ToolResult {
|
||||
provider_id: provider_id.to_owned(),
|
||||
content,
|
||||
@@ -833,54 +846,85 @@ pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str {
|
||||
&s[..cut]
|
||||
}
|
||||
|
||||
fn push_bounded(out: &mut String, s: &str, max: usize) {
|
||||
let remaining = max.saturating_sub(out.len());
|
||||
if remaining > 0 {
|
||||
out.push_str(truncate_at_boundary(s, remaining));
|
||||
/// 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;
|
||||
let mut truncated = false;
|
||||
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();
|
||||
|
||||
let flush_text = |out: &mut Vec<ToolResultContent>, text: &mut String, used: &mut usize| {
|
||||
if !text.is_empty() {
|
||||
*used = used.saturating_add(text.len());
|
||||
out.push(ToolResultContent::Text(std::mem::take(text)));
|
||||
// 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 text_budget =
|
||||
|used: usize, text: &str| max_bytes.saturating_sub(used).saturating_sub(text.len());
|
||||
let append = |text: &mut String, s: &str| {
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(s);
|
||||
};
|
||||
|
||||
for c in blocks {
|
||||
if used + text.len() >= max_bytes {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
match &c.raw {
|
||||
RawContent::Text(t) => {
|
||||
if !text.is_empty() {
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "\n", max);
|
||||
}
|
||||
let before = text.len();
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, &t.text, max);
|
||||
if text.len() - before < t.text.len() {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
RawContent::Text(t) => append(&mut text, &t.text),
|
||||
RawContent::Image(i) => {
|
||||
flush_text(&mut out, &mut text, &mut used);
|
||||
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);
|
||||
@@ -889,53 +933,31 @@ fn tool_result_content(
|
||||
mime_type: i.mime_type.clone(),
|
||||
});
|
||||
} else {
|
||||
truncated = true;
|
||||
let marker = format!(
|
||||
"[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]",
|
||||
short(&i.mime_type),
|
||||
i.data.len()
|
||||
append(
|
||||
&mut text,
|
||||
&format!(
|
||||
"[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]",
|
||||
short(&i.mime_type),
|
||||
i.data.len()
|
||||
),
|
||||
);
|
||||
let max = max_bytes.saturating_sub(used);
|
||||
push_bounded(&mut text, &marker, max);
|
||||
}
|
||||
}
|
||||
RawContent::Audio(a) => {
|
||||
if !text.is_empty() {
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "\n", max);
|
||||
}
|
||||
let chunk = format!(
|
||||
RawContent::Audio(a) => append(
|
||||
&mut text,
|
||||
&format!(
|
||||
"[audio elided: {}, {} bytes]",
|
||||
short(&a.mime_type),
|
||||
a.data.len()
|
||||
);
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, &chunk, max);
|
||||
}
|
||||
),
|
||||
),
|
||||
RawContent::ResourceLink(r) => {
|
||||
if !text.is_empty() {
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "\n", max);
|
||||
}
|
||||
let chunk = format!("[resource: {}]", short(&r.uri));
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, &chunk, max);
|
||||
}
|
||||
RawContent::Resource(_) => {
|
||||
if !text.is_empty() {
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "\n", max);
|
||||
}
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "[resource elided]", max);
|
||||
append(&mut text, &format!("[resource: {}]", short(&r.uri)));
|
||||
}
|
||||
RawContent::Resource(_) => append(&mut text, "[resource elided]"),
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
let max = text_budget(used, &text);
|
||||
push_bounded(&mut text, "\n[content truncated]", max);
|
||||
}
|
||||
flush_text(&mut out, &mut text, &mut used);
|
||||
flush_text(&mut out, &mut text, &mut used, &mut text_used);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -951,7 +973,7 @@ mod content_tests {
|
||||
Content::image("aW1n", "image/png"),
|
||||
Content::text("tail"),
|
||||
];
|
||||
let out = tool_result_content(&blocks, 1024);
|
||||
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!(
|
||||
@@ -965,8 +987,65 @@ mod content_tests {
|
||||
#[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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user