Skip to content

Commit 65a5f98

Browse files
joeysbaseclaude
andcommitted
fix(codex): store command output whole in result_summary (+ code-review fixes)
The Codex agent truncated command output with `output[:100]` when building CommandTelemetry.result_summary, so result_tokens (derived from its length) under-reported every Bash result and skewed the cost model. Store the output whole and add lint rule CE043 to forbid re-introducing output truncation in agents/. Code-review fixes applied on top: - streaming/renderers.py: cap the Rich live-feed ToolEnd preview at _MAX_RESULT_LEN so a now-whole result_summary can't flood the console; the reported char count still shows the true full length (+ regression test). - models/telemetry.py: narrow the result_tokens docstring — the untruncated contract covers captured command stdout/stderr, not the intentionally-brief one-line summaries of non-command tool items (_summarize_tool_item). - experiments/default.yaml: clarify the max_turns:100 comment as a hard safety ceiling above the typical 3-18 range (task_timeout/turn_timeout are the practical guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e5c28f commit 65a5f98

9 files changed

Lines changed: 194 additions & 7 deletions

File tree

experiments/default.yaml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ defaults:
1818
# Per-row dataset multiplication: a 100-row task with max_usd: 0.10 permits up to
1919
# $10 cumulative spend.
2020
run_limits:
21-
# Maximum agent inner-loop turns per iteration (null = SDK default).
22-
# Typical range in tasks: 3-18 depending on complexity.
23-
max_turns: 20
21+
# Hard safety ceiling on agent inner-loop turns per iteration (null = SDK
22+
# default). Set well above the typical 3-18 turns a task needs so it only
23+
# trips on runaway loops; task_timeout / turn_timeout below are the practical
24+
# guards. Override per-task when a task legitimately needs more.
25+
max_turns: 100
2426
# Soft target: when cumulative SDK turns across all iterations of a task
2527
# exceed this, the orchestrator logs a one-shot warning and the report
2628
# surfaces a badge. Does NOT abort the run (max_turns is the hard cap).

src/coder_eval/agents/codex_agent.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,10 +2178,15 @@ def _extract_command_telemetry(self, command_item: Any, sequence: int) -> Comman
21782178
# Determine result status from exit code
21792179
result_status = "success" if exit_code == 0 else "error" if exit_code is not None else "unknown"
21802180

2181-
# Build result summary with output if available
2181+
# Build result summary with output if available. Store the output WHOLE:
2182+
# CommandTelemetry.result_summary is the untruncated tool-result body (its
2183+
# length drives CommandTelemetry.result_tokens), so truncating here would
2184+
# under-report tool-output size for every command (see CE043). The output is
2185+
# already bounded by the Codex harness's own exec-output truncation; any
2186+
# further trimming for display belongs in the renderers/reports, not capture.
21822187
summary_parts = [f"Exit code: {exit_code}" if exit_code is not None else "Command executed"]
21832188
if output and len(output.strip()) > 0:
2184-
summary_parts.append(f"Output: {output[:100]}")
2189+
summary_parts.append(f"Output: {output}")
21852190
result_summary = " | ".join(summary_parts)
21862191

21872192
# Try to parse output as JSON

src/coder_eval/models/telemetry.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,16 @@ def result_tokens(self) -> int:
410410
result size from prompt-cache growth (which is unavailable when caching is
411411
disabled). Approximate, not the API's exact tokenizer count, but
412412
deterministic and always present. 0 when the tool returned no content.
413+
414+
This measure is only meaningful while ``result_summary`` stays whole: an
415+
agent that truncates a command's output before recording it (as the Codex
416+
agent once did with ``output[:100]``) silently under-reports that command's
417+
result. Lint rule CE043 forbids truncating captured command output
418+
(stdout/stderr) in the agents, so the "untruncated" contract holds for
419+
command results across agents. (One-line summaries of non-command tool
420+
items — e.g. collab/MCP status lines built by ``_summarize_tool_item`` —
421+
are intentionally brief and out of scope.) Trim for DISPLAY in the
422+
renderers/reports instead.
413423
"""
414424
if not self.result_summary:
415425
return 0

src/coder_eval/streaming/renderers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,18 @@ def _format_event(self, event: StreamEvent) -> str | None:
104104
return f"[cyan]>>> TOOL: {escape(event.tool.tool_name)}[/cyan] | {params_str}"
105105

106106
if isinstance(event, ToolEndEvent):
107-
preview = escape(event.tool.result_summary or "")
107+
# result_summary is stored WHOLE (untruncated) at capture; cap it here
108+
# for the terse live feed so a large command output doesn't flood the
109+
# console. The reported char count reflects the true (full) length.
110+
full = event.tool.result_summary or ""
111+
preview = escape(_truncate(full, _MAX_RESULT_LEN))
108112
if event.status == ToolEndStatus.OK:
109113
tag = "[green]<<< OK[/green]"
110114
elif event.status == ToolEndStatus.UNRESOLVED:
111115
tag = "[yellow]<<< UNRESOLVED:[/yellow]"
112116
else:
113117
tag = f"[red]<<< {escape(event.status.value.upper())}:[/red]"
114-
return f"{tag} ({len(preview)} chars) {preview}"
118+
return f"{tag} ({len(full)} chars) {preview}"
115119

116120
if isinstance(event, TextChunkEvent):
117121
return f"[dim]{escape(event.text)}[/dim]"
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""CE043: Agents must not truncate a command's output when recording it.
2+
3+
``CommandTelemetry.result_summary`` is contractually the *untruncated* tool-result
4+
body, and its length drives the ``result_tokens`` computed field (the cost
5+
simulator's cache-independent measure of tool-output size). An agent that clips a
6+
command's captured output before storing it silently under-reports every tool
7+
result — exactly the bug the Codex agent shipped with (``f"Output: {output[:100]}"``),
8+
which pinned ~77% of its Bash results at ~31 tokens and skewed the cost model.
9+
10+
This rule flags, inside ``src/coder_eval/agents/``, a constant-upper-bound slice
11+
(``x[:N]``) applied to a value that denotes captured command output:
12+
13+
* a name whose id is/ends with ``output`` / ``stdout`` / ``stderr``
14+
(e.g. ``output[:100]``, ``aggregated_output[:512]``, ``proc_stdout[:80]``)
15+
* an attribute access ``.aggregated_output`` / ``.output`` / ``.stdout`` / ``.stderr``
16+
(e.g. ``command_item.aggregated_output[:100]``)
17+
18+
Store the output whole (it is already bounded by the harness's own exec-output
19+
truncation) and trim for DISPLAY in the renderers/reports instead.
20+
21+
Add ``# noqa: CE043`` on the offending line for a genuinely non-recorded use
22+
(e.g. slicing stdout only to build a short crash/log message that never becomes a
23+
``result_summary``), with a comment explaining why.
24+
"""
25+
26+
import ast
27+
28+
from tests.lint.rules.base import BaseRule
29+
30+
31+
_OUTPUT_NAMES = {"output", "stdout", "stderr", "aggregated_output"}
32+
33+
34+
def _denotes_output(value: ast.AST) -> str | None:
35+
"""Return the output-ish identifier being sliced, or None."""
36+
if isinstance(value, ast.Name):
37+
low = value.id.lower()
38+
if low in _OUTPUT_NAMES or low.endswith("_output") or low.endswith("_stdout") or low.endswith("_stderr"):
39+
return value.id
40+
elif isinstance(value, ast.Attribute):
41+
if value.attr.lower() in _OUTPUT_NAMES:
42+
return value.attr
43+
return None
44+
45+
46+
def _is_const_upper_slice(sl: ast.AST) -> bool:
47+
"""True for ``[:N]`` / ``[:N:...]`` with a constant int upper bound."""
48+
return (
49+
isinstance(sl, ast.Slice)
50+
and sl.lower is None
51+
and isinstance(sl.upper, ast.Constant)
52+
and isinstance(sl.upper.value, int)
53+
)
54+
55+
56+
class NoCommandOutputTruncation(BaseRule):
57+
id = "CE043"
58+
59+
def check(self, tree: ast.AST) -> list:
60+
# Scope to the agent implementations — only they record CommandTelemetry.
61+
if "/agents/" not in self.filepath.replace("\\", "/"):
62+
return []
63+
self.visit(tree)
64+
return self.violations
65+
66+
def visit_Subscript(self, node: ast.Subscript) -> None:
67+
name = _denotes_output(node.value)
68+
if name is not None and _is_const_upper_slice(node.slice):
69+
self.violation(
70+
node,
71+
f"Do not truncate command output ({name}[:...]); CommandTelemetry.result_summary "
72+
"must stay whole (it drives result_tokens). Store the full output and trim for "
73+
"display in the renderers/reports instead.",
74+
)
75+
self.generic_visit(node)

tests/lint/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from tests.lint.rules.ce023_no_proxy_shim_import import NoProxyShimImports
2222
from tests.lint.rules.ce024_discriminated_unions import DiscriminatedUnions
2323
from tests.lint.rules.ce032_criteria_path_seam import CriteriaPathSeam
24+
from tests.lint.rules.ce043_no_command_output_truncation import NoCommandOutputTruncation
2425
from tests.lint.rules.no_agent_timing_access import NoAgentTimingAccess
2526
from tests.lint.rules.no_blocking_io_in_async import NoBlockingIoInAsync
2627
from tests.lint.rules.no_cli_imports_in_core import NoCliImportsInCore
@@ -65,6 +66,7 @@
6566
NoProxyShimImports,
6667
DiscriminatedUnions,
6768
CriteriaPathSeam,
69+
NoCommandOutputTruncation,
6870
]
6971

7072
# Anti-shadow invariant (mirrors AgentRegistry / register_pricing): every CE rule

tests/test_codex_agent_unit.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,28 @@ def test_command_dispatch_mutates_lists_in_place(self):
104104
# A tool_use block was recorded into the open buffer (cut at the next
105105
# tokenUsage flush, not here), joinable to the command by tool_id.
106106
assert any(b.block_type == "tool_use" and b.tool_use_id == "c1" for b in state.open_blocks)
107+
108+
def test_command_output_recorded_whole_not_truncated(self):
109+
# Regression for the Codex `output[:100]` bug (CE043): result_summary must
110+
# carry the FULL command output so result_tokens reflects real tool-output
111+
# size instead of being pinned at a ~31-token, 100-char cap.
112+
agent = CodexAgent(parse_agent_config(type=AgentKind.CODEX, model="gpt-5-codex"))
113+
state, commands, _messages = self._state(agent)
114+
115+
big_output = "X" * 4000 # far beyond the old 100-char clip
116+
cmd_root = SimpleNamespace(
117+
type="commandExecution",
118+
id="c2",
119+
command="cat big.txt",
120+
exit_code=0,
121+
aggregated_output=big_output,
122+
duration_ms=5,
123+
)
124+
state.on_item_started(_item_notification("item/started", cmd_root))
125+
state.on_item_completed(_item_notification("item/completed", cmd_root))
126+
127+
cmd = commands[0]
128+
assert big_output in (cmd.result_summary or ""), "full output must be recorded, not truncated"
129+
# result_tokens (ceil(len/4)) must scale with the real output, not ~31.
130+
assert cmd.result_tokens >= len(big_output) // 4
131+
assert cmd.result_tokens > 100

tests/test_custom_lint.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,48 @@ def test_ignores_input_tokens_on_other_models(self):
6060
assert not self._run("ReconciliationMessage(input_tokens=-5)")
6161

6262

63+
@pytest.mark.lint
64+
class TestCE043NoCommandOutputTruncation:
65+
"""CE043 flags truncation of captured command output inside agents/, only."""
66+
67+
@staticmethod
68+
def _run(src: str, *, in_agents: bool = True):
69+
import ast
70+
71+
from tests.lint.rules.ce043_no_command_output_truncation import NoCommandOutputTruncation
72+
73+
path = "src/coder_eval/agents/codex_agent.py" if in_agents else "src/coder_eval/reports_html.py"
74+
return NoCommandOutputTruncation(path).check(ast.parse(src))
75+
76+
@pytest.mark.parametrize(
77+
"expr",
78+
[
79+
"output[:100]",
80+
"aggregated_output[:512]",
81+
"command_item.aggregated_output[:100]",
82+
"proc_stdout[:80]",
83+
"result.stderr[:200]",
84+
'f"Output: {output[:100]}"',
85+
],
86+
)
87+
def test_flags_output_truncation_in_agents(self, expr: str):
88+
assert self._run(f"x = {expr}"), f"expected CE043 to flag {expr!r}"
89+
90+
def test_allows_untruncated_output(self):
91+
assert not self._run('summary = f"Output: {output}"')
92+
assert not self._run("summary = aggregated_output")
93+
94+
def test_ignores_non_output_slices(self):
95+
# Legit error/orchestration summaries that are NOT captured command output.
96+
assert not self._run("detail = summary.result[:200]")
97+
assert not self._run("msg = content_str[:200]")
98+
assert not self._run("s = '; '.join(messages)[:200]")
99+
100+
def test_scoped_to_agents_only(self):
101+
# The same pattern outside agents/ is not this rule's concern (display code).
102+
assert not self._run("preview = output[:100]", in_agents=False)
103+
104+
63105
@pytest.mark.lint
64106
class TestCE017ModelsLazyAgentImports:
65107
"""CE017 flags only module-level agents/plugins imports inside models/."""

tests/test_streaming_renderers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ def test_tool_end_renders_success():
7878
assert "OK" in output or "ok" in output.lower()
7979

8080

81+
def test_tool_end_caps_long_result_summary_but_reports_full_length():
82+
"""A large (untruncated-at-capture) result_summary is capped for the terse
83+
live feed, while the reported char count still reflects the full length."""
84+
from coder_eval.streaming.renderers import _MAX_RESULT_LEN
85+
86+
renderer, buf = _make_renderer()
87+
big = "Z" * 5000 # far beyond the display cap
88+
renderer.on_event(
89+
ToolEndEvent(
90+
task_id="t1",
91+
tool=_tool(tool_name="Bash", result_summary=big),
92+
status=ToolEndStatus.OK,
93+
)
94+
)
95+
output = buf.getvalue()
96+
# true length is reported...
97+
assert "(5000 chars)" in output
98+
# ...but the console body is capped (not the full 5000 Z's dumped).
99+
assert output.count("Z") <= _MAX_RESULT_LEN
100+
assert "..." in output
101+
102+
81103
def test_tool_end_renders_error():
82104
"""ToolEndEvent renders ERROR for failed results."""
83105
renderer, buf = _make_renderer()

0 commit comments

Comments
 (0)