diff --git a/docs/architecture.md b/docs/architecture.md index fb0d5406..5e9314f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/tests/test_tool_truncation.py b/tests/test_tool_truncation.py index b9bd1f11..6a45c97b 100644 --- a/tests/test_tool_truncation.py +++ b/tests/test_tool_truncation.py @@ -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) diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 230e790d..4c3652dc 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -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,