Skip to content

Commit efebc62

Browse files
Move shell to use truncate_text (#6842)
Move shell to use the configurable `truncate_text` --------- Co-authored-by: pakrym-oai <[email protected]>
1 parent 75f38f1 commit efebc62

File tree

16 files changed

+560
-475
lines changed

16 files changed

+560
-475
lines changed

codex-rs/core/src/client_common.rs

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,9 @@ fn build_structured_output(parsed: &ExecOutputJson) -> String {
165165
));
166166

167167
let mut output = parsed.output.clone();
168-
if let Some(total_lines) = extract_total_output_lines(&parsed.output) {
168+
if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) {
169169
sections.push(format!("Total output lines: {total_lines}"));
170-
if let Some(stripped) = strip_total_output_header(&output) {
171-
output = stripped.to_string();
172-
}
170+
output = stripped.to_string();
173171
}
174172

175173
sections.push("Output:".to_string());
@@ -178,19 +176,12 @@ fn build_structured_output(parsed: &ExecOutputJson) -> String {
178176
sections.join("\n")
179177
}
180178

181-
fn extract_total_output_lines(output: &str) -> Option<u32> {
182-
let marker_start = output.find("[... omitted ")?;
183-
let marker = &output[marker_start..];
184-
let (_, after_of) = marker.split_once(" of ")?;
185-
let (total_segment, _) = after_of.split_once(' ')?;
186-
total_segment.parse::<u32>().ok()
187-
}
188-
189-
fn strip_total_output_header(output: &str) -> Option<&str> {
179+
fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
190180
let after_prefix = output.strip_prefix("Total output lines: ")?;
191-
let (_, remainder) = after_prefix.split_once('\n')?;
181+
let (total_segment, remainder) = after_prefix.split_once('\n')?;
182+
let total_lines = total_segment.parse::<u32>().ok()?;
192183
let remainder = remainder.strip_prefix('\n').unwrap_or(remainder);
193-
Some(remainder)
184+
Some((remainder, total_lines))
194185
}
195186

196187
#[derive(Debug)]

codex-rs/core/src/codex.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2479,8 +2479,9 @@ mod tests {
24792479
duration: StdDuration::from_secs(1),
24802480
timed_out: true,
24812481
};
2482+
let (_, turn_context) = make_session_and_context();
24822483

2483-
let out = format_exec_output_str(&exec);
2484+
let out = format_exec_output_str(&exec, turn_context.truncation_policy);
24842485

24852486
assert_eq!(
24862487
out,

codex-rs/core/src/context_manager/history.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,17 @@ impl ContextManager {
145145
}
146146

147147
fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
148+
let policy_with_serialization_budget = policy.mul(1.2);
148149
match item {
149150
ResponseItem::FunctionCallOutput { call_id, output } => {
150-
let truncated = truncate_text(output.content.as_str(), policy);
151-
let truncated_items = output
152-
.content_items
153-
.as_ref()
154-
.map(|items| truncate_function_output_items_with_policy(items, policy));
151+
let truncated =
152+
truncate_text(output.content.as_str(), policy_with_serialization_budget);
153+
let truncated_items = output.content_items.as_ref().map(|items| {
154+
truncate_function_output_items_with_policy(
155+
items,
156+
policy_with_serialization_budget,
157+
)
158+
});
155159
ResponseItem::FunctionCallOutput {
156160
call_id: call_id.clone(),
157161
output: FunctionCallOutputPayload {
@@ -162,7 +166,7 @@ impl ContextManager {
162166
}
163167
}
164168
ResponseItem::CustomToolCallOutput { call_id, output } => {
165-
let truncated = truncate_text(output, policy);
169+
let truncated = truncate_text(output, policy_with_serialization_budget);
166170
ResponseItem::CustomToolCallOutput {
167171
call_id: call_id.clone(),
168172
output: truncated,

codex-rs/core/src/context_manager/history_tests.rs

Lines changed: 33 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use codex_protocol::models::ReasoningItemReasoningSummary;
1212
use pretty_assertions::assert_eq;
1313
use regex_lite::Regex;
1414

15-
const EXEC_FORMAT_MAX_LINES: usize = 256;
1615
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
16+
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
1717

1818
fn assistant_msg(text: &str) -> ResponseItem {
1919
ResponseItem::Message {
@@ -56,6 +56,10 @@ fn reasoning_msg(text: &str) -> ResponseItem {
5656
}
5757
}
5858

59+
fn truncate_exec_output(content: &str) -> String {
60+
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
61+
}
62+
5963
#[test]
6064
fn filters_non_api_messages() {
6165
let mut h = ContextManager::default();
@@ -228,7 +232,7 @@ fn normalization_retains_local_shell_outputs() {
228232
ResponseItem::FunctionCallOutput {
229233
call_id: "shell-1".to_string(),
230234
output: FunctionCallOutputPayload {
231-
content: "ok".to_string(),
235+
content: "Total output lines: 1\n\nok".to_string(),
232236
..Default::default()
233237
},
234238
},
@@ -330,8 +334,8 @@ fn record_items_respects_custom_token_limit() {
330334
assert!(stored.content.contains("tokens truncated"));
331335
}
332336

333-
fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) {
334-
let pattern = truncated_message_pattern(line, total_lines);
337+
fn assert_truncated_message_matches(message: &str, line: &str, expected_removed: usize) {
338+
let pattern = truncated_message_pattern(line);
335339
let regex = Regex::new(&pattern).unwrap_or_else(|err| {
336340
panic!("failed to compile regex {pattern}: {err}");
337341
});
@@ -347,51 +351,37 @@ fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usiz
347351
"body exceeds byte limit: {} bytes",
348352
body.len()
349353
);
354+
let removed: usize = captures
355+
.name("removed")
356+
.expect("missing removed capture")
357+
.as_str()
358+
.parse()
359+
.unwrap_or_else(|err| panic!("invalid removed tokens: {err}"));
360+
assert_eq!(removed, expected_removed, "mismatched removed token count");
350361
}
351362

352-
fn truncated_message_pattern(line: &str, total_lines: usize) -> String {
353-
let head_lines = EXEC_FORMAT_MAX_LINES / 2;
354-
let tail_lines = EXEC_FORMAT_MAX_LINES - head_lines;
355-
let head_take = head_lines.min(total_lines);
356-
let tail_take = tail_lines.min(total_lines.saturating_sub(head_take));
357-
let omitted = total_lines.saturating_sub(head_take + tail_take);
363+
fn truncated_message_pattern(line: &str) -> String {
358364
let escaped_line = regex_lite::escape(line);
359-
if omitted == 0 {
360-
return format!(
361-
r"(?s)^Total output lines: {total_lines}\n\n(?P<body>{escaped_line}.*\n\[\.{{3}} removed \d+ bytes to fit {EXEC_FORMAT_MAX_BYTES} byte limit \.{{3}}]\n\n.*)$",
362-
);
363-
}
364-
format!(
365-
r"(?s)^Total output lines: {total_lines}\n\n(?P<body>{escaped_line}.*\n\[\.{{3}} omitted {omitted} of {total_lines} lines \.{{3}}]\n\n.*)$",
366-
)
365+
format!(r"(?s)^(?P<body>{escaped_line}.*?)(?:\r?)?…(?P<removed>\d+) tokens truncated…(?:.*)?$")
367366
}
368367

369368
#[test]
370369
fn format_exec_output_truncates_large_error() {
371370
let line = "very long execution error line that should trigger truncation\n";
372371
let large_error = line.repeat(2_500); // way beyond both byte and line limits
373372

374-
let truncated = truncate::truncate_with_line_bytes_budget(&large_error, EXEC_FORMAT_MAX_BYTES);
373+
let truncated = truncate_exec_output(&large_error);
375374

376-
let total_lines = large_error.lines().count();
377-
assert_truncated_message_matches(&truncated, line, total_lines);
375+
assert_truncated_message_matches(&truncated, line, 36250);
378376
assert_ne!(truncated, large_error);
379377
}
380378

381379
#[test]
382380
fn format_exec_output_marks_byte_truncation_without_omitted_lines() {
383-
let long_line = "a".repeat(EXEC_FORMAT_MAX_BYTES + 50);
384-
let truncated = truncate::truncate_with_line_bytes_budget(&long_line, EXEC_FORMAT_MAX_BYTES);
385-
381+
let long_line = "a".repeat(EXEC_FORMAT_MAX_BYTES + 10000);
382+
let truncated = truncate_exec_output(&long_line);
386383
assert_ne!(truncated, long_line);
387-
let removed_bytes = long_line.len().saturating_sub(EXEC_FORMAT_MAX_BYTES);
388-
let marker_line = format!(
389-
"[... removed {removed_bytes} bytes to fit {EXEC_FORMAT_MAX_BYTES} byte limit ...]"
390-
);
391-
assert!(
392-
truncated.contains(&marker_line),
393-
"missing byte truncation marker: {truncated}"
394-
);
384+
assert_truncated_message_matches(&truncated, "a", 2500);
395385
assert!(
396386
!truncated.contains("omitted"),
397387
"line omission marker should not appear when no lines were dropped: {truncated}"
@@ -401,34 +391,25 @@ fn format_exec_output_marks_byte_truncation_without_omitted_lines() {
401391
#[test]
402392
fn format_exec_output_returns_original_when_within_limits() {
403393
let content = "example output\n".repeat(10);
404-
405-
assert_eq!(
406-
truncate::truncate_with_line_bytes_budget(&content, EXEC_FORMAT_MAX_BYTES),
407-
content
408-
);
394+
assert_eq!(truncate_exec_output(&content), content);
409395
}
410396

411397
#[test]
412398
fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() {
413-
let total_lines = EXEC_FORMAT_MAX_LINES + 100;
399+
let total_lines = 2_000;
400+
let filler = "x".repeat(64);
414401
let content: String = (0..total_lines)
415-
.map(|idx| format!("line-{idx}\n"))
402+
.map(|idx| format!("line-{idx}-{filler}\n"))
416403
.collect();
417404

418-
let truncated = truncate::truncate_with_line_bytes_budget(&content, EXEC_FORMAT_MAX_BYTES);
419-
let omitted = total_lines - EXEC_FORMAT_MAX_LINES;
420-
let expected_marker = format!("[... omitted {omitted} of {total_lines} lines ...]");
421-
422-
assert!(
423-
truncated.contains(&expected_marker),
424-
"missing omitted marker: {truncated}"
425-
);
405+
let truncated = truncate_exec_output(&content);
406+
assert_truncated_message_matches(&truncated, "line-0-", 34_723);
426407
assert!(
427-
truncated.contains("line-0\n"),
408+
truncated.contains("line-0-"),
428409
"expected head line to remain: {truncated}"
429410
);
430411

431-
let last_line = format!("line-{}\n", total_lines - 1);
412+
let last_line = format!("line-{}-", total_lines - 1);
432413
assert!(
433414
truncated.contains(&last_line),
434415
"expected tail line to remain: {truncated}"
@@ -437,22 +418,15 @@ fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() {
437418

438419
#[test]
439420
fn format_exec_output_prefers_line_marker_when_both_limits_exceeded() {
440-
let total_lines = EXEC_FORMAT_MAX_LINES + 42;
421+
let total_lines = 300;
441422
let long_line = "x".repeat(256);
442423
let content: String = (0..total_lines)
443424
.map(|idx| format!("line-{idx}-{long_line}\n"))
444425
.collect();
445426

446-
let truncated = truncate::truncate_with_line_bytes_budget(&content, EXEC_FORMAT_MAX_BYTES);
427+
let truncated = truncate_exec_output(&content);
447428

448-
assert!(
449-
truncated.contains("[... omitted 42 of 298 lines ...]"),
450-
"expected omitted marker when line count exceeds limit: {truncated}"
451-
);
452-
assert!(
453-
!truncated.contains("byte limit"),
454-
"line omission marker should take precedence over byte marker: {truncated}"
455-
);
429+
assert_truncated_message_matches(&truncated, "line-0-", 17_423);
456430
}
457431

458432
//TODO(aibrahim): run CI in release mode.
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
mod history;
22
mod normalize;
33

4-
pub(crate) use crate::truncate::truncate_with_line_bytes_budget;
54
pub(crate) use history::ContextManager;

codex-rs/core/src/tasks/user_shell.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ impl SessionTask for UserShellCommandTask {
122122
duration: Duration::ZERO,
123123
timed_out: false,
124124
};
125-
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
125+
let output_items = [user_shell_command_record_item(
126+
&raw_command,
127+
&exec_output,
128+
&turn_context,
129+
)];
126130
session
127131
.record_conversation_items(turn_context.as_ref(), &output_items)
128132
.await;
@@ -164,12 +168,19 @@ impl SessionTask for UserShellCommandTask {
164168
aggregated_output: output.aggregated_output.text.clone(),
165169
exit_code: output.exit_code,
166170
duration: output.duration,
167-
formatted_output: format_exec_output_str(&output),
171+
formatted_output: format_exec_output_str(
172+
&output,
173+
turn_context.truncation_policy,
174+
),
168175
}),
169176
)
170177
.await;
171178

172-
let output_items = [user_shell_command_record_item(&raw_command, &output)];
179+
let output_items = [user_shell_command_record_item(
180+
&raw_command,
181+
&output,
182+
&turn_context,
183+
)];
173184
session
174185
.record_conversation_items(turn_context.as_ref(), &output_items)
175186
.await;
@@ -201,11 +212,18 @@ impl SessionTask for UserShellCommandTask {
201212
aggregated_output: exec_output.aggregated_output.text.clone(),
202213
exit_code: exec_output.exit_code,
203214
duration: exec_output.duration,
204-
formatted_output: format_exec_output_str(&exec_output),
215+
formatted_output: format_exec_output_str(
216+
&exec_output,
217+
turn_context.truncation_policy,
218+
),
205219
}),
206220
)
207221
.await;
208-
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
222+
let output_items = [user_shell_command_record_item(
223+
&raw_command,
224+
&exec_output,
225+
&turn_context,
226+
)];
209227
session
210228
.record_conversation_items(turn_context.as_ref(), &output_items)
211229
.await;

codex-rs/core/src/tools/events.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,16 @@ impl ToolEmitter {
242242
self.emit(ctx, ToolEventStage::Begin).await;
243243
}
244244

245-
fn format_exec_output_for_model(&self, output: &ExecToolCallOutput) -> String {
245+
fn format_exec_output_for_model(
246+
&self,
247+
output: &ExecToolCallOutput,
248+
ctx: ToolEventCtx<'_>,
249+
) -> String {
246250
match self {
247251
Self::Shell { freeform: true, .. } => {
248-
super::format_exec_output_for_model_freeform(output)
252+
super::format_exec_output_for_model_freeform(output, ctx.turn.truncation_policy)
249253
}
250-
_ => super::format_exec_output_for_model_structured(output),
254+
_ => super::format_exec_output_for_model_structured(output, ctx.turn.truncation_policy),
251255
}
252256
}
253257

@@ -258,7 +262,7 @@ impl ToolEmitter {
258262
) -> Result<String, FunctionCallError> {
259263
let (event, result) = match out {
260264
Ok(output) => {
261-
let content = self.format_exec_output_for_model(&output);
265+
let content = self.format_exec_output_for_model(&output, ctx);
262266
let exit_code = output.exit_code;
263267
let event = ToolEventStage::Success(output);
264268
let result = if exit_code == 0 {
@@ -270,7 +274,7 @@ impl ToolEmitter {
270274
}
271275
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output })))
272276
| Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => {
273-
let response = self.format_exec_output_for_model(&output);
277+
let response = self.format_exec_output_for_model(&output, ctx);
274278
let event = ToolEventStage::Failure(ToolEventFailure::Output(*output));
275279
let result = Err(FunctionCallError::RespondToModel(response));
276280
(event, result)
@@ -359,7 +363,7 @@ async fn emit_exec_stage(
359363
aggregated_output: output.aggregated_output.text.clone(),
360364
exit_code: output.exit_code,
361365
duration: output.duration,
362-
formatted_output: format_exec_output_str(&output),
366+
formatted_output: format_exec_output_str(&output, ctx.turn.truncation_policy),
363367
};
364368
emit_exec_end(ctx, exec_input, exec_result).await;
365369
}

0 commit comments

Comments
 (0)