From db6d685522afb0211203cd0e3334237254524862 Mon Sep 17 00:00:00 2001 From: James Wiesebron Date: Fri, 29 May 2026 16:42:27 -0700 Subject: [PATCH 1/5] Fix #2884: raise SDK reader buffer; reframe per-tool caps as cost discipline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2804 buffer-overflow crash recurs on a routine Edit to a large file (e.g. orchestrator/routes/pipelines.py, 1.1 MB / 25k lines) despite #2876's Read/Grep cap. Root cause (corrected from the issue's first framing): the overflowing message is *not* model-bound. Claude Code attaches the entire original file to every Edit/Write result as transcript metadata (toolUseResult.originalFile) that the model never sees — only egg's stream-json reader decodes it. A ~2 KB edit to a 1.1 MB file therefore emits a >1 MB stream message and crashes the reader, while the model's tool_result is just the bounded fBB cat -n snippet. A per-call input/output cap can't see or police that metadata, so the right lever is the reader buffer itself. - client.py: raise ClaudeAgentOptions.max_buffer_size above the 1 MiB SDK default (32 MiB, tunable via EGG_SDK_MAX_BUFFER_BYTES). Costs no model context/tokens (the field never reaches the model; egg logs <=2 KB of any result) — only transient reader memory. Model-bound result sizes stay bounded independently by the predictive caps, the MCP @tool caps (#2805), and CC's own Bash truncation, so a larger reader buffer can't leak oversized payloads to the model. #2810's fail-fast remains the clean backstop above the raised buffer. - Reframe the now-misleading rationale (the de-misleading half of this change): tool_output_cap.py docstring, the client.py predictive-cap + overflow-marker comments, and docs/reference/agent-recovery.md now state plainly that the per-tool Read/Grep caps are model-context/cost discipline, NOT the crash fix — the raised reader buffer is. No revert of #2805/#2876/#2810: their logic has independent context/cost value and #2810 is the backstop. - Tests: max_buffer_size wiring + EGG_SDK_MAX_BUFFER_BYTES resolver (default/override/invalid); mock ClaudeAgentOptions gains max_buffer_size. --- docs/reference/agent-recovery.md | 18 ++++-- shared/egg_agent/client.py | 84 ++++++++++++++++++++++----- shared/egg_agent/tool_output_cap.py | 41 ++++++------- tests/shared/egg_agent/test_client.py | 41 ++++++++++++- 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/docs/reference/agent-recovery.md b/docs/reference/agent-recovery.md index 6cfec60d0d..8a73c69426 100644 --- a/docs/reference/agent-recovery.md +++ b/docs/reference/agent-recovery.md @@ -131,15 +131,25 @@ Agent crashed on Claude Agent SDK buffer overflow (issue #2804). Deterministic f The agent output is captured by piping stdout and stderr through `tee` into a temporary log file (`AGENT_OUTPUT_LOG`, created via `mktemp`). This log is truncated at the start of each agent run so old crash signatures don't bleed into subsequent runs. -> **Note:** The buffer-overflow marker string is synchronized between the wrapper's `grep` and the `_BUFFER_OVERFLOW_MARKER` constant in `shared/egg_agent/client.py`. If a future `claude-agent-sdk` release changes the wording, the wrapper silently falls back to burning the transient-crash retry budget. See [#2823](https://github.com/jwbron/egg/issues/2823) for the follow-up to pin this against the installed SDK. The real fix is tool-layer truncation of oversized payloads ([#2805](https://github.com/jwbron/egg/issues/2805)); this is the fail-fast path until that lands. +> **Note:** The buffer-overflow marker string is synchronized between the wrapper's `grep` and the `_BUFFER_OVERFLOW_MARKER` constant in `shared/egg_agent/client.py`. If a future `claude-agent-sdk` release changes the wording, the wrapper silently falls back to burning the transient-crash retry budget. See [#2823](https://github.com/jwbron/egg/issues/2823) for the follow-up to pin this against the installed SDK. With the reader buffer raised (next section, [#2884](https://github.com/jwbron/egg/issues/2884)) this fail-fast is now a rare backstop — it fires only if a single stream message exceeds the generous raised buffer — not the common path it was when the cap was 1 MiB. + +### SDK Reader Buffer (the crash-prevention layer) + +Source: `_DEFAULT_SDK_MAX_BUFFER_BYTES` / `_sdk_max_buffer_bytes()` in `shared/egg_agent/client.py`, wired as `ClaudeAgentOptions.max_buffer_size`. + +This is what actually prevents the [#2804](https://github.com/jwbron/egg/issues/2804) crash. egg's Agent SDK reader decodes the CLI's stream-json output into a JSON buffer; a single message larger than `max_buffer_size` raises `CLIJSONDecodeError` and kills the agent (exit 255). The SDK default is 1 MiB. + +The crucial point ([#2884](https://github.com/jwbron/egg/issues/2884)): **the messages that overflow are not model-bound.** Claude Code attaches the **entire original file** to every `Edit`/`Write` result as transcript metadata (`toolUseResult.originalFile`) that the model never sees — only egg's reader decodes it. So a routine ~2 KB edit to the 1.1 MB, 25k-line `orchestrator/routes/pipelines.py` emits a >1 MB stream message and crashes the reader, even though the model's `tool_result` is just a bounded `cat -n` snippet. (The original #2884 framing guessed the *edit snippet* scaled with file size; the CLI's `fBB` snippet builder bounds it to `new_string` lines + 8, so the culprit is the result *envelope* metadata, not the snippet.) + +egg raises `max_buffer_size` to **32 MiB** (default; override with `EGG_SDK_MAX_BUFFER_BYTES`). This costs **no model context or tokens** — the oversized field never reaches the model, and egg logs at most `_MAX_TOOL_CONTENT_LOG_LEN` of any result — only transient reader memory for one message. It cannot let an oversized payload reach the model either: model-bound result sizes are bounded independently (the predictive caps below, the MCP `@tool` caps [#2805](https://github.com/jwbron/egg/issues/2805), and Claude Code's own Bash truncation). 32 MiB covers source files far larger than anything in this repo while still bounding a runaway/malformed stream; the fail-fast above is the clean backstop for anything beyond it. ### Predictive Output Cap (PreToolUse) Source: `shared/egg_agent/tool_output_cap.py`, wired in `shared/egg_agent/client.py`. -The fail-fast above is a backstop, not prevention. egg caps its own MCP `@tool` payloads at the tool boundary ([#2805](https://github.com/jwbron/egg/issues/2805)), but **built-in** Claude Code tools (`Read`, `Grep`, `Edit`, `Bash`) run inside the CLI and can't be wrapped. [#2876](https://github.com/jwbron/egg/issues/2876) bounds those via a **PreToolUse** hook that fires *before* the tool runs and denies calls whose result is likely to overflow — for example a whole-file `Read` of the 1.1 MB, 24k-line `orchestrator/routes/pipelines.py` that crashed the [#2777](https://github.com/jwbron/egg/issues/2777) slice-1 coder. PostToolUse can't help here: the payload has already crossed the channel that crashes the reader ([#2810](https://github.com/jwbron/egg/issues/2810) dropped that approach). +These caps are **model-context/cost discipline, not crash prevention** (the reader buffer above is the crash fix). What they bound is the volume a tool sends *to the model*: a whole-file `Read` returns the file's content to the model (the 1.1 MB `pipelines.py` ≈ ~275k tokens), and a whole-repo content `Grep` dumps every matching line — both wasteful. **Built-in** Claude Code tools (`Read`, `Grep`, `Bash`) run inside the CLI and can't be wrapped the way egg caps its own MCP `@tool` payloads ([#2805](https://github.com/jwbron/egg/issues/2805)), so [#2876](https://github.com/jwbron/egg/issues/2876) bounds them via a **PreToolUse** hook that fires *before* the tool runs and denies calls whose model-bound result is likely to be excessive, telling the agent how to narrow the call. (`Edit`/`Write` are deliberately *not* capped here: their model-bound result is the small snippet, and their crash vector was the reader-buffer metadata, fixed above — not anything a per-call cap could see.) -Current heuristics — predictive, so expect some false positives/negatives, with the fail-fast as the backstop when a prediction misses: +Current heuristics — predictive, so expect some false positives/negatives: | Tool | Denied when | Deny reason points at | |------|-------------|------------------------| @@ -148,7 +158,7 @@ Current heuristics — predictive, so expect some false positives/negatives, wit | `Read` (image/notebook) | Target image/notebook > `EGG_READ_CAP_BYTES` (returned whole; `offset`/`limit`/`pages` don't bound it) | images: avoid reading whole, use Bash (`file`/`stat`) for metadata; notebooks: inspect cells with `jq` (e.g. `jq '.cells[].source'`) | | `Grep` | `output_mode=content`, no `head_limit`, **and** no `path`/`glob` scope (whole-repo content dump) | `head_limit`, a `path`/`glob` scope, or `output_mode=files_with_matches` | -The hook is **always-on** (the overflow hits every route, including first-party Opus). Set `EGG_TOOL_OUTPUT_CAP=false` (or `0`/`no`/`off`) to disable; set `EGG_READ_CAP_BYTES` to tune the `Read` threshold (a set-but-invalid value — non-integer or non-positive — is logged and ignored in favour of the default). +The hook is **always-on** (excess model-bound output is wasteful on every route, including first-party Opus). Set `EGG_TOOL_OUTPUT_CAP=false` (or `0`/`no`/`off`) to disable; set `EGG_READ_CAP_BYTES` to tune the `Read` threshold (a set-but-invalid value — non-integer or non-positive — is logged and ignored in favour of the default). ### Transient Exit Codes diff --git a/shared/egg_agent/client.py b/shared/egg_agent/client.py index a2cf53f84e..bd968ac534 100644 --- a/shared/egg_agent/client.py +++ b/shared/egg_agent/client.py @@ -68,15 +68,67 @@ def error(self, msg: str, **kwargs: Any) -> None: DEFAULT_MODEL = "opus[1m]" # Substring of the SDK's CLIJSONDecodeError message identifying the -# 1 MB JSON message-reader buffer overflow (issue #2804). The overflow +# JSON message-reader buffer overflow (issue #2804). The overflow # is deterministic — the same tool call against the same codebase # produces the same oversized payload — so the consensus-wrapper greps # for this marker on agent exit to short-circuit retry instead of -# burning the restart budget on a doomed re-run. The real fix is -# tool-layer truncation (see #2805); this PR only makes the failure -# mode observable and terminal. +# burning the restart budget on a doomed re-run. With the reader buffer +# raised below (#2884) this is now a rare backstop, not the common path: +# it fires only if a single stream message exceeds the (generous) raised +# buffer. See #2823 for the follow-up to pin this marker against the SDK. _BUFFER_OVERFLOW_MARKER = "exceeded maximum buffer size" +# Cap on a single message in egg's Agent SDK stream-json reader (issue #2884). +# +# The SDK reads the CLI's stdout stream into a JSON buffer; a single message +# larger than this raises CLIJSONDecodeError and kills the agent (exit 255, +# #2804). The SDK default is 1 MiB — but the messages that overflow are *not* +# model-bound. Claude Code attaches the **entire original file** to every +# Edit/Write result as transcript metadata (`toolUseResult.originalFile`) that +# the model never sees; only egg's reader decodes it. So a routine ~2 KB edit +# to the 1.1 MB / 25k-line orchestrator/routes/pipelines.py emits a >1 MB stream +# message and crashes the reader, even though the model's tool_result is just a +# bounded snippet (the #2884 crash, mis-attributed to a large edit at first). +# +# Raising the reader buffer lets egg ingest that metadata-heavy message. It does +# NOT cost model context or tokens (the field never reaches the model, and egg +# logs at most _MAX_TOOL_CONTENT_LOG_LEN of any result) — the only cost is +# transient reader memory for one message. Model-bound result sizes are bounded +# separately and independently (egg MCP @tool caps #2805; Read/Grep predictive +# caps #2876; Claude Code truncates Bash), so a large reader buffer cannot let an +# oversized payload reach the model. 32 MiB covers source files far larger than +# anything in this repo while still bounding a runaway/malformed stream; the +# #2810 fail-fast remains the clean backstop above it. Override with +# EGG_SDK_MAX_BUFFER_BYTES. +_DEFAULT_SDK_MAX_BUFFER_BYTES = 32 * 1024 * 1024 + + +def _sdk_max_buffer_bytes() -> int: + """Resolve the Agent SDK reader buffer cap from ``EGG_SDK_MAX_BUFFER_BYTES``. + + A set-but-invalid value (non-integer or non-positive) is logged and ignored + in favour of the default, so an operator typo can't silently re-expose the + 1 MiB-overflow crash. The unset case is silent (the default is expected). + """ + raw = os.environ.get("EGG_SDK_MAX_BUFFER_BYTES", "").strip() + if not raw: + return _DEFAULT_SDK_MAX_BUFFER_BYTES + try: + value = int(raw) + except ValueError: + logger.warning( + f"EGG_SDK_MAX_BUFFER_BYTES={raw!r} is not an integer; using the " + f"default {_DEFAULT_SDK_MAX_BUFFER_BYTES} bytes" + ) + return _DEFAULT_SDK_MAX_BUFFER_BYTES + if value <= 0: + logger.warning( + f"EGG_SDK_MAX_BUFFER_BYTES={raw!r} must be a positive integer; using " + f"the default {_DEFAULT_SDK_MAX_BUFFER_BYTES} bytes" + ) + return _DEFAULT_SDK_MAX_BUFFER_BYTES + return value + async def run_agent_async( prompt: str, @@ -206,6 +258,11 @@ async def _check_tool_permission( setting_sources=["project", "user"], disallowed_tools=disallowed, can_use_tool=tool_permission_callback, + # Raise the stream-json reader buffer above the 1 MiB default so a + # metadata-heavy Edit/Write result (CC attaches the whole original file + # as non-model-bound transcript metadata) doesn't crash the reader on + # large files. See _DEFAULT_SDK_MAX_BUFFER_BYTES above (#2884). + max_buffer_size=_sdk_max_buffer_bytes(), ) if max_turns is not None: options.max_turns = max_turns @@ -276,15 +333,16 @@ async def _check_tool_permission( ) # --- Predictive output cap for built-in CC tools (#2876) --- - # Built-in tools (Read/Grep/...) run inside the CLI; egg can't wrap their - # output the way it caps its own MCP @tool payloads (#2805). A result above - # the Agent SDK's 1 MB JSON buffer kills the agent with exit 255 (#2804) — - # the case that crashed the #2777 slice-1 coder on the 1.1 MB, 24k-line - # orchestrator/routes/pipelines.py. A PreToolUse hook can't see the result, - # but it can predict the overflow from the inputs and deny *before* the tool - # runs, telling the agent how to narrow the call. #2810's fail-fast is the - # backstop when a prediction misses. Always-on (the overflow hits every - # route, including first-party Opus); disable via EGG_TOOL_OUTPUT_CAP=false. + # This is model-context/cost discipline, NOT the buffer-crash fix (that is + # max_buffer_size above, #2884). A whole-file Read returns the file content + # *to the model* (the 1.1 MB pipelines.py ≈ ~275k tokens), and a whole-repo + # content Grep dumps matches to the model — both wasteful. Built-in tools run + # inside the CLI and can't be wrapped the way egg caps its own MCP @tool + # payloads (#2805), so a PreToolUse hook predicts the volume from the inputs + # and denies *before* the tool runs, telling the agent how to narrow the call + # (offset/limit, head_limit, files_with_matches). Capping model-bound output + # here also keeps the reader buffer from having to absorb it. Always-on; + # disable via EGG_TOOL_OUTPUT_CAP=false. from egg_agent.tool_output_cap import ( check_builtin_tool_output_risk, is_output_cap_disabled, diff --git a/shared/egg_agent/tool_output_cap.py b/shared/egg_agent/tool_output_cap.py index aa2b3d5514..3cdd4afb23 100644 --- a/shared/egg_agent/tool_output_cap.py +++ b/shared/egg_agent/tool_output_cap.py @@ -1,25 +1,26 @@ """Predictive PreToolUse caps for built-in Claude Code tools (issue #2876). -Built-in tools (``Read``, ``Grep``, ``Edit``, ``Write``, ``Bash``) run -inside the Claude Code CLI; egg cannot wrap their output the way it caps -its own MCP ``@tool`` payloads (#2805). A tool result that exceeds the -Agent SDK's 1 MB JSON message buffer kills the agent with exit 255 -(#2804); #2810 made that a clean fail-fast but does **not** prevent it. - -This module supplies *predictive* heuristics for a PreToolUse hook: the -hook fires **before** the tool runs and denies calls whose result is -likely to overflow, returning a reason that tells the agent exactly how -to narrow the call (``offset``/``limit``/``head_limit``/ -``files_with_matches``). Because the hook fires before execution it -cannot see the result, so the heuristics are necessarily approximate -(false positives/negatives are expected); #2810's fail-fast remains the -backstop when a prediction misses. - -The load-bearing case is ``Read`` of a very large source file — e.g. the -24k-line ``orchestrator/routes/pipelines.py`` (~1.1 MB) that crashed the -#2777 slice-1 coder. Reading it whole produces a tool result larger than -the 1 MB buffer; redirecting the agent to ``offset``/``limit`` keeps each -page bounded. +These caps are **model-context/cost discipline, not the buffer-crash fix.** +The Agent SDK reader's buffer-overflow crash (#2804/#2884) is prevented by +raising ``max_buffer_size`` in ``client.py`` — see the note there: the +messages that overflow the reader are dominated by *non-model-bound* transcript +metadata (Claude Code attaches the whole original file to every Edit/Write +result), which a per-tool input/output cap cannot and should not police. + +What this module *does* police is the volume a tool sends **to the model**. +A whole-file ``Read`` returns the file's content to the model (the ~1.1 MB, +24k-line ``orchestrator/routes/pipelines.py`` ≈ ~275k tokens), and a whole-repo +content ``Grep`` dumps every matching line to the model — both wasteful of +context and cost. Built-in tools (``Read``, ``Grep``, ``Bash``) run inside the +Claude Code CLI; egg cannot wrap their output the way it caps its own MCP +``@tool`` payloads (#2805), so a PreToolUse hook fires **before** the tool runs +and denies calls whose model-bound result is likely to be excessive, returning a +reason that tells the agent how to narrow the call (``offset``/``limit``/ +``head_limit``/``files_with_matches``). Because the hook fires before execution +it cannot see the result, so the heuristics are necessarily approximate (false +positives/negatives are expected). Keeping model-bound output small here also +spares the reader buffer from having to absorb it; the raised buffer plus +#2810's fail-fast cover the crash path independently. """ from __future__ import annotations diff --git a/tests/shared/egg_agent/test_client.py b/tests/shared/egg_agent/test_client.py index a7f0675dc2..3002aa9466 100644 --- a/tests/shared/egg_agent/test_client.py +++ b/tests/shared/egg_agent/test_client.py @@ -11,7 +11,9 @@ import pytest from egg_agent.client import ( _BUFFER_OVERFLOW_MARKER, + _DEFAULT_SDK_MAX_BUFFER_BYTES, _MAX_TOOL_CONTENT_LOG_LEN, + _sdk_max_buffer_bytes, _truncate, run_agent, run_agent_async, @@ -106,6 +108,7 @@ class ClaudeAgentOptions: # type: ignore[no-redef] setting_sources: list[str] | None = None disallowed_tools: list[str] = field(default_factory=list) can_use_tool: Any = None + max_buffer_size: int | None = None @dataclass class PermissionResultAllow: # type: ignore[no-redef] @@ -1070,11 +1073,47 @@ def test_grep_hook_denies_unbounded_content_grep(self, mock_query): assert "head_limit" in decision["permissionDecisionReason"] +class TestSdkReaderBuffer: + """Issue #2884: egg raises the Agent SDK stream-json reader's buffer above + the 1 MiB default so a metadata-heavy Edit/Write result (CC attaches the + whole original file as non-model-bound transcript metadata) doesn't crash + the reader on large files. Tunable via EGG_SDK_MAX_BUFFER_BYTES. + """ + + @patch.dict(os.environ, {"EGG_MCP_TOOLS": "false"}, clear=False) + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) + def test_max_buffer_size_wired_by_default(self, mock_query): + os.environ.pop("EGG_SDK_MAX_BUFFER_BYTES", None) + result = _run_async(run_agent_async("test prompt")) + assert result.success is True + opts = mock_query.call_args.kwargs["options"] + assert opts.max_buffer_size == _DEFAULT_SDK_MAX_BUFFER_BYTES + # The default must clear the 1 MiB SDK default that crashes on #2884. + assert opts.max_buffer_size > 1024 * 1024 + + @patch.dict(os.environ, {"EGG_MCP_TOOLS": "false", "EGG_SDK_MAX_BUFFER_BYTES": "8388608"}) + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) + def test_max_buffer_size_configurable_via_env(self, mock_query): + _run_async(run_agent_async("test prompt")) + opts = mock_query.call_args.kwargs["options"] + assert opts.max_buffer_size == 8 * 1024 * 1024 + + def test_env_resolver_rejects_invalid_values(self): + for bad in ("not-a-number", "0", "-5", "2mb"): + with patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": bad}): + assert _sdk_max_buffer_bytes() == _DEFAULT_SDK_MAX_BUFFER_BYTES + + def test_env_resolver_accepts_valid_override(self): + with patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": "16777216"}): + assert _sdk_max_buffer_bytes() == 16 * 1024 * 1024 + + class TestBufferOverflowErrorHandling: """Issue #2804: when the SDK raises CLIJSONDecodeError on a buffer overflow, the agent must return a structured failure with the overflow marker preserved in ``error`` — the consensus-wrapper - greps for that string to short-circuit retry. + greps for that string to short-circuit retry. With the reader buffer + raised (#2884) this is now a rare backstop, but must still be clean. """ @patch("claude_agent_sdk.query") From bbd7c9c256be4ee758f120a673702c83b6cc0c01 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:14:34 +0000 Subject: [PATCH 2/5] Address review feedback: finish reframing and harden env resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the Read/Grep deny strings, _DEFAULT_READ_CAP_BYTES rationale, shared/egg_tool_output.py docstring/notes, consensus_wrapper comments, and the agent-recovery / STRUCTURE / agent-tools doc references so the "per-tool caps are model-context/cost discipline, not the crash fix" framing is consistent everywhere the reader will land — not just in the new tool_output_cap.py docstring. In shared/egg_agent/client.py: - _sdk_max_buffer_bytes warnings now dedup per distinct raw value (mirroring tool_output_cap._warn_invalid_cap) so a steady misconfig doesn't spam on every agent spawn. - Add a 1 GiB hard upper bound on EGG_SDK_MAX_BUFFER_BYTES; an operator typo like a stray suffix-conversion (34359738368000 ≈ 34 TiB) would otherwise leave the reader effectively unbounded and could OOM the container on a runaway stream. In tests/shared/egg_agent/test_client.py: - test_env_resolver_rejects_invalid_values now patches the logger and asserts a warning fires (the docstring promises "logged and ignored" — a regression that dropped the warning was previously silently OK). - Add coverage for the new upper-bound clamp and the per-value dedup behaviour. Authored-by: egg --- docs/development/STRUCTURE.md | 2 +- docs/reference/agent-recovery.md | 2 +- docs/reference/agent-tools.md | 27 +++++++++------ orchestrator/consensus_wrapper.py | 28 +++++++++------ shared/egg_agent/client.py | 49 +++++++++++++++++++++------ shared/egg_agent/tool_output_cap.py | 32 ++++++++++------- shared/egg_tool_output.py | 40 ++++++++++++++-------- tests/shared/egg_agent/test_client.py | 49 ++++++++++++++++++++++++++- 8 files changed, 167 insertions(+), 62 deletions(-) diff --git a/docs/development/STRUCTURE.md b/docs/development/STRUCTURE.md index 4d26b9c259..77e5844a5c 100644 --- a/docs/development/STRUCTURE.md +++ b/docs/development/STRUCTURE.md @@ -270,7 +270,7 @@ shared/ │ ├── command.py # build_agent_command() for orchestrator-spawned containers │ ├── result.py # AgentResult dataclass │ ├── tool_interceptor.py # Pre-execution file write checks (Write/Edit/NotebookEdit) against role restrictions -│ └── tool_output_cap.py # Predictive PreToolUse cap for built-in CC tools (Read/Grep): denies calls likely to overflow the 1 MB SDK buffer before they run; tunable via EGG_TOOL_OUTPUT_CAP / EGG_READ_CAP_BYTES (#2876) +│ └── tool_output_cap.py # Predictive PreToolUse cap for built-in CC tools (Read/Grep): denies calls whose model-bound result is likely to be excessive (cost/context discipline, NOT the buffer-crash fix — that's the raised reader buffer in client.py, #2884); tunable via EGG_TOOL_OUTPUT_CAP / EGG_READ_CAP_BYTES (#2876) ├── egg_anchor/ # Agent anchor mechanism for post-compaction state recovery │ ├── __init__.py # Public API exports │ ├── models.py # Pydantic models (AgentAnchor, AnchorMeta, ProgressItem, Decision, BRCState) diff --git a/docs/reference/agent-recovery.md b/docs/reference/agent-recovery.md index 8a73c69426..84dd42f946 100644 --- a/docs/reference/agent-recovery.md +++ b/docs/reference/agent-recovery.md @@ -123,7 +123,7 @@ In concurrent (BRC) mode, all agents are wrapped with a shell script that handle The `is_buffer_overflow()` function is checked **before** `is_transient_crash()`. It greps the captured agent output log for the Claude Agent SDK's `CLIJSONDecodeError` marker (`"exceeded maximum buffer size"`) and, when found, exits the wrapper immediately without consuming any restart budget. -The SDK has a 1 MB JSON message-reader buffer cap; a tool result that exceeds it kills the agent with exit 255. This failure is **deterministic** — re-running the agent against the same codebase produces the same oversized payload and hits the same crash. Retrying is therefore wasteful. The wrapper logs: +The upstream Claude Agent SDK ships a 1 MiB JSON message-reader buffer; egg raises it to 32 MiB on this path (see the next section, [#2884](https://github.com/jwbron/egg/issues/2884)), so the cap that's actually in effect is much higher than the SDK default. A tool result that exceeds *the configured cap* — whatever it is — kills the agent with exit 255. This failure is **deterministic** — re-running the agent against the same codebase produces the same oversized payload and hits the same crash. Retrying is therefore wasteful. The wrapper logs: ``` Agent crashed on Claude Agent SDK buffer overflow (issue #2804). Deterministic failure; retry budget would be wasted. NOT restarting. diff --git a/docs/reference/agent-tools.md b/docs/reference/agent-tools.md index f2f1be4642..cf18c25097 100644 --- a/docs/reference/agent-tools.md +++ b/docs/reference/agent-tools.md @@ -214,23 +214,30 @@ round-trip. ### Output-size cap (`EGG_TOOL_OUTPUT_CAP_BYTES`, #2805) -Every egg-owned tool result is bounded before it crosses the Claude -Agent SDK's hard 1 MB JSON reader buffer — an oversized result kills the -agent with exit 255 (#2804). The cap is applied at two chokepoints: the +Every egg-owned tool result is bounded as **model-context/cost +discipline** before it crosses the Claude Agent SDK reader — a runaway +result would otherwise dump tens of thousands of tokens to the model in +a single tool call. The cap is applied at two chokepoints: the orchestrator MCP server (`handle_tool_call` → `cap_result_dict`) and every sandbox `@tool` wrapper (`invoke_handler` → `cap_text`), both via -the shared `shared/egg_tool_output.py` helper. +the shared `shared/egg_tool_output.py` helper. The cap is **not** the +crash-prevention layer for the SDK reader (the upstream 1 MiB buffer was +the original concern, but egg raises that to 32 MiB at +`ClaudeAgentOptions.max_buffer_size`, #2884 — see +[Agent Recovery → SDK Reader Buffer](agent-recovery.md#sdk-reader-buffer-the-crash-prevention-layer)). | Variable | Default | Effect | |----------|---------|--------| | `EGG_TOOL_OUTPUT_CAP_BYTES` | `102400` (100 KB) | Max serialized size of a single tool result. Output above the cap is replaced with a structured head-preview marker (`_egg_truncated`) that names how to narrow the call, or — for unpaginated content like a full checkpoint transcript — spilled to a temp file (`_egg_output_spilled`) the agent can `Read`/`grep`, with a small inline preview. | -The default leaves ~10× headroom under the 1 MB buffer. A non-positive -or non-integer value is **ignored with a logged warning** (the operator -is not left believing a cap is in effect when it isn't); the helper -falls back to the 100 KB default. The orchestrator measures the cap -against `indent=2`-serialized JSON (matching what its MCP server ships), -so raising the cap toward the buffer size stays safe. +At ~4 B/token for prose/JSON, the 100 KB default ≈ ~25k tokens — a +sensible upper bound for a single model-bound tool result. A +non-positive or non-integer value is **ignored with a logged warning** +(the operator is not left believing a cap is in effect when it isn't); +the helper falls back to the 100 KB default. The orchestrator measures +the cap against `indent=2`-serialized JSON (matching what its MCP server +ships), so raising the cap stays safe against the reader buffer above it +either way. **Built-in tool cap (complementary):** The cap above covers egg-owned MCP `@tool` payloads. Built-in Claude Code tools (`Read`, `Grep`, etc.) run diff --git a/orchestrator/consensus_wrapper.py b/orchestrator/consensus_wrapper.py index b6240e74c4..58a508c4d7 100644 --- a/orchestrator/consensus_wrapper.py +++ b/orchestrator/consensus_wrapper.py @@ -126,9 +126,12 @@ # Capture agent stdout+stderr so the wrapper can post-mortem the run. # Used by is_buffer_overflow() to detect the Claude Agent SDK -# message-reader 1MB JSON buffer crash (issue #2804) which is -# deterministic — retrying just hits the same overflow and burns -# the restart budget for no gain. +# message-reader JSON buffer crash (issue #2804) which is deterministic — +# retrying just hits the same overflow and burns the restart budget for +# no gain. With the reader buffer raised to 32 MiB on the egg path (#2884, +# see shared/egg_agent/client.py::_DEFAULT_SDK_MAX_BUFFER_BYTES) this is +# a rare backstop rather than the common path it was at the 1 MiB SDK +# default, but the wrapper still has to fail-fast when it does fire. # # Use ``mktemp`` for the default path so a co-tenant on the same host # cannot pre-create a symlink at a predictable ``/tmp/agent-output-$$`` @@ -179,12 +182,11 @@ return ${{PIPESTATUS[0]}} }} -# Detect the Claude Agent SDK 1 MB JSON message-reader overflow -# signature in the most recent agent run. Issue #2804. The overflow -# is deterministic: re-running the agent against the same codebase -# hits the same oversized tool result, so the wrapper must NOT -# consume retry budget on this failure class. Returns 0 (true) if -# the marker was logged, 1 otherwise. +# Detect the Claude Agent SDK JSON message-reader overflow signature in +# the most recent agent run. Issue #2804. The overflow is deterministic: +# re-running the agent against the same codebase hits the same oversized +# tool result, so the wrapper must NOT consume retry budget on this +# failure class. Returns 0 (true) if the marker was logged, 1 otherwise. # # The substring matches CLI output from claude_agent_sdk emitted on # the buffer overflow path. If a future SDK bump changes the @@ -194,8 +196,12 @@ # test_script_marker_matches_client_constant and the # test_buffer_overflow_*_aborts_without_retry pair) exercise the # wrapper against a synthetic log to keep this honest, but do not -# pin against the installed SDK. The real fix is tool-layer -# truncation (#2805); this is the fail-fast path until that lands. +# pin against the installed SDK. The real fix for the overflow class +# is the raised reader buffer (#2884, see +# shared/egg_agent/client.py::_DEFAULT_SDK_MAX_BUFFER_BYTES = 32 MiB); +# this fail-fast is the clean backstop for anything beyond it. The +# per-tool MCP @tool caps (#2805) and Read/Grep predictive caps (#2876) +# are independent model-context/cost discipline — not the crash fix. is_buffer_overflow() {{ [ -f "$AGENT_OUTPUT_LOG" ] || return 1 grep -q "exceeded maximum buffer size" "$AGENT_OUTPUT_LOG" 2>/dev/null diff --git a/shared/egg_agent/client.py b/shared/egg_agent/client.py index bd968ac534..009211d899 100644 --- a/shared/egg_agent/client.py +++ b/shared/egg_agent/client.py @@ -102,13 +102,39 @@ def error(self, msg: str, **kwargs: Any) -> None: # EGG_SDK_MAX_BUFFER_BYTES. _DEFAULT_SDK_MAX_BUFFER_BYTES = 32 * 1024 * 1024 +# Hard upper bound on EGG_SDK_MAX_BUFFER_BYTES. Defends against an operator +# typo (e.g. a stray suffix-conversion like ``34359738368000`` ≈ 34 TiB) that +# would otherwise leave the reader effectively unbounded — a runaway or +# malformed stream could then OOM the container before the SDK rejected it. +# 1 GiB is several orders of magnitude over anything a real source file or +# transcript metadata payload could legitimately produce, while still bounding +# the worst-case allocation. +_MAX_SDK_MAX_BUFFER_BYTES = 1024 * 1024 * 1024 + +# Raw EGG_SDK_MAX_BUFFER_BYTES values we've already warned about, so a steady +# bad value warns once per distinct raw value rather than on every +# ``run_agent_async`` invocation. Mirrors ``tool_output_cap._warned_cap_values`` +# (#2884 review feedback). +_warned_sdk_buffer_values: set[str] = set() + + +def _warn_invalid_sdk_buffer(raw: str, problem: str, fallback: int) -> None: + """Warn that an invalid EGG_SDK_MAX_BUFFER_BYTES is being clamped, once per value.""" + if raw in _warned_sdk_buffer_values: + return + _warned_sdk_buffer_values.add(raw) + logger.warning(f"EGG_SDK_MAX_BUFFER_BYTES={raw!r} {problem}; using {fallback} bytes") + def _sdk_max_buffer_bytes() -> int: """Resolve the Agent SDK reader buffer cap from ``EGG_SDK_MAX_BUFFER_BYTES``. - A set-but-invalid value (non-integer or non-positive) is logged and ignored - in favour of the default, so an operator typo can't silently re-expose the - 1 MiB-overflow crash. The unset case is silent (the default is expected). + A set-but-invalid value (non-integer, non-positive, or absurdly large) is + logged and clamped to the default or the hard upper bound, so an operator + typo can't silently re-expose the 1 MiB-overflow crash *or* leave the + reader effectively unbounded. The unset case is silent (the default is + expected). Warnings dedup per distinct raw value so a steady misconfig + doesn't spam logs on every agent spawn (#2884 review feedback). """ raw = os.environ.get("EGG_SDK_MAX_BUFFER_BYTES", "").strip() if not raw: @@ -116,17 +142,18 @@ def _sdk_max_buffer_bytes() -> int: try: value = int(raw) except ValueError: - logger.warning( - f"EGG_SDK_MAX_BUFFER_BYTES={raw!r} is not an integer; using the " - f"default {_DEFAULT_SDK_MAX_BUFFER_BYTES} bytes" - ) + _warn_invalid_sdk_buffer(raw, "is not an integer", _DEFAULT_SDK_MAX_BUFFER_BYTES) return _DEFAULT_SDK_MAX_BUFFER_BYTES if value <= 0: - logger.warning( - f"EGG_SDK_MAX_BUFFER_BYTES={raw!r} must be a positive integer; using " - f"the default {_DEFAULT_SDK_MAX_BUFFER_BYTES} bytes" - ) + _warn_invalid_sdk_buffer(raw, "must be a positive integer", _DEFAULT_SDK_MAX_BUFFER_BYTES) return _DEFAULT_SDK_MAX_BUFFER_BYTES + if value > _MAX_SDK_MAX_BUFFER_BYTES: + _warn_invalid_sdk_buffer( + raw, + f"exceeds the {_MAX_SDK_MAX_BUFFER_BYTES}-byte upper bound", + _MAX_SDK_MAX_BUFFER_BYTES, + ) + return _MAX_SDK_MAX_BUFFER_BYTES return value diff --git a/shared/egg_agent/tool_output_cap.py b/shared/egg_agent/tool_output_cap.py index 3cdd4afb23..eae40978d6 100644 --- a/shared/egg_agent/tool_output_cap.py +++ b/shared/egg_agent/tool_output_cap.py @@ -38,11 +38,14 @@ logger = logging.getLogger(__name__) # Default byte threshold above which a whole-file ``Read`` is denied. -# The SDK buffer is 1 MB; a Read result is the file bytes plus per-line -# number prefixes (~7-8 bytes/line) plus JSON-escaping inflation, and it -# shares the 1 MB message with the rest of the turn. 256 KiB leaves ample -# headroom while still letting moderate files through whole. Override with -# EGG_READ_CAP_BYTES. +# This is a model-context/cost knob, not a crash-prevention one (the raised +# reader buffer in ``client.py`` covers the crash; #2884). At ~4 bytes/token +# for source code, 256 KiB is roughly **64k tokens** dumped to the model on a +# single whole-file Read — a meaningful slice of context for one tool call. +# It's still permissive enough to let moderate files through whole; the +# threshold catches the obviously-too-large reads (the 1.1 MB / ~275k-token +# pipelines.py and friends) so the agent pages them with offset/limit +# instead. Override with EGG_READ_CAP_BYTES. _DEFAULT_READ_CAP_BYTES = 256 * 1024 # Rough average bytes per source line, used to estimate how many bytes a @@ -219,10 +222,14 @@ def check_read_output_risk(tool_input: dict[str, Any], cwd: str | None) -> str | return None approx_kb = size // 1024 + # Source code is roughly ~4 B/token, so this rough KB→token estimate is + # accurate enough to motivate paging without overstating precision. + approx_tokens_k = max(1, approx_kb // 4) return ( f"Read denied: '{file_path}' is ~{approx_kb} KB, large enough that " - f"reading it whole risks overflowing the agent's 1 MB message buffer " - f"and crashing the session (issue #2804). {_read_remedy(suffix, cap)}" + f"reading it whole would dump ~{approx_tokens_k}k tokens to the model " + f"in a single tool result — wasteful of context budget when the call " + f"can be narrowed. {_read_remedy(suffix, cap)}" ) @@ -246,11 +253,12 @@ def check_grep_output_risk(tool_input: dict[str, Any]) -> str | None: return ( "Grep denied: output_mode='content' across the whole repo with no " - "'head_limit' can return an unbounded volume of matching lines and " - "overflow the agent's 1 MB message buffer (issue #2804). Add a " - "'head_limit' (e.g. head_limit=100), scope the search with 'path' or " - "'glob', or use output_mode='files_with_matches' to list files first " - "and then Read the relevant ranges." + "'head_limit' can return an unbounded volume of matching lines, " + "dumping a large slice of the repo to the model in a single tool " + "result — wasteful of context budget. Add a 'head_limit' (e.g. " + "head_limit=100), scope the search with 'path' or 'glob', or use " + "output_mode='files_with_matches' to list files first and then Read " + "the relevant ranges." ) diff --git a/shared/egg_tool_output.py b/shared/egg_tool_output.py index 33939998ee..4f497c3528 100644 --- a/shared/egg_tool_output.py +++ b/shared/egg_tool_output.py @@ -1,9 +1,15 @@ """Tool-output size caps for egg-owned MCP tools (issue #2805). -The Claude Agent SDK message reader has a hard 1 MB JSON buffer; a tool -result that exceeds it kills the agent with exit 255 (#2804). #2810 made -that crash observable and terminal, but the *prevention* — never producing -an oversized payload in the first place — lives here. +These caps are **model-context/cost discipline, not the buffer-crash fix.** +The Agent SDK reader's buffer-overflow crash (#2804/#2884) is prevented by +raising ``ClaudeAgentOptions.max_buffer_size`` in +``shared/egg_agent/client.py`` — the messages that overflowed the reader +were dominated by *non-model-bound* transcript metadata (Claude Code +attaches the whole original file to every Edit/Write result) that a +per-tool result cap could never have seen or policed. What this module +*does* police is the volume an egg-owned MCP ``@tool`` sends **to the +model**: an unbounded result is wasteful of context budget on every model +hop, regardless of how large the SDK reader buffer is. This module is the shared helper referenced by #2805's "consistent across tools" requirement. It is deliberately a flat, stdlib-only module (no @@ -42,9 +48,12 @@ logger = logging.getLogger(__name__) -# Default cap, well under the SDK's 1 MB reader buffer so the serialized -# result still has headroom for the surrounding SDK/MCP framing. Override -# with ``EGG_TOOL_OUTPUT_CAP_BYTES`` (issue #2805 "configurable"). +# Default cap on an egg-owned MCP tool result, sized as cost discipline: +# at ~4 B/token for prose/JSON, 100 KiB ≈ 25k tokens, a sensible upper bound +# for a single model-bound tool result. (The SDK reader can absorb far more +# than this — its buffer is raised to 32 MiB on the egg path, #2884 — so the +# cap is not a crash backstop; it is a budget on what the model sees.) +# Override with ``EGG_TOOL_OUTPUT_CAP_BYTES`` (issue #2805 "configurable"). DEFAULT_CAP_BYTES = 100 * 1024 # Reserve for the non-preview marker fields so the assembled marker still @@ -124,9 +133,9 @@ def _truncation_marker( ) note = ( f"Result was {original_bytes} bytes; it exceeded the {cap_bytes}-byte " - "tool-output cap and was truncated to avoid the Agent SDK 1 MB " - "message-buffer crash (#2804/#2805). Only the head is shown below — " - f"to see the rest, {hint}." + "model-output cap and was truncated to keep a single tool result from " + "consuming an outsized slice of the model's context budget (#2805). " + f"Only the head is shown below — to see the rest, {hint}." ) budget = max(cap_bytes - _MARKER_RESERVE_BYTES, 256) preview = text.encode("utf-8")[:budget].decode("utf-8", errors="ignore") @@ -289,11 +298,12 @@ def spill_to_file( "total_bytes": total_bytes, "cap_bytes": limit, "note": ( - f"Result was {total_bytes} bytes (over the {limit}-byte tool-output " - "cap), so the full output was written to `output_path` to avoid the " - "Agent SDK 1 MB buffer crash (#2804/#2805). Read it with the `Read` " - "tool (use `offset`/`limit`) or `grep` it via `Bash`. A head sample " - "of the output is inlined below as `preview`." + f"Result was {total_bytes} bytes (over the {limit}-byte model-output " + "cap), so the full output was written to `output_path` to keep a " + "single tool result from consuming an outsized slice of the model's " + "context budget (#2805). Read it with the `Read` tool (use " + "`offset`/`limit`) or `grep` it via `Bash`. A head sample of the " + "output is inlined below as `preview`." ), "preview": preview, } diff --git a/tests/shared/egg_agent/test_client.py b/tests/shared/egg_agent/test_client.py index 3002aa9466..4db0701720 100644 --- a/tests/shared/egg_agent/test_client.py +++ b/tests/shared/egg_agent/test_client.py @@ -1099,14 +1099,61 @@ def test_max_buffer_size_configurable_via_env(self, mock_query): assert opts.max_buffer_size == 8 * 1024 * 1024 def test_env_resolver_rejects_invalid_values(self): + # Each bad value gets its own dedup-state reset so the per-value warning + # actually fires; a stale entry from a prior iteration would silently + # turn this into "passes when the function changes to skip warn". + import egg_agent.client as client_mod + for bad in ("not-a-number", "0", "-5", "2mb"): - with patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": bad}): + client_mod._warned_sdk_buffer_values.clear() + with ( + patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": bad}), + patch("egg_agent.client.logger") as mock_logger, + ): assert _sdk_max_buffer_bytes() == _DEFAULT_SDK_MAX_BUFFER_BYTES + # The docstring promises invalid values are *logged* and ignored; + # without this assertion a regression that demoted the warning + # (or dropped it) would still pass the silent-fallback check. + assert mock_logger.warning.called, ( + f"expected a logger.warning for invalid value {bad!r}" + ) def test_env_resolver_accepts_valid_override(self): with patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": "16777216"}): assert _sdk_max_buffer_bytes() == 16 * 1024 * 1024 + def test_env_resolver_clamps_absurdly_large_value(self): + """An operator typo (stray suffix-conversion like 34359738368000 ≈ 34 TiB) + must be clamped to the 1 GiB hard upper bound rather than silently + accepted — an effectively-unbounded reader buffer could OOM the + container on a runaway or malformed stream (#2884 review feedback). + """ + import egg_agent.client as client_mod + + client_mod._warned_sdk_buffer_values.clear() + with ( + patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": "34359738368000"}), + patch("egg_agent.client.logger") as mock_logger, + ): + assert _sdk_max_buffer_bytes() == client_mod._MAX_SDK_MAX_BUFFER_BYTES + assert mock_logger.warning.called + + def test_env_resolver_warning_dedups_per_value(self): + """A steady bad value warns once, not on every resolver call — the + resolver runs on every ``run_agent_async`` invocation, so an + unconditional warning would spam an operator-facing log line per + agent spawn (#2884 review feedback, mirroring tool_output_cap).""" + import egg_agent.client as client_mod + + client_mod._warned_sdk_buffer_values.clear() + with ( + patch.dict(os.environ, {"EGG_SDK_MAX_BUFFER_BYTES": "not-a-number"}), + patch("egg_agent.client.logger") as mock_logger, + ): + for _ in range(5): + assert _sdk_max_buffer_bytes() == _DEFAULT_SDK_MAX_BUFFER_BYTES + assert mock_logger.warning.call_count == 1 + class TestBufferOverflowErrorHandling: """Issue #2804: when the SDK raises CLIJSONDecodeError on a buffer From 7dd5814385d8d57d6f05aa4c8353b3a29ba9c312 Mon Sep 17 00:00:00 2001 From: "james-in-a-box[bot]" <246424927+james-in-a-box[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:23 +0000 Subject: [PATCH 3/5] Fix test_denies_unbounded_read_of_large_file: assert reframed message The PR reframed the per-tool Read cap as model-context/cost discipline rather than #2804 crash prevention, so the deny reason no longer mentions '#2804'. Update the test to assert on the new 'context budget' framing. --- tests/shared/egg_agent/test_tool_output_cap.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/shared/egg_agent/test_tool_output_cap.py b/tests/shared/egg_agent/test_tool_output_cap.py index 87be59f20f..7d33a26f35 100644 --- a/tests/shared/egg_agent/test_tool_output_cap.py +++ b/tests/shared/egg_agent/test_tool_output_cap.py @@ -35,7 +35,9 @@ def test_denies_unbounded_read_of_large_file(self, tmp_path): assert reason is not None # Reason must tell the agent how to narrow the call. assert "offset" in reason and "limit" in reason - assert "#2804" in reason + # Per #2884, the cap is reframed as model-context/cost discipline + # (not #2804 crash prevention — the SDK reader buffer handles that). + assert "context budget" in reason def test_allows_large_file_when_limit_given(self, tmp_path): big = _write(tmp_path, "big.py", 300 * 1024) From ee2c4577bb6c013a080bc2285ddc5a9972bd736c Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 01:08:13 +0000 Subject: [PATCH 4/5] Reframe remaining overflow language in tool-output cap surfaces Finish the cost-discipline reframing pass: rewrite the binary-extension Read deny string, the two check_*_output_risk docstring summaries, the _truncation_marker pathological-cap comment, and the agent-tools.md #2876 description so the per-tool caps no longer claim to protect the 1 MB SDK buffer. --- docs/reference/agent-tools.md | 4 ++-- shared/egg_agent/tool_output_cap.py | 11 ++++++----- shared/egg_tool_output.py | 6 +++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/reference/agent-tools.md b/docs/reference/agent-tools.md index cf18c25097..68ca00c524 100644 --- a/docs/reference/agent-tools.md +++ b/docs/reference/agent-tools.md @@ -242,8 +242,8 @@ either way. **Built-in tool cap (complementary):** The cap above covers egg-owned MCP `@tool` payloads. Built-in Claude Code tools (`Read`, `Grep`, etc.) run inside the CLI and can't be wrapped the same way. [#2876](https://github.com/jwbron/egg/issues/2876) -adds a PreToolUse hook that predicts overflows *before* the tool runs and -denies the call with a narrowing hint. See +adds a PreToolUse hook that predicts when a result would be excessive +*before* the tool runs and denies the call with a narrowing hint. See [Agent Recovery → Predictive Output Cap](agent-recovery.md#predictive-output-cap-pretooluse) for the heuristic table and the `EGG_TOOL_OUTPUT_CAP` / `EGG_READ_CAP_BYTES` operator knobs. diff --git a/shared/egg_agent/tool_output_cap.py b/shared/egg_agent/tool_output_cap.py index eae40978d6..e381987c4c 100644 --- a/shared/egg_agent/tool_output_cap.py +++ b/shared/egg_agent/tool_output_cap.py @@ -163,9 +163,10 @@ def _read_remedy(suffix: str, cap: int) -> str: ) if suffix in _NON_PAGEABLE_BINARY_EXTENSIONS: return ( - "This binary file is returned whole and cannot be paged, so it " - "cannot be read without risking the overflow. Avoid reading it " - "whole; if you only need metadata, use Bash (e.g. 'file' or 'stat')." + "This binary file is returned whole and cannot be paged, so " + "reading it would dump the entire binary to the model in a single " + "tool result — wasteful of context budget. Avoid reading it whole; " + "if you only need metadata, use Bash (e.g. 'file' or 'stat')." ) suggested_limit = max(1, cap // _EST_BYTES_PER_LINE) return ( @@ -177,7 +178,7 @@ def _read_remedy(suffix: str, cap: int) -> str: def check_read_output_risk(tool_input: dict[str, Any], cwd: str | None) -> str | None: - """Return a deny reason if a ``Read`` call is likely to overflow. + """Return a deny reason if a ``Read`` call would produce an excessive model-bound result. Denies when the target file exceeds the configured byte cap and the read is not bounded to a small enough range. A text read is "bounded" when its @@ -234,7 +235,7 @@ def check_read_output_risk(tool_input: dict[str, Any], cwd: str | None) -> str | def check_grep_output_risk(tool_input: dict[str, Any]) -> str | None: - """Return a deny reason if a ``Grep`` call is likely to overflow. + """Return a deny reason if a ``Grep`` call would produce an excessive model-bound result. Targets the genuinely unbounded case: ``output_mode='content'`` with no ``head_limit`` **and** no path/glob narrowing, i.e. dumping every diff --git a/shared/egg_tool_output.py b/shared/egg_tool_output.py index 4f497c3528..78663e019b 100644 --- a/shared/egg_tool_output.py +++ b/shared/egg_tool_output.py @@ -158,9 +158,9 @@ def _serialized_len() -> int: preview = preview[: len(preview) // 2] marker["preview"] = preview # Pathological tiny cap: even an empty preview leaves the fixed fields - # over the cap. Drop the preview entirely so the marker is as small as it - # can be — still sub-KB, so it can never threaten the 1 MB SDK buffer the - # cap exists to protect. + # over the cap. Drop the preview entirely so the marker stays as small as + # it can be — well within the cap's context budget even at a + # pathologically tiny ``cap_bytes``. if _serialized_len() > cap_bytes: marker.pop("preview", None) return marker From a10e35e2a6b96219e343f70a8b9dd99f3cd47a64 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 01:30:52 +0000 Subject: [PATCH 5/5] Reframe TestBuiltinOutputCapHook docstring as cost discipline --- tests/shared/egg_agent/test_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/shared/egg_agent/test_client.py b/tests/shared/egg_agent/test_client.py index 4db0701720..35d357e02f 100644 --- a/tests/shared/egg_agent/test_client.py +++ b/tests/shared/egg_agent/test_client.py @@ -980,9 +980,10 @@ def test_web_tool_deny_hook_not_registered_in_private_mode(self, mock_query): class TestBuiltinOutputCapHook: """Issue #2876: a PreToolUse hook predicts oversized built-in tool - results (Read/Grep) and denies them before they overflow the SDK's - 1 MB buffer, telling the agent how to narrow the call. Always-on - (not route-gated); disabled via EGG_TOOL_OUTPUT_CAP=false. + results (Read/Grep) and denies them as model-context/cost discipline, + telling the agent how to narrow the call. Not the buffer-crash fix — + that lives in the raised SDK reader buffer (#2884). Always-on (not + route-gated); disabled via EGG_TOOL_OUTPUT_CAP=false. """ @staticmethod