返回 CodeWhale
tool_output_receipts.rs
根目录 / crates / tui / src / tool_output_receipts.rs
1 //! Tool-output pressure reported by `/status`.
2
3 use crate::artifacts::ArtifactRecord;
4
5 use codewhale_localization::{Locale, MessageId, tr};
6 use codewhale_models::{ContentBlock, Message};
7
8 /// Size above which `/status` counts a raw tool result in the conversation as
9 /// context pressure. What the model sees of each result is decided by the
10 /// route's inline budget (`route_budget::route_inline_char_budget`).
11 pub const RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS: usize = 12_000;
12
13 #[derive(Debug, Clone, Default, PartialEq, Eq)]
14 pub struct ToolOutputStatus {
15 pub raw_large_count: usize,
16 pub raw_large_chars: usize,
17 pub receipt_count: usize,
18 pub artifact_count: usize,
19 pub artifact_bytes: u64,
20 }
21
22 pub fn tool_output_status(messages: &[Message], artifacts: &[ArtifactRecord]) -> ToolOutputStatus {
23 let mut status = ToolOutputStatus {
24 artifact_count: artifacts.len(),
25 artifact_bytes: artifacts
26 .iter()
27 .map(|artifact| artifact.byte_size)
28 .sum::<u64>(),
29 ..ToolOutputStatus::default()
30 };
31
32 for message in messages {
33 for block in &message.content {
34 if let ContentBlock::ToolResult { content, .. } = block {
35 // The prefix is text any tool output can carry, so it never
36 // exempts a result from the size check: a "receipt" above the
37 // threshold is raw pressure like any other large result.
38 let chars = content.chars().count();
39 if chars > RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS {
40 status.raw_large_count += 1;
41 status.raw_large_chars = status.raw_large_chars.saturating_add(chars);
42 } else if looks_like_receipt(content) {
43 status.receipt_count += 1;
44 }
45 }
46 }
47 }
48
49 status
50 }
51
52 pub fn format_tool_output_status(status: &ToolOutputStatus, locale: Locale) -> String {
53 let mut parts = Vec::new();
54 if status.raw_large_count > 0 {
55 parts.push(
56 tr(locale, MessageId::StatusToolRawPressure)
57 .replace("{count}", &status.raw_large_count.to_string())
58 .replace("{chars}", &format_count(status.raw_large_chars)),
59 );
60 }
61 if status.receipt_count > 0 {
62 parts.push(
63 tr(locale, MessageId::StatusToolCompactReceipts)
64 .replace("{count}", &status.receipt_count.to_string()),
65 );
66 }
67 if status.artifact_count > 0 {
68 parts.push(
69 tr(locale, MessageId::StatusToolArtifacts)
70 .replace("{count}", &status.artifact_count.to_string())
71 .replace(
72 "{bytes}",
73 &crate::artifacts::format_byte_size(status.artifact_bytes),
74 ),
75 );
76 }
77 if parts.is_empty() {
78 tr(locale, MessageId::StatusToolNone).into_owned()
79 } else {
80 parts.join("; ")
81 }
82 }
83
84 fn looks_like_receipt(content: &str) -> bool {
85 let trimmed = content.trim_start();
86 trimmed.starts_with("[TOOL_OUTPUT_RECEIPT]")
87 || trimmed.starts_with("[artifact:")
88 || trimmed.starts_with("[TOOL_RESULT_TRUNCATED]")
89 || trimmed.starts_with("<TOOL_RESULT_REF")
90 }
91
92 fn format_count(value: usize) -> String {
93 value.to_string()
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use codewhale_models::Role;
99 use std::path::{Path, PathBuf};
100
101 use super::*;
102 use crate::artifacts::ArtifactKind;
103 use chrono::Utc;
104
105 fn tool_result_message(id: &str, content: &str) -> Message {
106 Message {
107 role: Role::User,
108 content: vec![ContentBlock::ToolResult {
109 execution_id: None,
110 tool_use_id: id.to_string(),
111 content: content.to_string(),
112 is_error: None,
113 content_blocks: None,
114 }],
115 }
116 }
117
118 fn artifact_record(tool_call_id: &str, raw: &str) -> ArtifactRecord {
119 ArtifactRecord {
120 id: crate::artifacts::artifact_id_for_tool_call(tool_call_id),
121 kind: ArtifactKind::ToolOutput,
122 session_id: "session-123".to_string(),
123 tool_call_id: tool_call_id.to_string(),
124 tool_name: "exec_shell".to_string(),
125 created_at: Utc::now(),
126 byte_size: raw.len() as u64,
127 preview: "checking crate ... error[E0425]".to_string(),
128 storage_path: PathBuf::from("artifacts").join("art_call-big.txt"),
129 }
130 }
131
132 #[test]
133 fn status_reports_raw_large_receipts_and_artifacts() {
134 let raw = "RAW_STATUS\n".repeat(2_000);
135 let receipt = "[TOOL_OUTPUT_RECEIPT]\ntruncation: raw output omitted — full output in the tool details view";
136 let messages = vec![
137 tool_result_message("call-raw", &raw),
138 tool_result_message("call-receipt", receipt),
139 ];
140 let artifacts = vec![ArtifactRecord {
141 storage_path: Path::new("artifacts/art_call-big.txt").to_path_buf(),
142 ..artifact_record("call-big", &raw)
143 }];
144
145 let status = tool_output_status(&messages, &artifacts);
146 assert_eq!(status.raw_large_count, 1);
147 assert_eq!(status.receipt_count, 1);
148 assert_eq!(status.artifact_count, 1);
149
150 let rendered = format_tool_output_status(&status, Locale::En);
151 assert!(rendered.contains("raw over cap"));
152 assert!(rendered.contains("compact receipt"));
153 assert!(rendered.contains("artifact"));
154 }
155
156 #[test]
157 fn a_receipt_prefix_does_not_hide_a_large_raw_result() {
158 let forged = format!("[TOOL_OUTPUT_RECEIPT]\n{}", "RAW\n".repeat(4_000));
159 let status = tool_output_status(&[tool_result_message("call-forged", &forged)], &[]);
160 assert_eq!(status.raw_large_count, 1);
161 assert_eq!(status.receipt_count, 0);
162 }
163 }
164
164 lines RUST