fix(tui): honest large-output truncation + recovery path (#5212)
Dogfood finding (FINISH-0.9.4 appendix #35): large tool results were
elided dishonestly — the model could not tell content was omitted, the
preview could be empty or misleading, thresholds were too aggressive,
and the footer withheld where the full output went.
- large_output_router: raise DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS from
4_096 to 32_768 so ordinary results (file reads, build logs) stay
inline. Band logic unchanged (Inline <= t/4, Hybrid <= t, HandleOnly).
- truncate: adaptive evidence windows are now Hybrid 32 KiB head + 8 KiB
tail and HandleOnly 16 KiB head + 4 KiB tail; head/tail never overlap
(shared head_tail_windows helper). truncated_preview returns content
unchanged when omitted == 0 — it never claims a truncation that did
not happen, and the adaptive path declines to publish an artifact in
that case. The model-facing footer now names the omitted bytes/lines,
the on-disk artifact path, and a one-line recovery instruction.
SPILLOVER_PREVIEW_HINT is kept for TUI rendering only.
- file: read_file results stamp metadata evidence_routing=inline; the
tool self-bounds at 16 KiB behind its own next_start_line contract,
so the envelope must not double-wrap it. registry respects a
tool-declared routing instead of overwriting it with the estimate.
- engine context compactor + wire compactor (chat.rs): pass
evidence-bounded previews through untouched. Re-compacting them
destroyed the footer and falsely reported "no session-owned artifact
was recorded".
- history: is_truncated_output_preview also recognises the new footer.
Tests: updated existing assertions to the new intended behavior
(threshold sizes, footer shape, preview budgets; acceptance/PTY probes
now fill both streams to exceed the Hybrid budget with the sentinel in
the envelope-omitted middle). New regressions: omitted==0 passthrough,
head/tail non-overlap incl. UTF-8 edges, footer names artifact path +
recovery line, default threshold is 32_768, evidence previews are not
re-compacted.
cargo fmt --all clean. cargo test -p codewhale-tui: 9624 passed; the 19
bin failures (provider alias/catalog tests) and 2 qa_pty failures
(interactive_init, v091 visual matrix) reproduce identically on base
d53f4f998 and are unrelated.
This commit is contained in:
@@ -1746,6 +1746,21 @@ fn compact_tool_result_for_wire(
|
||||
};
|
||||
}
|
||||
|
||||
// Content already bounded by the adaptive evidence envelope carries its
|
||||
// own honest footer: the omitted count, the on-disk artifact path, and a
|
||||
// recovery instruction. Truncating it again here would destroy that
|
||||
// recovery contract and falsely report that no session-owned artifact
|
||||
// was recorded, so pass it through untouched.
|
||||
if content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT) {
|
||||
return WireToolResult {
|
||||
content: content.to_string(),
|
||||
original_chars,
|
||||
sent_chars: original_chars,
|
||||
truncated: false,
|
||||
deduplicated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let head = first_chars(content, TOOL_RESULT_HEAD_CHARS);
|
||||
let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS);
|
||||
let kept = head.chars().count() + tail.chars().count();
|
||||
|
||||
@@ -432,6 +432,20 @@ pub(crate) fn compact_tool_result_for_route(
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// A result already bounded by the adaptive evidence envelope is an
|
||||
// honest, context-sized preview whose footer names the artifact path and
|
||||
// a recovery instruction. Re-compacting it would strip that recovery
|
||||
// contract and double-truncate the output, so pass it through unchanged.
|
||||
if output
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("evidence_available"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -11544,6 +11544,28 @@ fn v4_keeps_large_file_reads_but_compacts_noisy_shell_output() {
|
||||
assert!(legacy_context.len() < v4_context.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_bounded_preview_is_not_recompacted() {
|
||||
// The adaptive evidence envelope already produced an honest bounded
|
||||
// preview (head + footer with the recovery path + tail). The context
|
||||
// compactor must pass it through untouched, even beyond the 12K hard
|
||||
// limit — re-compacting would strip the recovery contract.
|
||||
let content = format!(
|
||||
"{}\n\n… 19.0 KiB of output omitted (123 lines) — full output at /tmp/art_call.txt; read it back with the read_file tool or with sed line ranges\n\n…\n{}",
|
||||
"h".repeat(32 * 1024),
|
||||
"t".repeat(8 * 1024)
|
||||
);
|
||||
let output = ToolResult::success(content.clone()).with_metadata(json!({
|
||||
"evidence_available": true,
|
||||
"truncated": true,
|
||||
"spillover_path": "/tmp/art_call.txt"
|
||||
}));
|
||||
|
||||
let context = compact_tool_result_for_context("deepseek-v3.2-128k", "Bash", &output);
|
||||
assert_eq!(context, content);
|
||||
assert!(context.contains("full output at /tmp/art_call.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_tool_retention_uses_oauth_route_window_not_api_model_window() {
|
||||
let content = "route-effective context\n".repeat(900);
|
||||
|
||||
@@ -185,7 +185,9 @@ impl ToolSpec for ReadFileTool {
|
||||
|
||||
let total_lines = contents.lines().count();
|
||||
if total_lines <= SMALL_FILE_LINES {
|
||||
return Ok(ToolResult::success(contents));
|
||||
return Ok(ToolResult::success(contents).with_metadata(json!({
|
||||
"evidence_routing": "inline"
|
||||
})));
|
||||
}
|
||||
|
||||
// Small in bytes but too many lines: render the default window
|
||||
@@ -259,7 +261,9 @@ impl ToolSpec for ReadFileTool {
|
||||
[NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n\
|
||||
</file>"
|
||||
);
|
||||
return Ok(ToolResult::success(output));
|
||||
return Ok(ToolResult::success(output).with_metadata(json!({
|
||||
"evidence_routing": "inline"
|
||||
})));
|
||||
}
|
||||
|
||||
Ok(render_line_window(
|
||||
@@ -392,7 +396,12 @@ fn render_line_window(
|
||||
}
|
||||
output.push_str("</file>");
|
||||
|
||||
ToolResult::success(output)
|
||||
// The file tool self-bounds at 16 KiB and carries its own continuation
|
||||
// contract (`next_start_line`), so the large-output spillover envelope
|
||||
// must never re-wrap a read result with a second, weaker truncation.
|
||||
ToolResult::success(output).with_metadata(json!({
|
||||
"evidence_routing": "inline"
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_image_via_ocr(path: &Path, requested_path: &str) -> Result<ToolResult, ToolError> {
|
||||
|
||||
@@ -17,7 +17,13 @@ use crate::tools::spec::ToolResult;
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Default token threshold separating hybrid from handle-only evidence.
|
||||
pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 4_096;
|
||||
///
|
||||
/// 32K tokens (≈96 KiB of text at the 3 chars/token estimate) keeps ordinary
|
||||
/// tool results — file reads, test runs, build logs up to a few thousand
|
||||
/// lines — fully inline. Only genuinely large outputs spill to evidence
|
||||
/// artifacts, where the model-facing preview names the artifact path and how
|
||||
/// to recover the omitted range.
|
||||
pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 32_768;
|
||||
|
||||
/// Approximate characters-per-token ratio used for the heuristic estimate.
|
||||
/// We intentionally choose a conservative value (3 chars/token) so we err
|
||||
@@ -345,11 +351,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_threshold_is_32k_tokens() {
|
||||
assert_eq!(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, 32_768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesise_above_threshold() {
|
||||
let router = LargeOutputRouter::default();
|
||||
// DEFAULT threshold = 4096 tokens; 3 chars/token → 4096*3 = 12288 chars
|
||||
let big = "a".repeat(13_000);
|
||||
// DEFAULT threshold = 32768 tokens; 3 chars/token → 32768*3 = 98304 chars
|
||||
let big = "a".repeat(100_000);
|
||||
let result = make_result(&big);
|
||||
assert!(matches!(
|
||||
router.route("read_file", &result, false),
|
||||
@@ -360,7 +371,7 @@ mod tests {
|
||||
#[test]
|
||||
fn raw_bypass_skips_routing() {
|
||||
let router = LargeOutputRouter::default();
|
||||
let big = "a".repeat(13_000);
|
||||
let big = "a".repeat(100_000);
|
||||
let result = make_result(&big);
|
||||
// raw=true → always pass through regardless of size
|
||||
assert_eq!(
|
||||
@@ -372,7 +383,7 @@ mod tests {
|
||||
#[test]
|
||||
fn adaptive_evidence_cannot_bypass_context_bound_with_raw_flag() {
|
||||
let router = LargeOutputRouter::default();
|
||||
let big = make_result(&"a".repeat(13_000));
|
||||
let big = make_result(&"a".repeat(100_000));
|
||||
let (routing, _, _) = router.evidence_routing("exec_shell", &big, true);
|
||||
assert_eq!(routing, EvidenceRouting::HandleOnly);
|
||||
}
|
||||
|
||||
@@ -124,13 +124,22 @@ impl ToolRegistry {
|
||||
|
||||
if let Some(router) = ctx.large_output_router.as_ref() {
|
||||
use crate::tools::large_output_router::{
|
||||
LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
|
||||
EvidenceRouting, LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
|
||||
};
|
||||
if !classic_output_routing_enabled() {
|
||||
let (routing, estimated_tokens, threshold) =
|
||||
let (estimated_routing, estimated_tokens, threshold) =
|
||||
router.evidence_routing(name, &result, raw_bypass);
|
||||
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
// A tool that self-bounds its output behind its own
|
||||
// recovery contract (e.g. read_file's `next_start_line`
|
||||
// paging) declares its routing itself; the size estimate
|
||||
// must not override that and double-wrap the result.
|
||||
let routing = object
|
||||
.get("evidence_routing")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
|
||||
.unwrap_or(estimated_routing);
|
||||
object.insert(
|
||||
"evidence_routing".to_string(),
|
||||
serde_json::to_value(routing)
|
||||
|
||||
@@ -13035,12 +13035,16 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
|
||||
);
|
||||
|
||||
let path = spilled.expect("multi-MB output must spill");
|
||||
// Model-visible content is a bounded, ordinary preview. Internal
|
||||
// storage paths and retrieval machinery stay out of the transcript.
|
||||
assert!(inline.len() <= 4 * 1024);
|
||||
assert!(inline.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
|
||||
// Model-visible content is a bounded, honest preview: the footer
|
||||
// names the on-disk artifact path and how to read the omitted range
|
||||
// back. Retrieval machinery stays out of the transcript.
|
||||
assert!(inline.len() <= 21 * 1024);
|
||||
assert!(!inline.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
|
||||
assert!(inline.contains("of output omitted"));
|
||||
assert!(inline.contains("full output at"));
|
||||
assert!(inline.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT));
|
||||
assert!(inline.contains("\n…\n"));
|
||||
assert!(!inline.contains(&path.display().to_string()));
|
||||
assert!(inline.contains(&path.display().to_string()));
|
||||
assert!(!inline.contains("Exact evidence retained"));
|
||||
assert!(!inline.contains("retrieve_tool_result"));
|
||||
// Full output remains recoverable from disk.
|
||||
@@ -13070,8 +13074,9 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
|
||||
format!("Error: {raw}"),
|
||||
);
|
||||
assert!(spilled.is_some());
|
||||
assert!(bounded_err.len() <= 4 * 1024);
|
||||
assert!(bounded_err.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
|
||||
assert!(bounded_err.len() <= 21 * 1024);
|
||||
assert!(bounded_err.contains("of output omitted"));
|
||||
assert!(bounded_err.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT));
|
||||
assert!(!bounded_err.contains("Exact evidence retained"));
|
||||
assert!(!bounded_err.contains("retrieve_tool_result"));
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! path (`turn_loop.rs`) so any successful tool result over
|
||||
//! [`SPILLOVER_THRESHOLD_BYTES`] spills to disk and the model
|
||||
//! receives a bounded plain preview: a [`SPILLOVER_HEAD_BYTES`] head,
|
||||
//! a short retained tail, and an ordinary footer pointing at the
|
||||
//! tool details view.
|
||||
//! a short retained tail, and an honest footer naming the on-disk
|
||||
//! path of the full output plus a one-line recovery instruction.
|
||||
//! * Boot prune in `main.rs` deletes files older than
|
||||
//! [`SPILLOVER_MAX_AGE`].
|
||||
//!
|
||||
@@ -330,39 +330,72 @@ pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024;
|
||||
/// test failures are not systematically hidden by truncation.
|
||||
pub const SPILLOVER_TAIL_BYTES: usize = 8 * 1024;
|
||||
|
||||
fn retained_tail(content: &str, max_bytes: usize) -> &str {
|
||||
let floor = content.len().saturating_sub(max_bytes);
|
||||
let start = (floor..=content.len())
|
||||
.find(|&index| content.is_char_boundary(index))
|
||||
.unwrap_or(content.len());
|
||||
&content[start..]
|
||||
}
|
||||
/// Inline head/tail budgets for the adaptive evidence bands. Hybrid results
|
||||
/// keep a generous 32 KiB head + 8 KiB tail so mid-size outputs stay mostly
|
||||
/// readable; handle-only results keep a 16 KiB head + 4 KiB tail. The head
|
||||
/// and tail windows never overlap ([`head_tail_windows`]).
|
||||
const HYBRID_HEAD_BYTES: usize = 32 * 1024;
|
||||
const HYBRID_TAIL_BYTES: usize = 8 * 1024;
|
||||
const HANDLE_ONLY_HEAD_BYTES: usize = 16 * 1024;
|
||||
const HANDLE_ONLY_TAIL_BYTES: usize = 4 * 1024;
|
||||
|
||||
/// Phrase shared by the model-facing preview footer and the TUI expand
|
||||
/// affordance, so both surfaces agree on where the full output lives.
|
||||
/// The phrase itself is deliberately ordinary: no handles, paths, or
|
||||
/// retrieval references.
|
||||
/// Phrase used only by the TUI expand affordance and the UI-side detection of
|
||||
/// historical truncated previews. Never emitted into model-facing content:
|
||||
/// the model cannot open the tool details view, so the model-facing footer
|
||||
/// carries the artifact path and a recovery instruction instead.
|
||||
pub const SPILLOVER_PREVIEW_HINT: &str = "view full output in the tool details view";
|
||||
|
||||
/// Ordinary footer for a truncated tool result. The full output is retained
|
||||
/// internally; this text only tells the model the preview is bounded and
|
||||
/// where the complete output can be seen (the tool details view).
|
||||
fn spillover_preview_footer(omitted_bytes: usize) -> String {
|
||||
/// One-line recovery instruction in the model-facing truncation footer. Also
|
||||
/// used by the TUI to recognise current-format truncated previews.
|
||||
pub const SPILLOVER_RECOVERY_HINT: &str =
|
||||
"read it back with the read_file tool or with sed line ranges";
|
||||
|
||||
/// Model-facing footer for a truncated tool result. Names how much was
|
||||
/// omitted (bytes and lines), where the complete output lives on disk, and
|
||||
/// how the model can read the omitted range back.
|
||||
fn spillover_preview_footer(
|
||||
omitted_bytes: usize,
|
||||
omitted_lines: usize,
|
||||
recovery_path: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"… {} of output omitted — {SPILLOVER_PREVIEW_HINT}",
|
||||
"… {} of output omitted ({omitted_lines} lines) — full output at {recovery_path}; {SPILLOVER_RECOVERY_HINT}",
|
||||
crate::artifacts::format_byte_size(omitted_bytes.try_into().unwrap_or(u64::MAX))
|
||||
)
|
||||
}
|
||||
|
||||
/// Split `content` into a head of at most `head_bytes` and a tail of at most
|
||||
/// `tail_bytes` that never overlap: the tail window always starts at or after
|
||||
/// the head window ends, so no byte of the output appears twice and the
|
||||
/// omitted count is exact.
|
||||
fn head_tail_windows(content: &str, head_bytes: usize, tail_bytes: usize) -> (&str, &str) {
|
||||
let head_end = (0..=head_bytes.min(content.len()))
|
||||
.rev()
|
||||
.find(|&index| content.is_char_boundary(index))
|
||||
.unwrap_or(0);
|
||||
let tail_floor = content.len().saturating_sub(tail_bytes).max(head_end);
|
||||
let tail_start = (tail_floor..=content.len())
|
||||
.find(|&index| content.is_char_boundary(index))
|
||||
.unwrap_or(content.len());
|
||||
(&content[..head_end], &content[tail_start..])
|
||||
}
|
||||
|
||||
/// Build the model-facing preview for a truncated tool result: the head, an
|
||||
/// ordinary footer naming how much was omitted and where the full output can
|
||||
/// be seen, and a short retained tail. The full output is still retained
|
||||
/// internally; this is only the conversation-facing shape.
|
||||
fn truncated_preview(head: &str, tail: &str, original_len: usize) -> String {
|
||||
let omitted = original_len.saturating_sub(head.len() + tail.len());
|
||||
/// honest footer naming how much was omitted and where the full output can be
|
||||
/// read back, and a short retained tail. When the head and tail windows cover
|
||||
/// the whole output (nothing was actually omitted), the content is returned
|
||||
/// unchanged — the preview never claims a truncation that did not happen.
|
||||
fn truncated_preview(head: &str, tail: &str, original: &str, recovery_path: &str) -> String {
|
||||
let omitted = original.len().saturating_sub(head.len() + tail.len());
|
||||
if omitted == 0 {
|
||||
return original.to_string();
|
||||
}
|
||||
let omitted_lines = original[head.len()..original.len() - tail.len()]
|
||||
.lines()
|
||||
.count();
|
||||
format!(
|
||||
"{head}\n\n{}\n\n…\n{tail}",
|
||||
spillover_preview_footer(omitted)
|
||||
spillover_preview_footer(omitted, omitted_lines, recovery_path)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,9 +403,9 @@ fn truncated_preview(head: &str, tail: &str, original_len: usize) -> String {
|
||||
/// content exceeds [`SPILLOVER_THRESHOLD_BYTES`], writes the full
|
||||
/// content to a sibling file under `~/.codewhale/tool_outputs/`,
|
||||
/// replaces `result.content` with a [`SPILLOVER_HEAD_BYTES`] head
|
||||
/// plus an ordinary preview footer pointing at the tool details
|
||||
/// view, and stamps `metadata.spillover_path` so the UI can render
|
||||
/// its expand annotation.
|
||||
/// plus a footer naming the spillover path and how to read the
|
||||
/// omitted range back, and stamps `metadata.spillover_path` so the
|
||||
/// UI can render its expand annotation.
|
||||
///
|
||||
/// Returns the spillover path on success, `None` if no spillover
|
||||
/// happened (content small enough, error result, write failure).
|
||||
@@ -392,7 +425,8 @@ pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf
|
||||
/// Apply adaptive routing and publish session-scoped exact evidence.
|
||||
///
|
||||
/// The default path writes one immutable payload under the origin session and
|
||||
/// replaces non-inline content with a calm, bounded receipt. The legacy dual
|
||||
/// replaces non-inline content with a bounded preview whose footer names the
|
||||
/// artifact path and how to read the omitted range back. The legacy dual
|
||||
/// spillover behavior is reachable only through the classic rollback switch.
|
||||
pub fn apply_spillover_with_artifact(
|
||||
result: &mut ToolResult,
|
||||
@@ -451,8 +485,12 @@ fn apply_spillover_inner(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (head, path) = outcome;
|
||||
let tail = retained_tail(&original_content, SPILLOVER_TAIL_BYTES);
|
||||
let (_head, path) = outcome;
|
||||
let (head, tail) = head_tail_windows(
|
||||
&original_content,
|
||||
SPILLOVER_HEAD_BYTES,
|
||||
SPILLOVER_TAIL_BYTES,
|
||||
);
|
||||
let digest = crate::hashing::sha256_hex(original_content.as_bytes());
|
||||
let path_str = path.display().to_string();
|
||||
|
||||
@@ -490,7 +528,12 @@ fn apply_spillover_inner(
|
||||
relative_path.clone(),
|
||||
&original_content,
|
||||
);
|
||||
result.content = truncated_preview(&head, tail, original_content.len());
|
||||
result.content = truncated_preview(
|
||||
head,
|
||||
tail,
|
||||
&original_content,
|
||||
&absolute_path.display().to_string(),
|
||||
);
|
||||
artifact_path = Some((absolute_path, relative_path, record));
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -505,7 +548,7 @@ fn apply_spillover_inner(
|
||||
}
|
||||
|
||||
if artifact_path.is_none() {
|
||||
result.content = truncated_preview(&head, tail, original_content.len());
|
||||
result.content = truncated_preview(head, tail, &original_content, &path_str);
|
||||
}
|
||||
|
||||
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
|
||||
@@ -658,6 +701,22 @@ fn apply_adaptive_evidence_inner(
|
||||
}
|
||||
|
||||
let original = result.content.clone();
|
||||
let (head_bytes, tail_bytes) = if routing == EvidenceRouting::Hybrid {
|
||||
(HYBRID_HEAD_BYTES, HYBRID_TAIL_BYTES)
|
||||
} else {
|
||||
(HANDLE_ONLY_HEAD_BYTES, HANDLE_ONLY_TAIL_BYTES)
|
||||
};
|
||||
let (head, tail) = head_tail_windows(&original, head_bytes, tail_bytes);
|
||||
let omitted = original.len().saturating_sub(head.len() + tail.len());
|
||||
if omitted == 0 {
|
||||
// The whole output fits inside the preview budget: there is nothing
|
||||
// to recover, so publishing an artifact and claiming a truncation
|
||||
// would both be dishonest. Pass the content through unchanged.
|
||||
return None;
|
||||
}
|
||||
let head_len = head.len();
|
||||
let tail_len = tail.len();
|
||||
|
||||
let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
|
||||
let relative_path = crate::artifacts::session_artifact_relative_path(&artifact_id);
|
||||
let digest = crate::hashing::sha256_hex(original.as_bytes());
|
||||
@@ -736,22 +795,7 @@ fn apply_adaptive_evidence_inner(
|
||||
relative_path.clone(),
|
||||
&original,
|
||||
);
|
||||
let head_limit = if routing == EvidenceRouting::Hybrid {
|
||||
8 * 1024
|
||||
} else {
|
||||
2 * 1024
|
||||
};
|
||||
let tail_limit = if routing == EvidenceRouting::Hybrid {
|
||||
2 * 1024
|
||||
} else {
|
||||
512
|
||||
};
|
||||
let head_end = (0..=head_limit.min(original.len()))
|
||||
.rev()
|
||||
.find(|index| original.is_char_boundary(*index))
|
||||
.unwrap_or(0);
|
||||
let tail = retained_tail(&original, tail_limit);
|
||||
result.content = truncated_preview(&original[..head_end], tail, original.len());
|
||||
result.content = truncated_preview(head, tail, &original, &absolute_path.display().to_string());
|
||||
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.insert(
|
||||
@@ -772,8 +816,8 @@ fn apply_adaptive_evidence_inner(
|
||||
object.insert("evidence_available".into(), true.into());
|
||||
object.insert("truncated".into(), true.into());
|
||||
object.insert("original_byte_count".into(), artifact.size_bytes.into());
|
||||
object.insert("retained_head_bytes".into(), head_end.into());
|
||||
object.insert("retained_tail_bytes".into(), tail.len().into());
|
||||
object.insert("retained_head_bytes".into(), head_len.into());
|
||||
object.insert("retained_tail_bytes".into(), tail_len.into());
|
||||
object.insert(
|
||||
"artifact_preview".into(),
|
||||
original.chars().take(200).collect::<String>().into(),
|
||||
@@ -1092,18 +1136,22 @@ mod tests {
|
||||
let mut result = ToolResult::success(big.clone());
|
||||
let path = apply_spillover(&mut result, "call-big").expect("should spill");
|
||||
|
||||
// Inline content shrunk to head + plain preview footer.
|
||||
// Inline content shrunk to head + honest preview footer.
|
||||
assert!(result.content.len() < big.len());
|
||||
assert!(
|
||||
result.content.contains(SPILLOVER_PREVIEW_HINT),
|
||||
!result.content.contains(SPILLOVER_PREVIEW_HINT),
|
||||
"the tool-details phrase is a UI affordance, not model-facing"
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("of output omitted"),
|
||||
"footer missing: {}",
|
||||
&result.content[result.content.len().saturating_sub(200)..]
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.content
|
||||
.contains("of output omitted — view full output in the tool details view")
|
||||
);
|
||||
// The footer tells the model where the full output lives and how
|
||||
// to read the omitted range back.
|
||||
assert!(result.content.contains("full output at"));
|
||||
assert!(result.content.contains(&path.display().to_string()));
|
||||
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
|
||||
assert!(!result.content.contains("retrieve_tool_result"));
|
||||
|
||||
// Full bytes are on disk at the returned path.
|
||||
@@ -1156,13 +1204,19 @@ mod tests {
|
||||
.exists(),
|
||||
"adaptive evidence stores one exact origin-session copy"
|
||||
);
|
||||
// The model sees a plain preview with an ordinary footer — no
|
||||
// artifact handle, no retrieval reference.
|
||||
assert!(result.content.contains(SPILLOVER_PREVIEW_HINT));
|
||||
// The model sees a bounded preview with an honest footer: the
|
||||
// artifact path and a recovery instruction, no retrieval handle.
|
||||
assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
|
||||
assert!(result.content.contains("\n…\n"));
|
||||
assert!(result.content.contains("of output omitted"));
|
||||
assert!(result.content.contains("full output at"));
|
||||
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
|
||||
assert!(
|
||||
result.content.contains("art_call-big.txt"),
|
||||
"footer must name the artifact path so the model can recover the output"
|
||||
);
|
||||
assert!(!result.content.contains("Exact evidence retained"));
|
||||
assert!(!result.content.contains("retrieve_tool_result"));
|
||||
assert!(!result.content.contains("artifacts/art_call-big.txt"));
|
||||
assert!(
|
||||
session_artifact
|
||||
.with_file_name("art_call-big.evidence.json")
|
||||
@@ -1189,8 +1243,8 @@ mod tests {
|
||||
Some("session-123")
|
||||
);
|
||||
assert_eq!(metadata["original_byte_count"], big.len());
|
||||
assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 2 * 1024);
|
||||
assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 512);
|
||||
assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 16 * 1024);
|
||||
assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 4 * 1024);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1200,13 +1254,15 @@ mod tests {
|
||||
let tmp = tempdir().unwrap();
|
||||
with_test_home(tmp.path(), || {
|
||||
let sentinel = "DEEP_RAW_SENTINEL";
|
||||
// Payloads must exceed the 32_768-token (≈96 KiB) handle-only
|
||||
// threshold so adaptive routing actually spills them.
|
||||
let success_raw = format!(
|
||||
"{}{}{}",
|
||||
"head\n".repeat(2_000),
|
||||
"head\n".repeat(30_000),
|
||||
sentinel,
|
||||
"tail\n".repeat(2_000)
|
||||
"tail\n".repeat(30_000)
|
||||
);
|
||||
let failure_raw = format!("{}{}", "failure\n".repeat(3_000), "FAILURE_END");
|
||||
let failure_raw = format!("{}{}", "failure\n".repeat(30_000), "FAILURE_END");
|
||||
let mut success = ToolResult::success(success_raw.clone());
|
||||
let mut failure = ToolResult::error(failure_raw.clone());
|
||||
|
||||
@@ -1235,7 +1291,8 @@ mod tests {
|
||||
failure_raw.as_bytes()
|
||||
);
|
||||
assert!(!success.content.contains(sentinel));
|
||||
assert!(success.content.len() < 4 * 1024);
|
||||
// Handle-only preview: 16 KiB head + 4 KiB tail + footer.
|
||||
assert!(success.content.len() < 21 * 1024);
|
||||
let success_meta = success.metadata.as_ref().unwrap();
|
||||
let failure_meta = failure.metadata.as_ref().unwrap();
|
||||
assert_ne!(
|
||||
@@ -1405,4 +1462,102 @@ mod tests {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Honest-truncation regressions (v0.9.4) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncated_preview_returns_content_unchanged_when_nothing_omitted() {
|
||||
let original = "line one\nline two\nline three\n";
|
||||
let preview = truncated_preview(original, "", original, "/tmp/artifact.txt");
|
||||
assert_eq!(preview, original);
|
||||
assert!(
|
||||
!preview.contains("of output omitted"),
|
||||
"must never claim a truncation that did not happen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_tail_windows_never_overlap() {
|
||||
// Content smaller than head + tail budgets: the tail window shrinks
|
||||
// so it starts exactly where the head ends — no byte appears twice.
|
||||
let content = "x".repeat(10_000);
|
||||
let (head, tail) = head_tail_windows(&content, 8 * 1024, 4 * 1024);
|
||||
assert_eq!(head.len(), 8 * 1024);
|
||||
assert_eq!(tail.len(), 10_000 - 8 * 1024);
|
||||
assert!(head.len() + tail.len() <= content.len());
|
||||
|
||||
// Content larger than both budgets: full windows, exact omission.
|
||||
let big = "y".repeat(100_000);
|
||||
let (head, tail) = head_tail_windows(&big, 32 * 1024, 8 * 1024);
|
||||
assert_eq!(head.len(), 32 * 1024);
|
||||
assert_eq!(tail.len(), 8 * 1024);
|
||||
|
||||
// UTF-8 codepoints are never split at either window edge.
|
||||
let emoji = "🐳".repeat(5_000); // 20_000 bytes, 4 per codepoint
|
||||
let (head, tail) = head_tail_windows(&emoji, 8 * 1024 + 1, 4 * 1024 + 2);
|
||||
assert!(emoji.is_char_boundary(head.len()));
|
||||
assert!(emoji.is_char_boundary(emoji.len() - tail.len()));
|
||||
assert!(head.len() + tail.len() <= emoji.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_evidence_passes_through_when_preview_budget_covers_output() {
|
||||
let _g = setup();
|
||||
let tmp = tempdir().unwrap();
|
||||
with_test_home(tmp.path(), || {
|
||||
// 30_000 bytes → 10_000 estimated tokens → Hybrid band under the
|
||||
// 32_768-token default, but the 32 KiB + 8 KiB preview budget
|
||||
// covers the whole output, so nothing is actually omitted.
|
||||
let raw = "mid\n".repeat(7_500);
|
||||
assert_eq!(raw.len(), 30_000);
|
||||
let mut result = ToolResult::success(raw.clone());
|
||||
let path = apply_spillover_with_artifact(
|
||||
&mut result,
|
||||
"call-covered",
|
||||
"exec_shell",
|
||||
"session-covered",
|
||||
);
|
||||
assert!(path.is_none(), "no artifact when nothing is omitted");
|
||||
assert_eq!(result.content, raw);
|
||||
assert!(!result.content.contains("of output omitted"));
|
||||
assert!(
|
||||
!tmp.path()
|
||||
.join(".codewhale/sessions/session-covered/artifacts/art_call-covered.txt")
|
||||
.exists()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_evidence_footer_names_artifact_path_and_recovery() {
|
||||
let _g = setup();
|
||||
let tmp = tempdir().unwrap();
|
||||
with_test_home(tmp.path(), || {
|
||||
// 120_000 bytes → 40_000 estimated tokens → handle-only band.
|
||||
let raw = "entry\n".repeat(20_000);
|
||||
assert_eq!(raw.len(), 120_000);
|
||||
let mut result = ToolResult::success(raw);
|
||||
let path = apply_spillover_with_artifact(
|
||||
&mut result,
|
||||
"call-honest",
|
||||
"exec_shell",
|
||||
"session-honest",
|
||||
)
|
||||
.expect("should spill");
|
||||
|
||||
// Footer: omitted size + line count, artifact path, recovery line.
|
||||
assert!(result.content.contains("of output omitted ("));
|
||||
assert!(result.content.contains(" lines)"));
|
||||
assert!(result.content.contains("full output at"));
|
||||
assert!(result.content.contains(&path.display().to_string()));
|
||||
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
|
||||
assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
|
||||
|
||||
// Head and tail do not overlap: 16 KiB + 4 KiB handle-only
|
||||
// windows over a 120_000-byte output.
|
||||
let metadata = result.metadata.expect("metadata stamped");
|
||||
assert_eq!(metadata["retained_head_bytes"], 16 * 1024);
|
||||
assert_eq!(metadata["retained_tail_bytes"], 4 * 1024);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1934,11 +1934,13 @@ fn render_spillover_annotation(width: u16) -> Line<'static> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Detect a truncated-output preview: either the current plain footer or the
|
||||
/// legacy receipt header still present in older saved sessions. Live cards
|
||||
/// collapse to the expand affordance for both.
|
||||
/// Detect a truncated-output preview: the current model-facing footer (which
|
||||
/// names the artifact path and recovery instruction), the previous plain
|
||||
/// footer, or the legacy receipt header still present in older saved
|
||||
/// sessions. Live cards collapse to the expand affordance for all of them.
|
||||
fn is_truncated_output_preview(content: &str) -> bool {
|
||||
content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
|
||||
content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT)
|
||||
|| content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
|
||||
|| content.trim_start().starts_with("[Exact evidence retained")
|
||||
}
|
||||
|
||||
|
||||
@@ -57,16 +57,24 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
|
||||
(&failure_receipt, FAILURE_SENTINEL),
|
||||
] {
|
||||
assert!(
|
||||
receipt.contains("of output omitted — view full output in the tool details view"),
|
||||
"model-facing truncation must use the plain preview footer"
|
||||
receipt.contains("of output omitted"),
|
||||
"model-facing truncation must state how much was omitted"
|
||||
);
|
||||
assert!(
|
||||
receipt.contains("full output at"),
|
||||
"model-facing truncation must name the recovery path"
|
||||
);
|
||||
assert!(
|
||||
receipt.contains("/artifacts/"),
|
||||
"the footer deliberately names the on-disk artifact so the model can read the omitted range back"
|
||||
);
|
||||
assert!(!receipt.contains("retrieve_tool_result"));
|
||||
assert!(!receipt.contains("[Exact evidence retained"));
|
||||
assert!(!receipt.contains(sentinel));
|
||||
assert!(!receipt.contains("/artifacts/"));
|
||||
assert!(
|
||||
receipt.len() <= 3_200,
|
||||
"bounded preview must stay within the receipt budget"
|
||||
receipt.len() <= 42_000,
|
||||
"bounded preview must stay within the hybrid 32 KiB head + 8 KiB tail receipt budget, got {} bytes",
|
||||
receipt.len()
|
||||
);
|
||||
}
|
||||
assert_ne!(success_receipt, failure_receipt);
|
||||
@@ -256,22 +264,27 @@ fn bash_tool_sse(call_id: &str, success: bool) -> String {
|
||||
].join("")
|
||||
}
|
||||
|
||||
/// Shell fixture that emits enough bytes to force exact-evidence routing: one
|
||||
/// sentinel line buried at iteration 120 of ~2,800 filler lines. The probe
|
||||
/// executes through the platform shell — bash on Unix, `cmd /C` on Windows
|
||||
/// Shell fixture that emits enough bytes to force exact-evidence routing under
|
||||
/// the 32_768-token default threshold. The Bash adapter self-bounds each
|
||||
/// stream to ~30 KB, so a single stream would now fit inside the hybrid
|
||||
/// 32 KiB + 8 KiB preview budget; the probe therefore fills stdout AND stderr
|
||||
/// (~60 KB combined) so the envelope still omits a middle range. The sentinel
|
||||
/// rides stderr at filler line 100: deep enough to survive the shell tool's
|
||||
/// own 22 KB head bound (so the artifact retains it) yet beyond the preview's
|
||||
/// 32 KiB head (so the model receipt omits it). The probe executes through
|
||||
/// the platform shell — bash on Unix, `cmd /C` on Windows
|
||||
/// (#1691) — so each platform needs native syntax to exercise the same
|
||||
/// routing path.
|
||||
#[cfg(not(windows))]
|
||||
fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
|
||||
let trailer = if success { "" } else { "; exit 7" };
|
||||
let body = format!(
|
||||
"i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf '%s\\n' '{sentinel}'; fi; printf '{prefix}-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done{trailer}"
|
||||
let stdout_loop = format!(
|
||||
"i=0; while [ \"$i\" -lt 2800 ]; do printf '{prefix}-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done"
|
||||
);
|
||||
if success {
|
||||
body
|
||||
} else {
|
||||
format!("{{ {body}; }} >&2")
|
||||
}
|
||||
let stderr_loop = format!(
|
||||
"j=0; while [ \"$j\" -lt 2800 ]; do if [ \"$j\" -eq 100 ]; then printf '%s\\n' '{sentinel}'; fi; printf '{prefix}-ERR-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$j\"; j=$((j + 1)); done"
|
||||
);
|
||||
format!("{stdout_loop}; {{ {stderr_loop}; }} >&2{trailer}")
|
||||
}
|
||||
|
||||
/// PowerShell syntax: on Windows the shell dispatcher prefers `pwsh.exe`,
|
||||
@@ -283,26 +296,18 @@ fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
|
||||
/// stderr handle and exiting 7 after the loop.
|
||||
#[cfg(windows)]
|
||||
fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
|
||||
let emit = |text: &str| {
|
||||
if success {
|
||||
format!("Write-Output {text}")
|
||||
} else {
|
||||
format!("[Console]::Error.WriteLine({text})")
|
||||
}
|
||||
};
|
||||
let line = format!(
|
||||
let stdout_line = format!(
|
||||
"'{prefix}-{{0}}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"
|
||||
);
|
||||
let body = format!(
|
||||
"0..2799 | ForEach-Object {{ if ($_ -eq 120) {{ {} }} else {{ {} }} }}",
|
||||
emit(&format!("'{sentinel}'")),
|
||||
emit(&format!("({line} -f $_)"))
|
||||
let stderr_line = format!(
|
||||
"'{prefix}-ERR-{{0}}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"
|
||||
);
|
||||
if success {
|
||||
body
|
||||
} else {
|
||||
format!("{body}; exit 7")
|
||||
}
|
||||
let stdout_loop = format!("0..2799 | ForEach-Object {{ Write-Output ({stdout_line} -f $_) }}");
|
||||
let stderr_loop = format!(
|
||||
"0..2799 | ForEach-Object {{ if ($_ -eq 100) {{ [Console]::Error.WriteLine('{sentinel}') }}; [Console]::Error.WriteLine(({stderr_line} -f $_)) }}"
|
||||
);
|
||||
let trailer = if success { "" } else { "; exit 7" };
|
||||
format!("{stdout_loop}; {stderr_loop}{trailer}")
|
||||
}
|
||||
|
||||
fn final_sse() -> String {
|
||||
|
||||
+20
-10
@@ -2793,8 +2793,15 @@ fn spawn_tool_lifecycle_screen_fixture(
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
// The Bash adapter self-bounds each stream to ~30 KB, so a single stream
|
||||
// would fit inside the hybrid 32 KiB + 8 KiB preview budget under the
|
||||
// 32_768-token evidence threshold. Fill stdout AND stderr (~60 KB
|
||||
// combined) so the envelope still omits a middle range; the sentinel
|
||||
// rides stderr at filler line 100 — inside the shell tool's own 22 KB
|
||||
// head bound (so the artifact retains it) but beyond the preview's
|
||||
// 32 KiB head (so the model receipt omits it).
|
||||
let shell_command = format!(
|
||||
"printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; printf 'PTY-TOOL-END\\n'"
|
||||
"printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; {{ j=0; while [ \"$j\" -lt 2800 ]; do if [ \"$j\" -eq 100 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-ERR-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$j\"; j=$((j + 1)); done; }} >&2; printf 'PTY-TOOL-END\\n'"
|
||||
);
|
||||
let replies = [
|
||||
pty_tool_call_sse(
|
||||
@@ -2901,22 +2908,25 @@ fn spawn_tool_lifecycle_screen_fixture(
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !bash_result.contains(
|
||||
"of output omitted — view full output in the tool details view",
|
||||
) || bash_result.contains("Exact evidence retained")
|
||||
|| bash_result.contains("art_call_bash_pty")
|
||||
// Honest bounded-preview contract: the footer states
|
||||
// the omission and names the on-disk artifact path so
|
||||
// the model can read the omitted range back; the deep
|
||||
// sentinel stays out of the inline receipt.
|
||||
if !bash_result.contains("of output omitted")
|
||||
|| !bash_result.contains("full output at")
|
||||
|| !bash_result.contains("art_call_bash_pty.txt")
|
||||
|| bash_result.contains("Exact evidence retained")
|
||||
|| bash_result.contains("retrieve_tool_result")
|
||||
|| bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL")
|
||||
|| bash_result.contains("/artifacts/")
|
||||
{
|
||||
contract_errors.push(format!(
|
||||
"final request violated the bounded plain Bash preview contract (preview={}, legacy_receipt={}, handle={}, retrieval_tool={}, deep_sentinel={}, artifact_path={})",
|
||||
bash_result.contains("of output omitted — view full output in the tool details view"),
|
||||
"final request violated the honest bounded Bash preview contract (omission={}, path_footer={}, artifact_path={}, legacy_receipt={}, retrieval_tool={}, deep_sentinel={})",
|
||||
bash_result.contains("of output omitted"),
|
||||
bash_result.contains("full output at"),
|
||||
bash_result.contains("art_call_bash_pty.txt"),
|
||||
bash_result.contains("Exact evidence retained"),
|
||||
bash_result.contains("art_call_bash_pty"),
|
||||
bash_result.contains("retrieve_tool_result"),
|
||||
bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL"),
|
||||
bash_result.contains("/artifacts/"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user