fix(session): survive tool-result truncation at zero context budget (#883)

At an exhausted context budget the drain loop replaced every tool result
with a placeholder that read as a successful-but-trimmed call. For
structural results — spawn_workstream's ws_id, the tasks scratchpad —
the model lost the handle orchestration depends on and silently
stalled, while the UI (told the real summary before the drain) kept
showing success. Worse, the budget zeroes near 70% fullness when
max_tokens ≥ context_window/4, well below the 80% auto-compact
threshold, so a stalled coordinator could sit in that band indefinitely
with no compaction ever firing.

Three guarantees at the truncation seam, one renewal trigger at the
drain:

- structural-tool and error results get a guaranteed 2048-char
  admission floor (head+tail beyond it) — never the zero-budget drop
- any result at or under the floor passes verbatim (denial notices,
  spawn acks: never destroy what is smaller than the guarantee)
- bulky non-structural results get an explicit drop notice stating the
  call RAN but its output could not be admitted — never a trim
  impersonation the model cannot distinguish from success
- a zero truncation budget triggers one mid-turn compaction (no
  threshold_pct — none was evaluated, same rule as the ctx-overflow
  retry), closing the 70-80% band where the budget zeroed but
  compaction was never owed

Background-bash spawn acks ride the small-result pass; a name-keyed
floor cannot distinguish them from foreground bash — see #891.
This commit is contained in:
Patrick Buckley
2026-07-21 15:58:11 -07:00
parent 51bb525b27
commit 6c4c848a08
3 changed files with 720 additions and 12 deletions
+23
View File
@@ -967,6 +967,29 @@ The default limit is 50% of the context window in characters (computed as
This truncation message is visible to the model, so it knows output was cut.
During the send loop the limit is additionally capped by the remaining
context budget, and three guarantees apply when that budget reaches zero
(#883):
- **Structural floor** — orchestration handles (`spawn_workstream`,
`spawn_batch`, `wait_for_workstream`, `tasks`) and error results are
always admitted up to a guaranteed floor (2048 chars, head+tail beyond
it), because a lost `ws_id` or a masked failure wedges the session.
- **Small-result pass** — results at or under the floor pass verbatim,
funded from a bounded per-batch grace pool (2× the floor) so a wide
batch of small results cannot collectively bypass budget accounting;
past the pool they get the drop notice instead.
- **Honest drop notice** — a bulky non-structural result is replaced by an
explicit `Error: tool result dropped — context budget exhausted…` notice
stating the call ran but its output could not be admitted (never a
successful-looking trim).
A zero budget also triggers one mid-turn auto-compaction before results are
sized: the response reserve zeroes the budget well below the auto-compact
threshold (near 70% fullness with `max_tokens ≥ context_window/4`), and
without this trigger a session could idle in that band indefinitely with
every tool result floored or dropped.
---
## Persistence
+506 -9
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import ChatSession
from turnstone.core.trajectory import turns_from_dicts
from turnstone.core.trajectory import Role, turns_from_dicts
# ---------------------------------------------------------------------------
# Helpers
@@ -63,16 +64,67 @@ class TestTruncateOutput:
result = session._truncate_output(big, remaining_budget_tokens=25)
assert "chars truncated" in result
def test_zero_budget_returns_placeholder(self, session):
big = "x" * 1000
result = session._truncate_output(big, remaining_budget_tokens=0)
assert "exceeded context budget" in result
assert len(result) < 100
def test_empty_output_passes_at_any_budget(self, session):
# A 0-char result must never be replaced by a ~310-char drop notice
# ("none of it could be added" would be false, and net-negative).
assert session._truncate_output("") == ""
assert session._truncate_output("", remaining_budget_tokens=0) == ""
assert session._truncate_output("", remaining_budget_tokens=0, floor_chars=0) == ""
def test_negative_budget_returns_placeholder(self, session):
big = "x" * 1000
def test_zero_budget_small_result_drops_without_floor(self, session):
# The function itself has no small-pass: at zero budget an unfloored
# result gets the drop notice regardless of size. Verbatim
# admission of small results is the DRAIN's per-batch grace-pool
# decision, funded through floor_chars — see TestZeroBudgetDrain.
small = "x" * 1000
result = session._truncate_output(small, remaining_budget_tokens=0)
assert "dropped" in result
assert "context budget exhausted" in result
def test_zero_budget_bulky_result_gets_honest_drop_notice(self, session):
big = "x" * 5000
result = session._truncate_output(big, remaining_budget_tokens=0)
# States the truth: the call ran, the output is gone — never the
# old successful-but-trimmed impersonation (#883).
assert "dropped" in result
assert "context budget exhausted" in result
assert "5000" in result
assert not result.startswith("[Output truncated")
assert "xxxx" not in result # no payload content leaks into the notice
assert len(result) < 400
def test_negative_budget_same_as_zero(self, session):
big = "x" * 5000
result = session._truncate_output(big, remaining_budget_tokens=-10)
assert "exceeded context budget" in result
assert "dropped" in result
assert "context budget exhausted" in result
def test_floor_overrides_zero_budget(self, session):
from turnstone.core.session import _TRUNCATION_FLOOR_CHARS
big = "A" * 5000 + "Z" * 5000
result = session._truncate_output(
big, remaining_budget_tokens=0, floor_chars=_TRUNCATION_FLOOR_CHARS
)
# Floored: head+tail truncation at the floor, not the drop notice.
assert "chars truncated" in result
assert result.startswith("A")
assert result.endswith("Z")
assert len(result) <= _TRUNCATION_FLOOR_CHARS + 200 # + marker
def test_floor_overrides_operator_cap(self, session):
# The floor deliberately wins over a tiny operator-set cap:
# framework integrity beats config for structural results.
session.tool_truncation = 100
big = "A" * 5000 + "Z" * 5000
result = session._truncate_output(big, floor_chars=2048)
assert "chars truncated" in result
assert len(result) > 1000 # floored, not capped at 100
def test_floor_no_effect_with_healthy_budget(self, session):
session.tool_truncation = 100_000
out = "x" * 500
assert session._truncate_output(out, remaining_budget_tokens=5000, floor_chars=2048) == out
def test_none_budget_uses_fixed_limit(self, session):
session.tool_truncation = 100
@@ -244,3 +296,448 @@ class TestContextOverflowRecovery:
pytest.raises(Exception, match="maximum context length exceeded"),
):
session.send("hello")
# ---------------------------------------------------------------------------
# Zero-budget drain behavior (#883): the floor doors + the band-closing compact
# ---------------------------------------------------------------------------
@contextlib.contextmanager
def _send_with_tool_batches(session, batches, **extra_patches):
"""Drive one ``send()`` through the tool-execution drain with canned results.
*batches* is a list of ``(tool_calls, results)`` pairs, one send-loop
iteration each: ``_stream_response`` returns an assistant turn carrying
each batch's *tool_calls* in order, then a plain reply ends the loop.
Each *results* is what ``_execute_tools`` hands the drain — the
truncation/floor/compact path under test runs REAL code between the
mocked boundaries. Mirrors ``tests/test_session.py::_send_with_mocks``;
kept local because these tests patch the budget/compaction seam
differently per scenario.
``_estimated_prompt_tokens`` is pinned LOW so the end-of-turn/owed
compaction paths stay quiet — every compaction observed by these tests
is therefore the drain's own zero-budget trigger, keeping exact
call-count assertions honest. Title generation is pre-latched off so
no background utility-completion thread churns against the mock client.
"""
session._title_generated = True
responses = [
{"role": "assistant", "content": "", "tool_calls": tool_calls} for tool_calls, _ in batches
] + [{"role": "assistant", "content": "done"}]
exec_results = [(results, []) for _, results in batches]
def mock_stream(_msgs):
return iter([])
def mock_response(_stream, _gen):
return responses.pop(0)
with contextlib.ExitStack() as stack:
stack.enter_context(
patch.object(session, "_create_stream_with_retry", side_effect=mock_stream)
)
stack.enter_context(patch.object(session, "_stream_response", side_effect=mock_response))
stack.enter_context(patch.object(session, "_execute_tools", side_effect=exec_results))
for attr, value in extra_patches.items():
stack.enter_context(patch.object(session, attr, value))
stack.enter_context(patch.object(session, "_estimated_prompt_tokens", return_value=100))
stack.enter_context(patch.object(session, "_full_messages", return_value=[]))
stack.enter_context(patch.object(session, "_update_token_table"))
stack.enter_context(patch.object(session, "_print_status_line"))
stack.enter_context(patch.object(session, "_emit_state"))
stack.enter_context(patch.object(session, "_visible_memory_count", return_value=0))
stack.enter_context(patch.object(session, "_apply_post_execute_advisories"))
stack.enter_context(patch("turnstone.core.session.save_message"))
yield
@contextlib.contextmanager
def _send_with_tool_batch(session, tool_calls, results, **extra_patches):
"""Single-batch form of :func:`_send_with_tool_batches`."""
with _send_with_tool_batches(session, [(tool_calls, results)], **extra_patches):
yield
def _tool_turn_texts(session):
return [m.text for m in session.messages if m.role is Role.TOOL]
_SPAWN_CALL = [
{
"id": "tc_spawn",
"function": {"name": "spawn_workstream", "arguments": '{"name": "child"}'},
}
]
_SPAWN_RESULT = (
'{"child_ws_id":"ws-8f3a","name":"child","node_id":"n1","routing_strategy":"least_busy"}'
)
class TestZeroBudgetDrain:
def test_structural_handle_survives_zero_budget(self, session):
"""The #883 regression: spawn_workstream's ws_id must reach the
trajectory even at a fully exhausted context budget."""
with _send_with_tool_batch(
session,
_SPAWN_CALL,
[("tc_spawn", _SPAWN_RESULT)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
tool_texts = _tool_turn_texts(session)
assert any("ws-8f3a" in t for t in tool_texts)
assert not any("dropped" in t for t in tool_texts)
def test_bulky_structural_result_floored_not_dropped(self, session):
"""A tasks list bigger than the floor keeps head+tail at zero budget."""
from turnstone.core.session import _TRUNCATION_FLOOR_CHARS
big_tasks = '{"tasks":[' + ",".join(f'{{"id":{i}}}' for i in range(800)) + "]}"
assert len(big_tasks) > _TRUNCATION_FLOOR_CHARS
calls = [{"id": "tc_t", "function": {"name": "tasks", "arguments": '{"action":"list"}'}}]
with _send_with_tool_batch(
session,
calls,
[("tc_t", big_tasks)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
(text,) = _tool_turn_texts(session)
assert text.startswith('{"tasks":[')
assert "chars truncated" in text
assert "dropped" not in text
def test_bulky_plain_result_dropped_honestly(self, session):
"""Non-structural bulky output at zero budget gets the drop notice,
never the old successful-but-trimmed impersonation."""
with _send_with_tool_batch(
session,
[{"id": "tc_f", "function": {"name": "web_fetch", "arguments": "{}"}}],
[("tc_f", "page " * 2000)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
(text,) = _tool_turn_texts(session)
assert "dropped" in text
assert "context budget exhausted" in text
assert "page" not in text
assert "[Output truncated" not in text
def test_error_result_floored_at_zero_budget(self, session):
"""A bulky error output keeps its lead: a masked failure reads as
success, which is the dishonesty #883 removes."""
err = "Error: deploy failed: " + "trace line\n" * 500
session._tool_error_flags["tc_e"] = True
with _send_with_tool_batch(
session,
[{"id": "tc_e", "function": {"name": "bash", "arguments": "{}"}}],
[("tc_e", err)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
(text,) = _tool_turn_texts(session)
assert text.startswith("Error: deploy failed:")
assert "dropped" not in text
def test_mid_drain_zeroing_still_floors_structural(self, session):
"""A bulky earlier result exhausting the budget must not zero a
structural sibling later in the same batch."""
session.tool_truncation = 100_000
calls = [
{"id": "tc_f", "function": {"name": "web_fetch", "arguments": "{}"}},
{"id": "tc_spawn", "function": {"name": "spawn_workstream", "arguments": "{}"}},
]
results = [("tc_f", "page " * 5000), ("tc_spawn", _SPAWN_RESULT)]
with _send_with_tool_batch(
session,
calls,
results,
# 100 tokens: the fetch consumes it all; the spawn arrives at 0.
_remaining_token_budget=MagicMock(return_value=100),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
spawn_texts = [t for t in _tool_turn_texts(session) if "ws-8f3a" in t]
assert spawn_texts, "structural handle was zero-dropped mid-drain"
def test_zero_budget_triggers_midturn_compact(self, session):
"""The band fix: a zero truncation budget below the owed thresholds
fires one mid-turn compaction (no threshold_pct — none was
evaluated), then re-reads the budget."""
compact = MagicMock(return_value=True)
budget = MagicMock(side_effect=[0, 5000])
with _send_with_tool_batch(
session,
[{"id": "tc_f", "function": {"name": "web_fetch", "arguments": "{}"}}],
[("tc_f", "page " * 2000)],
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
):
session.send("go")
compact.assert_called_once_with(
auto=True,
preserve_tail=1,
my_generation=session._generation,
where="mid-turn, tool-result budget exhausted",
)
assert budget.call_count == 2
# Budget recovered to 5000 tokens → the fetch is truncated normally,
# not dropped.
(text,) = _tool_turn_texts(session)
assert "dropped" not in text
assert "page" in text
def test_zero_budget_compact_skipped_when_owed_already_ran(self, session):
"""One compaction attempt per drain: the owed path already compacted,
so a still-zero budget goes straight to the floor/drop backstop."""
compact = MagicMock(return_value=True)
owed_compact = MagicMock(return_value=True)
with _send_with_tool_batch(
session,
_SPAWN_CALL,
[("tc_spawn", _SPAWN_RESULT)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=True),
_do_auto_compact=owed_compact,
):
session.send("go")
# The owed compaction is what must have suppressed the zero-budget
# attempt — not merely a latch set without compacting.
owed_compact.assert_called_once()
compact.assert_not_called()
assert any("ws-8f3a" in t for t in _tool_turn_texts(session))
def test_zero_budget_compact_bail_backstop(self, session):
"""Compaction bails (returns False) → budget stays 0 → the floor and
the honest drop notice are the backstop, and only one attempt fires."""
compact = MagicMock(return_value=False)
calls = [
{"id": "tc_spawn", "function": {"name": "spawn_workstream", "arguments": "{}"}},
{"id": "tc_f", "function": {"name": "web_fetch", "arguments": "{}"}},
]
with _send_with_tool_batch(
session,
calls,
[("tc_spawn", _SPAWN_RESULT), ("tc_f", "page " * 2000)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
):
session.send("go")
assert compact.call_count == 1
texts = _tool_turn_texts(session)
assert any("ws-8f3a" in t for t in texts)
assert any("dropped" in t for t in texts)
def test_unproductive_zero_budget_compact_fires_once_per_send(self, session):
"""An attempt that cannot clear the zero band must not re-fire on
every later tool batch of the same send: the send-scoped latch caps
the unproductive LLM summary call at one, and later batches fall
through to the floor/drop backstop."""
compact = MagicMock(return_value=False) # never clears the band
batches = [
(_SPAWN_CALL, [("tc_spawn", _SPAWN_RESULT)]),
(
[{"id": "tc_f", "function": {"name": "web_fetch", "arguments": "{}"}}],
[("tc_f", "page " * 2000)],
),
(
[{"id": "tc_f2", "function": {"name": "web_fetch", "arguments": "{}"}}],
[("tc_f2", "page " * 2000)],
),
]
with _send_with_tool_batches(
session,
batches,
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
):
session.send("go")
assert compact.call_count == 1
# The backstop still held for every batch: handle admitted, bulky
# results dropped honestly.
texts = _tool_turn_texts(session)
assert any("ws-8f3a" in t for t in texts)
assert sum("dropped" in t for t in texts) == 2
def test_productive_compact_rearms_after_budget_rezeroes(self, session):
"""A compaction that RECOVERS the budget does not latch: when later
batches genuinely re-exhaust it there is new content to fold, so one
fresh attempt is warranted — and an unproductive second attempt then
latches for the rest of the send."""
compact = MagicMock(return_value=True)
# batch 1: read 0 → compact → re-read 5000 (recovered; no latch)
# batch 2: read 0 → compact → re-read 0 (unproductive; latch)
# batch 3: read 0 → latched, no third attempt
budget = MagicMock(side_effect=[0, 5000, 0, 0, 0, 0, 0])
batches = [
(
[{"id": f"tc_f{i}", "function": {"name": "web_fetch", "arguments": "{}"}}],
[(f"tc_f{i}", "page " * 2000)],
)
for i in range(3)
]
with _send_with_tool_batches(
session,
batches,
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
):
session.send("go")
assert compact.call_count == 2
def test_small_results_admitted_from_grace_pool(self, session):
"""Small non-structural results (denials, acks) pass verbatim at
zero budget, funded by the per-batch grace pool."""
calls = [
{"id": "tc_d", "function": {"name": "bash", "arguments": "{}"}},
{"id": "tc_a", "function": {"name": "bash", "arguments": "{}"}},
]
results = [
("tc_d", "Denied: operator rejected the command"),
("tc_a", "Started background shell shell-4f2e (pid 1234)"),
]
with _send_with_tool_batch(
session,
calls,
results,
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
texts = _tool_turn_texts(session)
assert "Denied: operator rejected the command" in texts
assert "Started background shell shell-4f2e (pid 1234)" in texts
def test_grace_pool_bounds_collective_admission(self, session):
"""A wide batch of small results cannot collectively bypass budget
accounting: once the per-batch pool is spent, further non-structural
results get the honest drop notice."""
from turnstone.core.session import _ZERO_BUDGET_VERBATIM_POOL_CHARS
calls = [
{"id": f"tc_{i}", "function": {"name": "web_fetch", "arguments": "{}"}}
for i in range(4)
]
results = [(f"tc_{i}", chr(ord("A") + i) * 1800) for i in range(4)]
assert 2 * 1800 <= _ZERO_BUDGET_VERBATIM_POOL_CHARS < 3 * 1800
with _send_with_tool_batch(
session,
calls,
results,
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
texts = _tool_turn_texts(session)
assert "A" * 1800 in texts
assert "B" * 1800 in texts
assert sum("dropped" in t for t in texts) == 2
def test_grace_pool_resets_per_batch(self, session):
"""The grace pool is per-batch: a later tool batch in the same send
gets a fresh allowance."""
batches = [
(
[
{"id": f"tc_{b}_{i}", "function": {"name": "web_fetch", "arguments": "{}"}}
for i in range(2)
],
[(f"tc_{b}_{i}", f"{b}{i}" * 900) for i in range(2)],
)
for b in range(2)
]
with _send_with_tool_batches(
session,
batches,
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
texts = _tool_turn_texts(session)
assert len(texts) == 4
assert not any("dropped" in t for t in texts)
def test_marginal_recovery_thrash_capped(self, session):
"""A compaction that keeps landing the budget marginally positive
(fixed overhead hovering just under the zero line) must not pay an
LLM summary call on every batch: the attempt counter caps it."""
compact = MagicMock(return_value=True)
budget = MagicMock(side_effect=[0, 400, 0, 400, 0, 0, 0, 0, 0])
batches = [
(
[{"id": f"tc_f{i}", "function": {"name": "web_fetch", "arguments": "{}"}}],
[(f"tc_f{i}", "page " * 2000)],
)
for i in range(4)
]
with _send_with_tool_batches(
session,
batches,
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
):
session.send("go")
assert compact.call_count == 2
def test_structural_floor_set_matches_coordinator_catalog(self, session):
"""Every name in the floor set must be a registered coordinator
tool: a typo or a tool rename that drops a member would silently
remove that handle's zero-budget floor and re-open the #883 wedge
with a green suite."""
from turnstone.core.session import _STRUCTURAL_FLOOR_TOOLS
from turnstone.core.tools import COORDINATOR_TOOLS
coordinator_names = {t["function"]["name"] for t in COORDINATOR_TOOLS}
assert coordinator_names >= _STRUCTURAL_FLOOR_TOOLS
def test_spawn_batch_and_wait_survive_zero_budget(self, session):
"""The two floor-set members without dedicated coverage: batch spawn
handles and wait resolutions must reach the trajectory at zero
budget like spawn_workstream's."""
batch_json = (
'{"results":{"0":{"child_ws_id":"ws-b1"},"1":{"child_ws_id":"ws-b2"}},"denied":[]}'
)
wait_json = '{"complete":true,"elapsed":4.2,"results":{"ws-b1":{"state":"idle"}}}'
calls = [
{"id": "tc_b", "function": {"name": "spawn_batch", "arguments": "{}"}},
{"id": "tc_w", "function": {"name": "wait_for_workstream", "arguments": "{}"}},
]
with _send_with_tool_batch(
session,
calls,
[("tc_b", batch_json), ("tc_w", wait_json)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=MagicMock(return_value=False),
):
session.send("go")
texts = _tool_turn_texts(session)
assert any("ws-b1" in t and "ws-b2" in t for t in texts)
assert any('"complete":true' in t for t in texts)
assert not any("dropped" in t for t in texts)
+191 -3
View File
@@ -472,6 +472,47 @@ _active_shell_owner: contextvars.ContextVar[str | None] = contextvars.ContextVar
_REPEAT_EXEMPT_TOOLS: frozenset[str] = frozenset({"bash_output"})
# Tools whose results are orchestration *handles* — control-determining state
# (a spawned child's ws_id, the task scratchpad, wait resolutions) the model
# cannot re-derive once dropped. At an exhausted context budget these cross
# truncation with a guaranteed floor instead of the zero-budget drop: losing
# tens of bytes of handle wedges the whole orchestration loop (#883 — a
# coordinator can never ``wait_for_workstream`` a child whose ws_id it never
# saw), while admitting a floored result costs at most
# ``_TRUNCATION_FLOOR_CHARS`` against a safety margin the pre-send
# ``_over_hard`` guard enforces anyway. Only builtin names belong here: MCP
# tools are namespaced ``mcp__{server}__{tool}`` and can never collide.
# Background-bash spawn acks carry a shell_id handle too, but share the
# "bash" name with foreground bash (whose huge outputs MUST stay
# budget-truncated); they are covered by the drain's per-batch zero-budget
# grace pool instead — see #891 before widening this set.
_STRUCTURAL_FLOOR_TOOLS: frozenset[str] = frozenset(
{"spawn_workstream", "spawn_batch", "wait_for_workstream", "tasks"}
)
# The guaranteed admission size (chars, ~512 tokens at 4 chars/token): the
# floor granted to structural-tool and error results, and the per-result
# ceiling on the zero-budget verbatim grace pool below.
# docs/architecture.md ("Tool Output Truncation") enumerates the floor
# tools and these sizes in prose — keep it in sync when either changes.
_TRUNCATION_FLOOR_CHARS: int = 2048
# Per-BATCH grace pool (chars) funding verbatim admission of small
# NON-structural results at zero budget (denial notices, spawn acks —
# destroying a result smaller than the ~310-char drop notice is a net
# loss). Bounded so a wide batch of small results cannot collectively
# bypass the per-output budget bookkeeping: once the pool is spent,
# further results get the honest drop notice (~6x smaller than the
# floor). Structural/error floors do not draw from it.
_ZERO_BUDGET_VERBATIM_POOL_CHARS: int = 2 * _TRUNCATION_FLOOR_CHARS
# Hard cap on zero-budget mid-turn compaction attempts per send(). An
# UNPRODUCTIVE attempt (budget still exhausted after) jumps straight to
# the cap; productive attempts increment toward it, so a session whose
# post-compact fixed overhead (system prompt + tool defs + summary)
# hovers just under the zero line cannot pay an LLM summary call on
# every tool batch — the marginal-recovery thrash regime. 2 = one
# genuine recover-then-re-exhaust cycle per send.
_ZERO_BUDGET_COMPACT_CAP_PER_SEND: int = 2
# ONE source of truth for recognized boolean-arg strings — both coercers
# below derive from these, so a new provider quirk added here reaches every
# tool at once instead of drifting per-tool.
@@ -3416,22 +3457,66 @@ class ChatSession:
self._print_status_line()
return compacted
def _truncate_output(self, output: str, remaining_budget_tokens: int | None = None) -> str:
def _truncate_output(
self,
output: str,
remaining_budget_tokens: int | None = None,
floor_chars: int = 0,
) -> str:
"""Truncate tool output, keeping head + tail.
The effective limit is the *minimum* of:
- ``self.tool_truncation`` (fixed cap, defaults to 50% of context)
- ``remaining_budget_tokens`` converted to chars (if provided)
raised to at least ``floor_chars`` when given. The floor is the
guaranteed admission size for results the orchestration cannot lose
(structural handles, error dispositions the drain loop decides);
it deliberately wins over BOTH the budget and an operator-set cap,
because a lost ws_id or masked failure wedges the session outright
(#883) while an admitted floor costs ~512 tokens against a margin
the pre-send ``_over_hard`` guard enforces regardless.
This ensures a single tool result cannot overflow the context window
even when the conversation is already partially full.
A non-positive limit (context budget exhausted reachable only via
the budget arm; ``tool_truncation`` is always positive) replaces the
output with an explicit drop notice. Small results are usually
still admitted at zero budget, but that is the DRAIN's decision, not
this function's: the caller funds them through ``floor_chars`` from
a bounded per-batch grace pool, so collective verbatim admission
stays accounted (an unconditioned small-pass here let a wide batch
of small results bypass the per-output bookkeeping entirely).
The notice must never read as a
successful-but-trimmed call: the earlier placeholder did, and a
coordinator that "successfully" spawned a child while its ws_id was
silently destroyed stalled unrecoverably (#883). It states what the
shell knows the call ran; the output is gone so the model
neither re-fires side-effecting calls nor waits on results it will
never see. Full receipts are not preserved for re-derivation
(deliberate see #883 discussion); re-running after compaction is
the recovery path for read-only calls.
"""
if not output:
# Nothing to truncate and nothing to account: an empty result
# always passes. Without this, a zero-budget batch would
# replace a 0-char result with the ~310-char drop notice —
# false ("none of it could be added") and net-negative.
return output
limit = self.tool_truncation
if remaining_budget_tokens is not None:
budget_chars = int(remaining_budget_tokens * self._chars_per_token)
limit = min(limit, budget_chars)
limit = max(limit, floor_chars)
if limit <= 0:
return f"[Output truncated — {len(output)} chars exceeded context budget]"
return (
f"Error: tool result dropped — context budget exhausted. The call ran "
f"and produced a {len(output)}-char result, but none of it could be "
f"added to the conversation. Do not assume the call failed and do not "
f"re-run side-effecting calls. Compact or wrap up; re-issue read-only "
f"calls afterwards if their output is still needed."
)
if len(output) <= limit:
return output
half = limit // 2
@@ -6116,6 +6201,23 @@ class ChatSession:
)
if self._generation != my_generation:
return
# Send-scoped attempt counter for the zero-budget compaction
# trigger in the tool-result drain below, capped at
# ``_ZERO_BUDGET_COMPACT_CAP_PER_SEND``. Each drain pass resets
# ``pre_attempted_compact``, so without send-scoped state a
# wedged turn re-pays the LLM summary call every tool batch.
# An UNPRODUCTIVE attempt (budget still exhausted after) jumps
# straight to the cap; a productive one increments toward it,
# so a genuine recover-then-re-exhaust still earns one fresh
# attempt while the marginal-recovery thrash regime (fixed
# overhead hovering just under the zero line, every attempt
# "productive" by a hair) is bounded at the cap all the same.
# Deliberately a LOCAL, not an instance attribute: the
# generation-swap ``return``s inside the loop would skip any
# end-of-send clear, and stale instance state would suppress a
# legitimate compaction on the NEXT send. Dying with the call
# frame is the reset.
zero_budget_compact_attempts = 0
while True:
self._check_cancelled(my_generation)
msgs = self._prepare_wire_messages(self._full_messages())
@@ -6323,11 +6425,93 @@ class ChatSession:
self._do_auto_compact("mid-turn", preserve_tail=1, my_generation=my_generation)
pre_attempted_compact = True
truncation_budget = self._remaining_token_budget()
if (
truncation_budget <= 0
and not pre_attempted_compact
and zero_budget_compact_attempts < _ZERO_BUDGET_COMPACT_CAP_PER_SEND
and self._generation == my_generation
):
# Zero tool-result budget wedges the loop BELOW the owed
# thresholds: with max_tokens ≥ context_window/4 the
# response reserve zeroes the budget near 70% fullness,
# well under auto_compact_pct, and a stalled model
# appends too little to ever cross it — so without this
# trigger the session can sit in the zero band
# indefinitely while every tool result is floored or
# dropped (#883). Zero budget is itself
# compaction-owed evidence. ``auto=True`` WITHOUT
# ``threshold_pct``, exactly like the ctx-overflow
# retry: no threshold was evaluated, so the notice must
# not claim one. Bounded per send by the attempt
# counter; if compaction cannot clear the band the
# floor/drop-notice path below is the backstop.
self._compact_messages(
auto=True,
preserve_tail=1,
my_generation=my_generation,
where="mid-turn, tool-result budget exhausted",
)
self._print_status_line()
pre_attempted_compact = True
zero_budget_compact_attempts += 1
truncation_budget = self._remaining_token_budget()
if truncation_budget <= 0:
# The attempt didn't clear the band (bail, or the
# post-compact floor of system prompt + summary +
# tool defs alone exceeds the zero threshold on
# this window) — retrying cannot help, so burn the
# remaining attempts for this send.
zero_budget_compact_attempts = _ZERO_BUDGET_COMPACT_CAP_PER_SEND
_truncated: dict[str, str] = {}
# Per-batch grace pool: small NON-structural results are
# admitted verbatim at zero budget by funding their own
# size as the floor, until the pool is spent — bounding
# THIS door's collective admission where an unconditioned
# small-pass would let N small results bypass the
# per-output bookkeeping below entirely. The pool bounds
# only the small-result door; the structural/error floors
# below are per-result by design (ruling at their site).
zero_budget_verbatim_pool = _ZERO_BUDGET_VERBATIM_POOL_CHARS
for tc_id, output in results:
if isinstance(output, str):
# Structural handles and error dispositions get the
# guaranteed floor: neither may be zero-dropped (a
# lost ws_id stalls orchestration, a masked failure
# reads as success — #883). ``_tool_error_flags``
# is still populated here; the per-result loop
# below pops it to build the persisted turn.
# DELIBERATELY per-result, with NO aggregate cap
# (unlike the grace pool): capping structural
# floors would re-open #883 for wide fan-outs
# (every parallel spawn's handle is needed or its
# child orphans), and capping error floors would
# mask failures behind a success-leaning notice —
# inviting the blind re-runs #865/#866 exist to
# prevent. Worst case is bounded by the model's
# own batch width and lands on the pre-send
# ``_over_hard`` guard / ctx-overflow retry: one
# extra compaction round-trip, traded for never
# losing a handle or a disposition.
_floor = (
_TRUNCATION_FLOOR_CHARS
if (
_tc_names.get(tc_id, "") in _STRUCTURAL_FLOOR_TOOLS
or self._tool_error_flags.get(tc_id, False)
)
else 0
)
if (
_floor == 0
and truncation_budget <= 0
and len(output) <= _TRUNCATION_FLOOR_CHARS
and zero_budget_verbatim_pool >= len(output)
):
_floor = len(output)
zero_budget_verbatim_pool -= len(output)
truncated = self._truncate_output(
output, remaining_budget_tokens=truncation_budget
output,
remaining_budget_tokens=truncation_budget,
floor_chars=_floor,
)
_truncated[tc_id] = truncated
truncation_budget = max(
@@ -9914,6 +10098,10 @@ class ChatSession:
"skills": self._prepare_skills,
# Coordinator tools: only reachable when this session was
# constructed with kind="coordinator" (COORDINATOR_TOOLS set).
# A new tool whose result is an orchestration HANDLE (ws_id,
# task scratchpad, wait resolution) must also join
# ``_STRUCTURAL_FLOOR_TOOLS`` or it loses its zero-budget
# truncation floor and re-opens the #883 wedge.
"spawn_workstream": self._prepare_spawn_workstream,
"spawn_batch": self._prepare_spawn_batch,
"close_all_children": self._prepare_close_all_children,