mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user