fix(flows): exempt workflow proposal tools from tokenjuice compaction (canvas renders ≥4-node graphs) (#4888)

This commit is contained in:
Cyrus Gray
2026-07-15 17:03:29 +05:30
committed by GitHub
parent fe2a30445a
commit 390c1ca6c1
2 changed files with 563 additions and 53 deletions
+95
View File
@@ -4472,6 +4472,101 @@ async fn compute_required_connections_skips_native_and_http_nodes() {
);
}
// ── extract_workflow_proposal: survives large, tabulation-eligible graphs ─────
//
// Regression coverage for the "blank canvas on ≥4-node graphs" bug: tinyjuice's
// JSON compressor tabulates any uniform object-array of >= 3 rows over ~512
// bytes, which strips the `"type": "workflow_proposal"` marker this extractor
// keys on. The fix lives in `tinyagents::middleware::ToolOutputMiddleware`
// (COMPACTION_EXEMPT_TOOLS), which keeps proposal-tool results out of
// tokenjuice entirely — so by the time a payload reaches `agent.history()`
// here, it must still be the untabulated, structurally-intact JSON.
#[test]
fn extract_workflow_proposal_survives_large_graph() {
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
// 6 nodes, several columns each — comfortably over tinyjuice's MIN_ROWS (3)
// and ~512-byte tabulation thresholds, so an unprotected payload would get
// compacted into a `[json table: …]` marker and lose the `"type"` field.
let nodes: Vec<serde_json::Value> = (0..6)
.map(|i| {
json!({
"id": format!("node-{i}"),
"kind": if i == 0 { "trigger" } else { "tool_call" },
"name": format!("Step {i}"),
"config": {
"slug": format!("oh:placeholder_action_{i}"),
"args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" }
}
})
})
.collect();
let edges: Vec<serde_json::Value> = (0..5)
.map(|i| json!({ "from_node": format!("node-{i}"), "to_node": format!("node-{}", i + 1) }))
.collect();
let proposal_payload = json!({
"type": "workflow_proposal",
"flow_id": "flow-large-graph",
"graph": { "nodes": nodes, "edges": edges },
});
let payload_str = serde_json::to_string(&proposal_payload).unwrap();
assert!(
payload_str.len() > 512,
"test payload must exceed tinyjuice's tabulation byte threshold: {} bytes",
payload_str.len()
);
let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage {
tool_call_id: "call-1".to_string(),
content: payload_str,
}])];
let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable");
assert_eq!(
proposal.get("type").and_then(serde_json::Value::as_str),
Some("workflow_proposal")
);
assert_eq!(
proposal["graph"]["nodes"].as_array().unwrap().len(),
6,
"all 6 nodes must survive intact: {proposal}"
);
}
#[test]
fn extract_workflow_proposal_returns_the_latest_of_multiple_results() {
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
let first = json!({ "type": "workflow_proposal", "flow_id": "first" });
let second = json!({ "type": "workflow_proposal", "flow_id": "second" });
let history = vec![
ConversationMessage::ToolResults(vec![ToolResultMessage {
tool_call_id: "call-1".to_string(),
content: first.to_string(),
}]),
ConversationMessage::ToolResults(vec![ToolResultMessage {
tool_call_id: "call-2".to_string(),
content: second.to_string(),
}]),
];
let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable");
assert_eq!(proposal["flow_id"], "second");
}
#[test]
fn extract_workflow_proposal_ignores_non_proposal_tool_results() {
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage {
tool_call_id: "call-1".to_string(),
content: json!({ "type": "search_results", "items": [] }).to_string(),
}])];
assert!(extract_workflow_proposal(&history).is_none());
}
// ─────────────────────────────────────────────────────────────────────────────
// Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state
// guarantee: every turn ends in a proposal or a real question, never silence).
+468 -53
View File
@@ -718,6 +718,65 @@ impl Middleware<()> for PromptCacheSegmentMiddleware {
}
}
/// Tools whose results are self-describing JSON payloads that downstream
/// extractors and the frontend canvas parse structurally (the `type` marker
/// must survive). Compacting/summarizing them destroys the contract and
/// serves no purpose — the model doesn't benefit from a tabulated graph and
/// the payload is the turn's final output, not intermediate context.
///
/// These tools are exempt from *every* content-rewriting stage below —
/// tokenjuice compaction (steps 1+2) **and** the per-tool char cap / shared
/// byte-budget backstop (steps 3+4, see [`is_truncation_exempt`]). Both
/// `flows::ops::extract_workflow_proposal` and the frontend's
/// `parseWorkflowProposal` parse this content as a single whole-string JSON
/// document; a byte-cap truncation at a UTF-8 boundary produces invalid JSON
/// just as surely as tokenjuice tabulation strips the `"type"` marker — both
/// end in a silent `proposal: None` and a blank canvas. A ≥10-node graph
/// routinely clears the ~16 KiB shared budget, so the truncation exemption
/// matters just as much as the compaction one.
const COMPACTION_EXEMPT_TOOLS: &[&str] = &[
"propose_workflow",
"revise_workflow",
"edit_workflow",
"save_workflow",
"create_workflow",
];
/// Tools whose results the model reads to derive an exact schema (e.g.
/// `primary_array_path` / `output_fields`) from a *real* sampled tool
/// response, per the B12 output-probe contract (`flows::builder_tools`).
/// TokenJuice's array-elision tabulation defeats their purpose outright — a
/// tabulated sample hides the very array shape the model is calling the tool
/// to observe, so it derives a wrong or nonexistent `split_out.path` from the
/// summary instead of the real response. They're compaction-exempt
/// ([`is_compaction_exempt`]) for that reason.
///
/// Unlike [`COMPACTION_EXEMPT_TOOLS`], their payload is intermediate context
/// the model reasons over — not the turn's final machine-parsed output — and
/// samples can be genuinely large (a full API response body). So they stay
/// subject to the per-tool char cap / shared byte-budget backstop
/// ([`is_truncation_exempt`] returns `false` for them): a truncated-but-not-
/// tabulated sample is still a usable (if partial) real response, and the
/// backstop keeps these calls from blowing the context budget.
const SAMPLING_TOOLS: &[&str] = &["get_tool_output_sample", "get_tool_contract"];
/// Steps 1 (payload summarizer) + 2 (tokenjuice compaction) exemption:
/// proposal tools (final-output contract, see [`COMPACTION_EXEMPT_TOOLS`])
/// plus sampling tools (tabulation would corrupt the schema they exist to
/// reveal, see [`SAMPLING_TOOLS`]).
fn is_compaction_exempt(name: &str) -> bool {
COMPACTION_EXEMPT_TOOLS.contains(&name) || SAMPLING_TOOLS.contains(&name)
}
/// Steps 3 (per-tool char cap) + 4 (shared byte-budget backstop) exemption:
/// proposal tools only. Their JSON is parsed as a single whole-string
/// document downstream, so any truncation — not just tokenjuice tabulation —
/// breaks the parse. Sampling tools are deliberately *not* in this set: see
/// [`SAMPLING_TOOLS`] for why the byte cap stays in force for them.
fn is_truncation_exempt(name: &str) -> bool {
COMPACTION_EXEMPT_TOOLS.contains(&name)
}
/// `after_tool`: apply the semantic payload summarizer (when configured) and
/// then the hard per-tool-result byte cap to each tool result's model-facing
/// content, before it enters the transcript. The graph analogue of the byte cap
@@ -758,75 +817,120 @@ impl Middleware<()> for ToolOutputMiddleware {
_state: &(),
result: &mut TaToolResult,
) -> TaResult<()> {
// Proposal-/persistence-emitting workflow tools return a self-describing
// `{ "type": "workflow_proposal", … }` JSON payload that `flows::ops`'
// `extract_workflow_proposal` (and the frontend's content-based
// recognition) parse structurally. Sampling tools (`get_tool_contract` /
// `get_tool_output_sample`) return a real API response the model reads
// to derive an exact array path/schema. All four stages below are
// content-*rewriting*: tokenjuice (steps 1+2) tabulates any uniform
// object-array of ≥3 rows over ~512 bytes into a `[json table: …]`
// marker (stripping the `"type"` field on graphs with enough nodes, or
// eliding the array a sample exists to reveal); the char cap and shared
// byte-budget backstop (steps 3+4) truncate at a UTF-8 boundary, which
// breaks the whole-string JSON parse both proposal consumers do. See
// [`is_compaction_exempt`]/[`is_truncation_exempt`] for which stages
// each tool family skips and why.
let compaction_exempt = is_compaction_exempt(&result.name);
let truncation_exempt = is_truncation_exempt(&result.name);
if compaction_exempt {
tracing::debug!(
tool = %result.name,
bytes = result.content.len(),
"[tinyagents::mw] compaction-exempt: skipping payload summarizer + tokenjuice"
);
}
if truncation_exempt {
tracing::debug!(
tool = %result.name,
bytes = result.content.len(),
"[tinyagents::mw] truncation-exempt: skipping per-tool char cap + shared byte-budget backstop"
);
}
// 1. Semantic summarization (progressive disclosure) — swap the raw
// payload for a compressed summary when the summarizer opts in.
// Failures never break the tool call (the trait swallows them).
if let Some(ps) = &self.payload_summarizer {
if let Ok(Some(payload)) = ps
.maybe_summarize_in_parent(ctx, &result.name, None, &result.content)
.await
{
tracing::info!(
tool = %result.name,
from_bytes = payload.original_bytes,
to_bytes = payload.summary_bytes,
"[tinyagents::mw] payload_summarizer compressed tool output"
);
ctx.emit(AgentEvent::Compressed {
from_tokens: estimate_output_tokens(payload.original_bytes),
to_tokens: estimate_output_tokens(payload.summary_bytes),
});
result.content = payload.summary;
if !compaction_exempt {
if let Some(ps) = &self.payload_summarizer {
if let Ok(Some(payload)) = ps
.maybe_summarize_in_parent(ctx, &result.name, None, &result.content)
.await
{
tracing::info!(
tool = %result.name,
from_bytes = payload.original_bytes,
to_bytes = payload.summary_bytes,
"[tinyagents::mw] payload_summarizer compressed tool output"
);
ctx.emit(AgentEvent::Compressed {
from_tokens: estimate_output_tokens(payload.original_bytes),
to_tokens: estimate_output_tokens(payload.summary_bytes),
});
result.content = payload.summary;
}
}
}
// 2. TokenJuice content-aware compaction. This mirrors the legacy
// `agent_tool_exec` stage that ran after semantic summarization and
// before the hard output caps.
let before_tokenjuice_bytes = result.content.len();
let compacted = crate::openhuman::tokenjuice::compact_output_with_policy(
std::mem::take(&mut result.content),
&result.name,
self.tokenjuice_compaction_enabled,
self.tokenjuice_compression,
)
.await;
result.content = compacted;
let after_tokenjuice_bytes = result.content.len();
if after_tokenjuice_bytes < before_tokenjuice_bytes {
ctx.emit(AgentEvent::Compressed {
from_tokens: estimate_output_tokens(before_tokenjuice_bytes),
to_tokens: estimate_output_tokens(after_tokenjuice_bytes),
});
// 2. TokenJuice content-aware compaction. This mirrors the legacy
// `agent_tool_exec` stage that ran after semantic summarization and
// before the hard output caps.
let before_tokenjuice_bytes = result.content.len();
let compacted = crate::openhuman::tokenjuice::compact_output_with_policy(
std::mem::take(&mut result.content),
&result.name,
self.tokenjuice_compaction_enabled,
self.tokenjuice_compression,
)
.await;
result.content = compacted;
let after_tokenjuice_bytes = result.content.len();
if after_tokenjuice_bytes < before_tokenjuice_bytes {
ctx.emit(AgentEvent::Compressed {
from_tokens: estimate_output_tokens(before_tokenjuice_bytes),
to_tokens: estimate_output_tokens(after_tokenjuice_bytes),
});
}
}
// 3. Per-tool **char** cap — a tool that declares `max_result_size_chars`
// caps its own output in characters, with the tool-cap marker the model
// was taught to read (legacy engine parity). Distinct from the generic
// byte budget below: the tool cap is the tool's own contract.
// byte budget below: the tool cap is the tool's own contract. Skipped
// for truncation-exempt tools (see [`is_truncation_exempt`]) — the tool
// cap is still *computed* below (step 4's "no cap of its own" check
// reads it), just not applied to `result.content`.
let tool_cap = self.tool_char_cap(&result.name);
if let Some(cap) = tool_cap {
let char_count = result.content.chars().count();
if char_count > cap {
let truncated: String = result.content.chars().take(cap).collect();
let dropped = char_count - cap;
tracing::debug!(
tool = %result.name,
cap,
char_count,
dropped,
"[tinyagents::mw] per-tool char cap applied"
);
result.content = format!(
"{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]"
);
if !truncation_exempt {
if let Some(cap) = tool_cap {
let char_count = result.content.chars().count();
if char_count > cap {
let truncated: String = result.content.chars().take(cap).collect();
let dropped = char_count - cap;
tracing::debug!(
tool = %result.name,
cap,
char_count,
dropped,
"[tinyagents::mw] per-tool char cap applied"
);
result.content = format!(
"{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]"
);
}
}
}
// 4. Shared byte-cap backstop — truncate at a UTF-8 boundary with a marker.
// Only for tools with no cap of their own (a capped tool already bounded
// itself above; stacking the two markers would double-truncate).
if tool_cap.is_none() && self.budget_bytes > 0 {
// itself above; stacking the two markers would double-truncate), and
// never for truncation-exempt tools. This is a per-result cap only —
// `apply_per_result_persistence` takes a single `content: String` and a
// fixed `self.budget_bytes`, with no shared/global accumulator across
// tool calls (the aggregate-spill variant, `spill_aggregate_tool_results`,
// is a separate legacy code path not wired into this middleware) — so
// exempting these tools' own contribution here cannot perturb any other
// tool's budget accounting.
if !truncation_exempt && tool_cap.is_none() && self.budget_bytes > 0 {
let (capped, outcome) = apply_per_result_persistence(
std::mem::take(&mut result.content),
self.artifact_store.as_ref(),
@@ -3599,6 +3703,317 @@ mod tests {
);
}
// ── ToolOutputMiddleware: COMPACTION_EXEMPT_TOOLS (workflow proposals) ───
/// A `workflow_proposal` payload with enough uniform-object rows to clear
/// tinyjuice's `MIN_ROWS` (3) and default ~512-byte tabulation floor —
/// i.e. exactly the shape that used to get its `"type"` marker stripped by
/// the `[json table: …]` rewrite before the middleware exemption existed.
fn large_workflow_proposal_json() -> String {
let nodes: Vec<serde_json::Value> = (0..6)
.map(|i| {
json!({
"id": format!("node-{i}"),
"kind": if i == 0 { "trigger" } else { "tool_call" },
"name": format!("Step {i}"),
"config": {
"slug": format!("oh:placeholder_action_{i}"),
"args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" }
}
})
})
.collect();
serde_json::to_string(&json!({
"type": "workflow_proposal",
"flow_id": "flow-large-graph",
"graph": { "nodes": nodes, "edges": [] },
}))
.unwrap()
}
fn compaction_enabled_mw() -> ToolOutputMiddleware {
ToolOutputMiddleware {
budget_bytes: 1_000_000,
payload_summarizer: None,
artifact_store: None,
tokenjuice_compaction_enabled: true,
tokenjuice_compression: AgentTokenjuiceCompression::Full,
tool_policies: HashMap::new(),
}
}
#[test]
fn compaction_exempt_tools_contains_every_proposal_tool() {
for tool in [
"propose_workflow",
"revise_workflow",
"edit_workflow",
"save_workflow",
"create_workflow",
] {
assert!(
COMPACTION_EXEMPT_TOOLS.contains(&tool),
"{tool} must be exempt from tokenjuice/summarizer compaction"
);
}
}
#[tokio::test]
async fn tool_output_tabulates_a_large_graph_for_a_non_exempt_tool() {
// Sanity baseline proving this test's payload actually exercises real
// tinyjuice tabulation (and isn't just below-threshold): a tool name
// NOT in COMPACTION_EXEMPT_TOOLS loses the `"type"` marker.
let mw = compaction_enabled_mw();
let payload = large_workflow_proposal_json();
let mut result = tool_result("some_other_tool", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_ne!(
result.content, payload,
"a non-exempt tool's large uniform-array payload should be rewritten by tokenjuice"
);
let reparsed: Result<serde_json::Value, _> = serde_json::from_str(&result.content);
let marker_survived = reparsed
.ok()
.and_then(|v| v.get("type").and_then(|t| t.as_str().map(str::to_string)))
== Some("workflow_proposal".to_string());
assert!(
!marker_survived,
"baseline expectation: tabulation strips the type marker for non-exempt tools"
);
}
#[tokio::test]
async fn tool_output_leaves_propose_workflow_byte_for_byte_intact() {
let mw = compaction_enabled_mw();
let payload = large_workflow_proposal_json();
let mut result = tool_result("propose_workflow", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_eq!(
result.content, payload,
"propose_workflow results must pass through compaction untouched"
);
let reparsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(reparsed["type"], "workflow_proposal");
assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 6);
}
#[tokio::test]
async fn tool_output_leaves_every_exempt_tool_name_intact() {
let mw = compaction_enabled_mw();
let payload = large_workflow_proposal_json();
for tool in COMPACTION_EXEMPT_TOOLS {
let mut result = tool_result(tool, &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_eq!(
result.content, payload,
"{tool}'s result must pass through compaction untouched"
);
}
}
// ── ToolOutputMiddleware: truncation exemption (#4888 follow-up, gap 1) ──
/// A `workflow_proposal` payload with `node_count` nodes, each padded with
/// a 500-byte `note`, so the caller can force the serialized size past the
/// ~16 KiB shared byte-budget backstop (`DEFAULT_TOOL_RESULT_BUDGET_BYTES`)
/// — the size class a real ≥10-node graph proposal routinely reaches, and
/// exactly what used to get UTF-8-boundary-truncated into unparseable JSON
/// before the truncation exemption existed.
fn oversized_workflow_proposal_json(node_count: usize) -> String {
let nodes: Vec<serde_json::Value> = (0..node_count)
.map(|i| {
json!({
"id": format!("node-{i}"),
"kind": if i == 0 { "trigger" } else { "tool_call" },
"name": format!("Step {i}"),
"config": {
"slug": format!("oh:placeholder_action_{i}"),
"args": { "input": format!("value-{i}"), "note": "a".repeat(500) }
}
})
})
.collect();
serde_json::to_string(&json!({
"type": "workflow_proposal",
"flow_id": "flow-oversized-graph",
"graph": { "nodes": nodes, "edges": [] },
}))
.unwrap()
}
/// Middleware config isolating the byte-cap stages (3+4): tokenjuice off,
/// no tool-declared char cap, the real `DEFAULT_TOOL_RESULT_BUDGET_BYTES`
/// (~16 KiB) as the shared backstop, and no artifact store (so an
/// over-budget non-exempt tool falls straight to inline truncation instead
/// of being persisted — deterministic to assert on).
fn truncation_probe_mw() -> ToolOutputMiddleware {
ToolOutputMiddleware {
budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES,
payload_summarizer: None,
artifact_store: None,
tokenjuice_compaction_enabled: false,
tokenjuice_compression: AgentTokenjuiceCompression::Off,
tool_policies: HashMap::new(),
}
}
#[tokio::test]
async fn tool_output_leaves_an_oversized_propose_workflow_byte_for_byte_intact() {
// Gap 1: a ≥10-node proposal routinely exceeds the ~16 KiB shared
// byte-budget backstop. Before the truncation exemption, step 4
// truncated it at a UTF-8 boundary — invalid JSON, so both
// `flows::ops::extract_workflow_proposal` and the frontend's
// `parseWorkflowProposal` silently fell back to `proposal: None` and a
// blank canvas. This must survive byte-for-byte regardless of size.
let mw = truncation_probe_mw();
let payload = oversized_workflow_proposal_json(30);
assert!(
payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES,
"test payload must exceed the shared byte budget to exercise step 4: {} bytes",
payload.len()
);
let mut result = tool_result("propose_workflow", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_eq!(
result.content, payload,
"an oversized propose_workflow result must not be truncated by the shared byte-budget backstop"
);
let reparsed: serde_json::Value = serde_json::from_str(&result.content)
.expect("must still be valid JSON after passing through after_tool");
assert_eq!(reparsed["type"], "workflow_proposal");
assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 30);
}
#[tokio::test]
async fn tool_output_truncates_the_same_oversized_payload_for_a_non_exempt_tool() {
// Baseline pairing with the test above: proves the identical oversized
// payload IS truncated (and consequently unparseable) for a tool that
// is NOT truncation-exempt, so the exemption test isn't vacuously true
// because the payload never actually crossed the budget.
let mw = truncation_probe_mw();
let payload = oversized_workflow_proposal_json(30);
let mut result = tool_result("some_other_tool", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_ne!(
result.content, payload,
"a non-exempt tool's oversized payload should be truncated by the shared byte-budget backstop"
);
assert!(
result.content.contains("truncated by tool_result_budget"),
"expected the byte-budget truncation marker: {}",
result.content
);
assert!(
serde_json::from_str::<serde_json::Value>(&result.content).is_err(),
"truncated JSON should no longer parse as a whole document"
);
}
// ── ToolOutputMiddleware: sampling tools (#4888 follow-up, gap 2) ────────
/// A large uniform-array JSON payload shaped like a real sampled tool
/// response (no `workflow_proposal` envelope) — what `get_tool_output_sample`
/// / `get_tool_contract` actually return so the model can derive an exact
/// `primary_array_path`/`output_fields` from the real shape. `row_count`
/// rows of ≥3 clear tinyjuice's tabulation threshold.
fn large_sample_response_json(row_count: usize) -> String {
let rows: Vec<serde_json::Value> = (0..row_count)
.map(|i| {
json!({
"id": i,
"title": format!("Issue {i}"),
"state": "open",
"body": "padding padding padding padding padding padding",
})
})
.collect();
serde_json::to_string(&json!({ "items": rows })).unwrap()
}
#[tokio::test]
async fn get_tool_output_sample_is_compaction_exempt() {
// Gap 2: tokenjuice tabulation elides the very array the model calls
// this tool to observe, so it would derive a wrong or nonexistent
// `split_out.path` from the tabulated summary instead of the real
// response shape. The sample must reach the model untabulated.
let mw = compaction_enabled_mw();
let payload = large_sample_response_json(10);
let mut result = tool_result("get_tool_output_sample", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_eq!(
result.content, payload,
"get_tool_output_sample's response must not be tokenjuice-tabulated"
);
}
#[tokio::test]
async fn get_tool_contract_is_compaction_exempt() {
let mw = compaction_enabled_mw();
let payload = large_sample_response_json(10);
let mut result = tool_result("get_tool_contract", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_eq!(
result.content, payload,
"get_tool_contract's response must not be tokenjuice-tabulated"
);
}
#[tokio::test]
async fn sampling_tool_output_still_hits_the_byte_budget_backstop() {
// Unlike the proposal tools, sampling tools are deliberately NOT
// truncation-exempt: a truncated-but-untabulated sample is still a
// usable (if partial) real response, and these calls can be genuinely
// large, so the shared byte-budget backstop keeps protecting the
// context budget for them.
let mw = truncation_probe_mw();
let payload = large_sample_response_json(400);
assert!(
payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES,
"test payload must exceed the shared byte budget: {} bytes",
payload.len()
);
let mut result = tool_result("get_tool_output_sample", &payload);
mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();
assert_ne!(
result.content, payload,
"get_tool_output_sample must still be subject to the shared byte-budget backstop"
);
assert!(
result.content.contains("truncated by tool_result_budget"),
"expected the byte-budget truncation marker: {}",
result.content
);
}
#[test]
fn compaction_and_truncation_exempt_sets_are_distinct() {
// Proposal tools: exempt from both compaction and truncation.
for tool in COMPACTION_EXEMPT_TOOLS {
assert!(
is_compaction_exempt(tool),
"{tool} must be compaction-exempt"
);
assert!(
is_truncation_exempt(tool),
"{tool} must be truncation-exempt"
);
}
// Sampling tools: exempt from compaction only.
for tool in SAMPLING_TOOLS {
assert!(
is_compaction_exempt(tool),
"{tool} must be compaction-exempt"
);
assert!(
!is_truncation_exempt(tool),
"{tool} must remain subject to the char cap / shared byte-budget backstop"
);
}
// An arbitrary non-listed tool: exempt from neither.
assert!(!is_compaction_exempt("some_other_tool"));
assert!(!is_truncation_exempt("some_other_tool"));
}
// ── CostBudgetMiddleware ────────────────────────────────────────────────
#[tokio::test]