# Tool-result size gate

**One tool result that re-enters the prompt on every loop iteration is the cost bomb.** A flashcard request once pulled a 1.8 MB note dump through the generic `data` tool; it was re-sent to the provider on four more iterations → ~2.2M input tokens, ~$25, no error. The gate makes that class extinct, at two layers, loud.

Verified against code 2026-06-30.

## The model: tools own their output; the gate is the alarm

A blunt generic truncation **destroys** a structured result (cutting `json.dumps(rows)` mid-string hands the model invalid JSON). So the real mechanism is **tool self-responsibility**: a tool that can produce a large result truncates it *surgically* itself and declares it did. The gate is what catches a tool that **didn't** — and its firing is recorded so the gap gets fixed.

## The contract — two fields on `ToolResult`

- **`output_self_capped: bool`** — "I trimmed my own large fields and offered the agent a way to fetch more." When True the **soft cap stands down** — the tool is trusted. Set it only when the WHOLE result is bounded by the cap you applied; claiming it for a result you didn't actually bound makes the absolute-ceiling alarm cry wolf.
- **`approved_max_chars: int | None`** — an explicit per-result model-visible ceiling the tool authorizes (raises the absolute ceiling for THIS result only). None ⇒ even a self-capped result is bounded by the absolute ceiling (fail-safe).

A tool self-manages by trimming one field with **`cap_text(text, limit=...)`** ([`output_caps.py`](output_caps.py)) and attaching honest `<field>_chars` / `<field>_truncated` siblings, then setting `output_self_capped=True`. Reference consumer: [`aidream/services/agent_data/read_caps.py`](../../../../aidream/services/agent_data/read_caps.py).

**List-shaped results use `cap_list(rows, limit=…)`** (same module) — the count-side companion to `cap_text`. A `*_list` tool projects each row to a compact shape itself (domain knowledge), then `cap_list` bounds the ROW COUNT and returns honest `total` / `shown` / `truncated` siblings; small compact rows + a capped count ⇒ the tool can honestly set `output_self_capped=True`. Reference consumers: `note_list` / `task_list` ([`implementations/notes.py`](implementations/notes.py), [`implementations/tasks.py`](implementations/tasks.py)) and the `debug_traces_*` tools ([`implementations/debug_traces_tools.py`](implementations/debug_traces_tools.py)), which return compact rows by default and let the agent drill into one `call_id` (or `verbose=true`) for the heavy fields. A "list-everything" tool that returns full rows with no count cap is the bug this catches — `TOOL_LIST_DEFAULT_LIMIT=200`.

## Three tiers, three alarms ([`output_caps.py`](output_caps.py) constants)

| Tier | Threshold | What happens |
|------|-----------|--------------|
| **canary** | 25K ≤ size < 50K | passed; recorded to ops-triage so the cap can be tuned from real data. Quiet. |
| **soft** (Layer 1) | size ≥ 50K, not self-capped | truncate-with-notice + stash overflow + alarm. Native/agent tool ⇒ **defect** (fix it); external/MCP ⇒ expected (can't teach it). |
| **ceiling** (Layer 2) | size > 500K (or `approved_max_chars`) | hard-truncate at the provider boundary. LOUDEST — Layer 1 was bypassed. |

Numbers are **generous on purpose**: too low and every legit large read truncates → the agent fetches more → one click becomes two requests → cost balloons the other way. The gate catches crazy cases, not normal big results.

## The two layers — and why both

- **Layer 1 — [`result_gate.py::apply_size_gate`](result_gate.py)**, called once at `ToolExecutor.execute`'s single convergence point ([`executor.py`](executor.py), the `return` after `prepare_metadata`). Source-aware, graceful: truncates the model-visible content, stashes the full payload in the [`output_overflow.py`](output_overflow.py) cache keyed by `call_id`, lowers `output_chars` to the truncated size, and **injects `fetch_tool_result`** for the next turn via `queue_tool_changes`. It never touches `result.output` (the DB/replay copy stays full). Re-billing dies here because the message list only ever holds the capped head.
- **Layer 2 — [`MessageList._enforce_absolute_tool_result_ceiling`](../config/message_config.py)**, run inside `sanitize()` before EVERY provider call. Dumb, absolute, **source-agnostic** — it measures `str` content as-is and `dict`/`list` content as `json.dumps` (what the serializer will send), so it catches results that bypassed Layer 1 entirely: client-posted delegated results, rebuilt-from-DB rows, raw `/chat` messages carrying a non-string `tool_result.content`. Idempotent (a block already carrying the truncation marker is skipped → the alarm fires once per event).

**The realtime/voice surface** (`POST /ai/tools/execute`, [`realtime_tools.py`](../../../../aidream/api/routers/realtime_tools.py)) builds no `MessageList`, so it returns the Layer-1-gated `_content["content"]`, NEVER `result.output`.

## Retrieval — `fetch_tool_result`

When the soft cap truncates, the notice names the `call_id` and tells the model to call **`fetch_tool_result(call_id, max_chars, offset)`** ([`aidream/tools/fetch_tool_result.py`](../../../../aidream/tools/fetch_tool_result.py)). `max_chars` is **required** — the good agent declares what it wants and is never punished with blind paging. The cache is owner-verified (`user_id` must match — `call_id` is NOT globally unique) and fails closed. Process-local, TTL 30 min; on a miss the model re-runs the source tool with narrower args.

## Recording — the durable alarm

matrx-ai exposes `register_tool_result_gate_sink(...)`; aidream wires the ops-triage writer in [`package_integration.py`](../../../../aidream/package_integration.py) (boundary rule — the package never imports a host's DB). Sink: [`aidream/observability/tool_result_gate_sink.py`](../../../../aidream/observability/tool_result_gate_sink.py) → dedup-keyed `public.ops_issue_class`/`ops_issue_event`, surfaced at the dashboard `/ops-triage` page. Best-effort, non-blocking, context-isolated (`detached_task`), never raises into dispatch.

## To add a self-managing tool

1. Trim your big field(s) with `cap_text(...)`; attach `<field>_chars`/`<field>_truncated`.
2. Set `result.output_self_capped = True` **only if the whole result is bounded**.
3. Offer a native "get more" (re-fetch by id with a higher cap) — or rely on the generic `fetch_tool_result`.
4. Need a deliberately large result? Set `approved_max_chars` to authorize it.

## Change Log

- **2026-07-14** — `web` `read` / `batch_read` now self-cap: standard `offset`/`chars` paging (default 8k; short pages ≤16k return in full), whole-result budget under the soft cap, `output_self_capped=True`. `web` `search` also caps its combined text. Root cause: ops-triage `tool_result_overflow:web` on a 4-URL `batch_read` at ~76k (per-URL default was 50k with no batch budget). Helpers in [`implementations/_web_read_caps.py`](implementations/_web_read_caps.py); migration `0173_web_read_paging_args.sql`.
- **2026-06-30** — Initial build. Layers 1+2, canary/soft/ceiling tiers, overflow cache + `fetch_tool_result`, ops-triage sink, data-tool surgical caps. Hardened after adversarial review (Layer-2 dict-content path, idempotency, honest self-cap, realtime surface, overflow memory bound).
