diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index a8a789d02..b4c1663c1 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -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 = (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 = (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). diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 559c02973..dee3415cc 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -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 = (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::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 = (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::(&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 = (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]