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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 74 additions & 31 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15757,9 +15757,10 @@ async def _handle_offload(self) -> None:
prior_event = state_values.get("_summarization_event")
before_messages = state_values.get("messages", [])
prior_cutoff = _summarization_cutoff(prior_event)
tokens_before = count_tokens_approximately(
conversation_tokens_before = count_tokens_approximately(
_effective_conversation(before_messages, prior_event)
)
reported_tokens_before = _persisted_context_tokens(state_values)

# Own the seeded tool-call id here so a failed run can clean up the
# committed-but-unanswered seed (see `_remove_unanswered_offload_seed`).
Expand Down Expand Up @@ -15859,27 +15860,54 @@ async def _handle_offload(self) -> None:
if isinstance(new_event, dict)
else getattr(new_event, "file_path", None)
)
# Recompute the post-offload size from the ORIGINAL pre-seed
# messages plus the new event. `_effective_conversation` yields
# `[summary, *before_messages[new_cutoff:]]` — the compacted
# conversation without the tool's own machinery (the seeded tool
# call, the tool result, and the trailing model turn), all of which
# land in `new_state["messages"]` at/after `new_cutoff`. Counting
# `before_messages` keeps this token figure consistent with the
# message counts below and avoids understating the reduction.
#
# This is a client-side approximation for the status bar and is
# deliberately not the persisted `_context_tokens` (refreshed from
# the trailing turn's real provider usage, which includes
# system/tool overhead and the machinery messages). The two can
# differ, and if the trailing turn failed `_context_tokens` keeps
# its pre-offload value.
tokens_after = count_tokens_approximately(
# Recompute the post-offload conversation from the original pre-seed
# messages plus the new event. This excludes the compact tool's own
# machinery while preserving the provider-reported system/tool overhead
# from the last ordinary turn when that total is available.
conversation_tokens_after = count_tokens_approximately(
_effective_conversation(before_messages, new_event)
)
# Message and turn counts are likewise derived purely from the
# absolute cutoffs, so those same machinery artifacts are never
# mistaken for kept conversation.
if reported_tokens_before:
# Subtract the *delta* from the provider total rather than
# rebuilding the total as `overhead + conversation_after`. The two
# are algebraically equal, but only this form keeps both figures on
# the provider's scale: `count_tokens_approximately` need only
# overshoot the provider count by a token for an
# `overhead = max(0, reported - conversation_before)` clamp to
# collapse the overhead to zero, which would silently report the
# whole system prompt and tool schema as freed context.
#
# The estimator's error appears with opposite signs in the two
# conversation counts and largely cancels in the difference, which
# is why `before` prints exact and only `after` carries a `~`.
tokens_before = reported_tokens_before
tokens_after = max(
0,
reported_tokens_before
- (conversation_tokens_before - conversation_tokens_after),
)
usage_label = "Context"
before = format_token_count(tokens_before)
after = f"~{format_token_count(tokens_after)}"
else:
# No usable provider total (never set, or a checkpoint value
# `_persisted_context_tokens` rejected), so fall back to a
# conversation-only estimate. `usage_label` is what tells the user
# which metric they are reading: "Conversation" excludes the
# system/tool overhead that "Context" includes, so the two
# percentages are not comparable across offloads.
tokens_before = conversation_tokens_before
tokens_after = conversation_tokens_after
usage_label = "Conversation"
before = f"~{format_token_count(tokens_before)}"
after = f"~{format_token_count(tokens_after)}"

# Message and turn counts are derived purely from the absolute cutoffs
# into the ORIGINAL pre-seed `before_messages`, never from the post-run
# `new_state["messages"]`. The compact tool's own machinery (the seeded
# tool call, its result, and the trailing model turn) lands at/after
# `new_cutoff` in the post-run list, so slicing that list instead would
# count those artifacts as kept conversation.
messages_offloaded = max(0, new_cutoff - prior_cutoff)
messages_kept = max(0, len(before_messages) - new_cutoff)
turns_offloaded = sum(
Expand All @@ -15896,23 +15924,39 @@ async def _handle_offload(self) -> None:
kept_message_label = "message" if messages_kept == 1 else "messages"
offloaded_turn_label = "turn" if turns_offloaded == 1 else "turns"
kept_turn_label = "turn" if turns_kept == 1 else "turns"
# Floored at zero: a summary can come out larger than the messages it
# replaced, and in the reported branch `tokens_after` mixes an exact
# provider total with an estimated delta. Neither should ever render as
# a negative "decrease".
pct = (
round((tokens_before - tokens_after) / tokens_before * 100)
max(0, round((tokens_before - tokens_after) / tokens_before * 100))
if tokens_before > 0
else 0
)

before = format_token_count(tokens_before)
after = format_token_count(tokens_after)
offloaded_counts = (
f"{messages_offloaded} older {offloaded_message_label} "
f"({turns_offloaded} conversation {offloaded_turn_label})"
)
stats_line = (
f"Context: {before} → {after} tokens ({pct}% decrease), "
f"{messages_kept} {kept_message_label} "
f"({turns_kept} conversation {kept_turn_label}) kept."
)
if tokens_after <= tokens_before:
stats_line = (
f"{usage_label}: {before} → {after} tokens ({pct}% decrease), "
f"{messages_kept} {kept_message_label} "
f"({turns_kept} conversation {kept_turn_label}) kept."
)
outcome = (
f"Offloaded {offloaded_counts}, freeing up context window space."
)
else:
stats_line = (
f"{usage_label}: {before} → {after} tokens (increase), "
f"{messages_kept} {kept_message_label} "
f"({turns_kept} conversation {kept_turn_label}) kept."
)
outcome = (
f"Offloaded {offloaded_counts}, but the summary was larger "
"than the messages it replaced, so context increased."
)
if archive_path:
from deepagents_code.offload import offload_storage_is_ephemeral

Expand All @@ -15929,8 +15973,7 @@ async def _handle_offload(self) -> None:
)
await self._mount_message(
AppMessage(
f"Offloaded {offloaded_counts}, freeing up context window "
f"space.\n{stats_line}{caveat}",
f"{outcome}\n{stats_line}{caveat}",
),
)
else:
Expand All @@ -15940,7 +15983,7 @@ async def _handle_offload(self) -> None:
# separate warning immediately followed by a success line.
await self._mount_message(
ErrorMessage(
f"Offloaded {offloaded_counts} and freed context, but the "
f"{outcome} The "
"conversation history could not "
"be saved to storage, so those messages are not "
f"recoverable. Check logs for details.\n{stats_line}",
Expand Down
169 changes: 168 additions & 1 deletion libs/code/tests/unit_tests/test_offload.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ async def test_offload_shows_feedback_message(self) -> None:
"6 messages (3 conversation turns) kept" in str(widget._content)
for widget in msgs
)
# No provider total was persisted, so the report is conversation-only.
assert any("Conversation: ~" in str(w._content) for w in msgs)

async def test_kept_turns_ignore_tools_and_internal_messages(self) -> None:
"""Turn counts should skip AI/tool rows and internal humans on both sides."""
Expand Down Expand Up @@ -620,6 +622,170 @@ async def test_offload_updates_context_tokens(self) -> None:

assert app._context_tokens == expected

async def test_offload_preserves_fixed_overhead_in_context_report(self) -> None:
"""Provider totals should keep fixed overhead in the post-offload estimate."""
from langchain_core.messages.utils import count_tokens_approximately

from deepagents_code.app import _effective_conversation

app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
_setup_server_offload_app(app)

before_messages = _make_dict_messages(10)
after_event = _summary_event(4)
conversation_before = count_tokens_approximately(before_messages)
conversation_after = count_tokens_approximately(
_effective_conversation(before_messages, after_event)
)
fixed_tokens = 50_000
reported_before = conversation_before + fixed_tokens
expected_after = conversation_after + fixed_tokens
before = _state_values(before_messages)
before["_context_tokens"] = reported_before
after = _state_values(
[*before_messages, *_make_dict_messages(2)], after_event
)

with (
patch.object(
app,
"_get_thread_state_values",
new_callable=AsyncMock,
side_effect=[before, after],
),
patch.object(
app,
"_drive_server_side_compaction",
new_callable=AsyncMock,
return_value=None,
),
):
await app._handle_offload()
await pilot.pause()

expected_report = (
f"Context: {format_token_count(reported_before)} → "
f"~{format_token_count(expected_after)} tokens"
)
assert any(
expected_report in str(widget._content)
for widget in app.query(AppMessage)
)
assert app._context_tokens == expected_after
assert app._tokens_approximate is True

async def test_offload_report_stays_on_provider_scale_when_total_is_low(
self,
) -> None:
"""A provider total below the local estimate must not free fixed overhead.

When `_context_tokens` is stale (or the approximation overshoots), the
reported total can fall below `conversation_tokens_before`. The report must
still subtract only the conversation *delta*, keeping both figures on the
provider's scale -- rebuilding the after-figure as
`max(0, reported - conversation_before) + conversation_after` would collapse
the overhead to zero and credit the offload with freeing the whole system
prompt and tool schema.
"""
from langchain_core.messages.utils import count_tokens_approximately

from deepagents_code.app import _effective_conversation

app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
_setup_server_offload_app(app)

before_messages = _make_dict_messages(10)
after_event = _summary_event(4)
conversation_before = count_tokens_approximately(before_messages)
conversation_after = count_tokens_approximately(
_effective_conversation(before_messages, after_event)
)
# Stale/low provider total: below the local conversation estimate.
reported_before = conversation_before // 2
expected_after = reported_before - (
conversation_before - conversation_after
)
assert expected_after > 0, "fixture should not exercise the zero floor"
before = _state_values(before_messages)
before["_context_tokens"] = reported_before
after = _state_values(
[*before_messages, *_make_dict_messages(2)], after_event
)

with (
patch.object(
app,
"_get_thread_state_values",
new_callable=AsyncMock,
side_effect=[before, after],
),
patch.object(
app,
"_drive_server_side_compaction",
new_callable=AsyncMock,
return_value=None,
),
):
await app._handle_offload()
await pilot.pause()

contents = [str(widget._content) for widget in app.query(AppMessage)]
expected_report = (
f"Context: {format_token_count(reported_before)} → "
f"~{format_token_count(expected_after)} tokens"
)
assert any(expected_report in content for content in contents)
# The overhead was never treated as freed, so the reduction stays
# modest rather than approaching 100%.
assert not any("(100% decrease)" in content for content in contents)
assert app._context_tokens == expected_after

async def test_offload_reports_oversized_summary_as_increase(self) -> None:
"""A summary larger than what it replaced is reported as an increase."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
_setup_server_offload_app(app)

before_messages = _make_dict_messages(10)
# A summary far longer than the four messages it replaces, so
# `tokens_after` exceeds `tokens_before`.
after_event = _summary_event(4)
after_event["summary_message"]["content"] = "verbose summary " * 500
before = _state_values(before_messages)
after = _state_values(
[*before_messages, *_make_dict_messages(2)], after_event
)

with (
patch.object(
app,
"_get_thread_state_values",
new_callable=AsyncMock,
side_effect=[before, after],
),
patch.object(
app,
"_drive_server_side_compaction",
new_callable=AsyncMock,
return_value=None,
),
):
await app._handle_offload()
await pilot.pause()

contents = [str(widget._content) for widget in app.query(AppMessage)]
assert any("Offloaded " in content for content in contents)
assert any("(increase)" in content for content in contents)
assert not any(
"freeing up context window space" in content for content in contents
)
assert any("context increased" in content for content in contents)

async def test_no_ui_clear_reload(self) -> None:
"""Should NOT clear/reload UI since messages stay in state."""
app = DeepAgentsApp()
Expand Down Expand Up @@ -981,7 +1147,8 @@ async def test_missing_archive_path_warns_about_unrecoverable_history(
# ErrorMessage. The turn parenthetical and the trailing stats line are
# asserted too, so the error path cannot silently keep older wording.
assert any(
"Offloaded 4 older messages (2 conversation turns) and freed context"
"Offloaded 4 older messages (2 conversation turns), "
"freeing up context window space."
in str(widget._content)
and "could not be saved to storage" in str(widget._content)
and "conversation turns) kept." in str(widget._content)
Expand Down