Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/reference/agent-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ The agent output is captured by piping stdout and stderr through `tee` into a te

> **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.

### 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).

Current heuristics — predictive, so expect some false positives/negatives, with the fail-fast as the backstop when a prediction misses:

| Tool | Denied when | Deny reason points at |
|------|-------------|------------------------|
| `Read` (text) | Target file > `EGG_READ_CAP_BYTES` (default 256 KiB) **and** the read is unbounded — no `limit`, or a `limit` whose estimated payload (`limit` × ~128 B/line) still exceeds the cap | `offset` / `limit` to page through the file (with a suggested `limit` that fits the cap) |
| `Read` (PDF) | Target PDF > `EGG_READ_CAP_BYTES` **and** no non-empty `pages` range — a `pages`-scoped read is bounded (the Read tool caps it at 20 pages), mirroring `limit` for text | `pages` to read a bounded page range (e.g. `pages='1-5'`) |
| `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).

### Transient Exit Codes

The `is_transient_crash()` function classifies these exit codes as transient:
Expand Down
52 changes: 52 additions & 0 deletions shared/egg_agent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,58 @@ async def _check_tool_permission(
error=str(e),
)

# --- 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.
from egg_agent.tool_output_cap import (
check_builtin_tool_output_risk,
is_output_cap_disabled,
)

if not is_output_cap_disabled():
# Resolves Read's relative file_paths the same way the tool will: prefer
# the live cwd the SDK reports on each PreToolUse event, falling back to
# the launch cwd if absent. Returns {} (no decision) when the call is
# within bounds, so allowed calls fall through to the normal flow.
async def _cap_builtin_tool_output(
input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
reason = check_builtin_tool_output_risk(
input_data.get("tool_name", ""),
input_data.get("tool_input", {}) or {},
input_data.get("cwd") or resolved_cwd,
)
if reason is None:
return {}
logger.info(
"Predictive output cap denied built-in tool call",
event_type="tool_intercepted",
event_subtype="output_cap_deny",
tool_name=input_data.get("tool_name"),
tool_use_id=tool_use_id,
reason=reason,
)
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}

existing_hooks = getattr(options, "hooks", None) or {}
pre_tool_use = list(existing_hooks.get("PreToolUse", []))
pre_tool_use.append(HookMatcher(matcher="Read", hooks=[_cap_builtin_tool_output]))
pre_tool_use.append(HookMatcher(matcher="Grep", hooks=[_cap_builtin_tool_output]))
options.hooks = {**existing_hooks, "PreToolUse": pre_tool_use}

# --- DuckDuckGo MCP fallback for the LiteLLM→non-Anthropic path (#2856) ---
# On that path (signalled by ANTHROPIC_CUSTOM_MODEL_OPTION) the built-in
# WebSearch/WebFetch tools silently no-op: LiteLLM's drop_params strips the
Expand Down
270 changes: 270 additions & 0 deletions shared/egg_agent/tool_output_cap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
"""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.
"""

from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import Any

try:
from egg_logging import get_logger

logger: Any = get_logger("egg-agent")
except ImportError: # pragma: no cover - egg_logging always present in-sandbox
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.
_DEFAULT_READ_CAP_BYTES = 256 * 1024

# Rough average bytes per source line, used to estimate how many bytes a
# *bounded* Read (``limit`` lines) will return. Deliberately conservative
# (real source averages well under this) so a normal paging limit like
# ``limit=2000`` stays under the 256 KiB default while an absurd
# ``limit=10_000_000`` is still recognised as unbounded.
_EST_BYTES_PER_LINE = 128

# Binary file types ``Read`` returns whole, where line-based ``offset`` /
# ``limit`` paging does not apply. PDFs are the exception — they page via
# ``pages`` (the Read tool caps a request at 20 pages), so a ``pages``-scoped
# PDF read is bounded and allowed. Notebooks are JSON but Read returns every
# cell whole, so they get a ``jq``-oriented remedy rather than the generic one.
_PDF_EXTENSION = ".pdf"
_NOTEBOOK_EXTENSION = ".ipynb"
_NON_PAGEABLE_BINARY_EXTENSIONS = frozenset(
{".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico", _NOTEBOOK_EXTENSION}
)

# Raw EGG_READ_CAP_BYTES values we've already warned about, so a misconfigured
# knob logs once per distinct value rather than on every Read in the session.
_warned_cap_values: set[str] = set()


def is_output_cap_disabled() -> bool:
"""True when the predictive cap is switched off via env.

Mirrors the EGG_MCP_TOOLS kill-switch convention so operators can
disable the heuristic without a code change if it proves too noisy.
"""
return os.environ.get("EGG_TOOL_OUTPUT_CAP", "").strip().lower() in (
"false",
"0",
"no",
"off",
)


def _read_cap_bytes() -> int:
"""Resolve the Read byte cap from env, falling back to the default.

``EGG_READ_CAP_BYTES`` is an operator tuning knob, so a *set-but-invalid*
value is logged loudly before falling back — an operator who typoed the
value or used an unsupported suffix (``2mb``, ``0``, a negative) would
otherwise silently get the default and a different false-positive rate
than they intended. The unset case stays silent (the default is expected).
"""
raw = os.environ.get("EGG_READ_CAP_BYTES", "").strip()
if not raw:
return _DEFAULT_READ_CAP_BYTES
try:
value = int(raw)
except ValueError:
_warn_invalid_cap(raw, "is not an integer")
return _DEFAULT_READ_CAP_BYTES
if value <= 0:
_warn_invalid_cap(raw, "must be a positive integer")
return _DEFAULT_READ_CAP_BYTES
return value


def _warn_invalid_cap(raw: str, problem: str) -> None:
"""Warn that an invalid EGG_READ_CAP_BYTES is being ignored, once per value.

The cap is resolved on every ``Read``, so warning unconditionally would emit
hundreds of identical lines for one misconfiguration. Track the raw values
already warned about so a fixed-then-re-broken knob still warns on the new
bad value, but a steady bad value warns only once.
"""
if raw in _warned_cap_values:
return
_warned_cap_values.add(raw)
logger.warning(
f"EGG_READ_CAP_BYTES={raw!r} {problem}; ignoring it and using the "
f"default {_DEFAULT_READ_CAP_BYTES} bytes"
)


def _resolve_path(file_path: str, cwd: str | None) -> Path:
"""Resolve a possibly-relative tool ``file_path`` against the agent cwd."""
path = Path(file_path)
if not path.is_absolute() and cwd:
path = Path(cwd) / path
return path


def _coerce_positive_int(value: Any) -> int | None:
"""Return ``value`` as a positive int, or None if it isn't one."""
try:
n = int(value)
except TypeError, ValueError:
return None
return n if n > 0 else None


def _read_remedy(suffix: str, cap: int) -> str:
"""Build the deny-message remedy clause tailored to the file type.

Line-based ``offset``/``limit`` paging only makes sense for text files;
for PDFs the agent should page with ``pages``, for notebooks it should
pull individual cells out with ``jq``, and for other binaries (images)
``Read`` returns the whole file so no paging applies.
"""
if suffix == _PDF_EXTENSION:
return (
"Re-run Read with the 'pages' parameter to read a bounded page "
"range (e.g. pages='1-5')."
)
if suffix == _NOTEBOOK_EXTENSION:
return (
"Read returns the whole notebook (every cell and its outputs), so "
"line paging does not apply. Inspect individual cells with Bash and "
"jq instead (e.g. jq '.cells[].source' notebook.ipynb)."
)
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')."
)
suggested_limit = max(1, cap // _EST_BYTES_PER_LINE)
return (
f"Re-run Read with 'offset' and 'limit' to page through it "
f"(e.g. offset=1, limit={suggested_limit}), or use Grep with "
f"output_mode='files_with_matches' / a 'head_limit' to locate the "
f"lines you need first, then Read that range."
)


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.

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
``limit`` × ~bytes-per-line estimate stays under the cap — a mere ``limit``
is *not* a free pass, since ``limit=10_000_000`` would still read the whole
file (the #2810 fail-fast caught that gap). A PDF is "bounded" when a
non-empty ``pages`` range is given (the analogue of ``limit`` for PDFs, and
the mechanism the deny remedy points at — capped at 20 pages by the Read
tool). Other binaries (images, notebooks) ignore ``offset``/``limit``/
``pages``, so they are judged on size alone. Missing/unstattable files are
allowed (let the real tool report the error).
"""
file_path = tool_input.get("file_path")
if not file_path:
return None

path = _resolve_path(str(file_path), cwd)
try:
size = path.stat().st_size
except OSError:
# Missing/unreadable — let the real Read tool surface the error.
return None

cap = _read_cap_bytes()
if size <= cap:
return None

suffix = path.suffix.lower()
is_pdf = suffix == _PDF_EXTENSION

if is_pdf:
# A `pages`-scoped PDF read is bounded (the Read tool caps it at 20
# pages), so it must not be denied while the remedy points at `pages`.
pages = tool_input.get("pages")
if pages is not None and str(pages).strip():
return None
elif suffix not in _NON_PAGEABLE_BINARY_EXTENSIONS:
# A bounded text read whose estimated payload fits under the cap is safe.
# (Binary reads ignore offset/limit, so a limit never makes them safe.)
limit = _coerce_positive_int(tool_input.get("limit"))
if limit is not None and min(size, limit * _EST_BYTES_PER_LINE) <= cap:
return None

approx_kb = size // 1024
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)}"
)


def check_grep_output_risk(tool_input: dict[str, Any]) -> str | None:
"""Return a deny reason if a ``Grep`` call is likely to overflow.

Targets the genuinely unbounded case: ``output_mode='content'`` with
no ``head_limit`` **and** no path/glob narrowing, i.e. dumping every
matching line across the whole repo. Content greps that are scoped
(by ``path`` or ``glob``) or capped (by ``head_limit``) are allowed —
the heuristic deliberately stays narrow to avoid denying the common,
small content grep.
"""
if tool_input.get("output_mode") != "content":
return None
if tool_input.get("head_limit") is not None:
return None
# Scoped to a subtree or file glob → bounded enough; allow.
if tool_input.get("path") or tool_input.get("glob"):
return 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."
)


def check_builtin_tool_output_risk(
tool_name: str, tool_input: dict[str, Any], cwd: str | None
) -> str | None:
"""Dispatch a built-in tool call to its predictive-cap checker.

Returns a deny reason string, or None when the call is allowed (or the
cap is disabled via EGG_TOOL_OUTPUT_CAP).
"""
if is_output_cap_disabled():
return None
if tool_name == "Read":
return check_read_output_risk(tool_input, cwd)
if tool_name == "Grep":
return check_grep_output_risk(tool_input)
return None
Loading
Loading