mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
fix(flows): exempt workflow proposal tools from tokenjuice compaction (canvas renders ≥4-node graphs) (#4888)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user