fix(gateway): pass full transcript to compressor instead of filtered messages (#58551)

Both gateway compression entry points (session-hygiene auto-compress in
run.py; manual /compress in slash_commands.py) filtered the transcript
to user/assistant-only, content-bearing messages before calling
_compress_context. That starved the compressor:

- tool results are usually the bulk of the context, and
  _prune_old_tool_results never saw them
- short filtered histories tripped the protect-first/last early-return,
  so compression became a no-op even on huge sessions
- assistant tool_calls stubs (content=None) were dropped, so even the
  summary lost the tool activity

Pass user/assistant/tool messages through intact, matching what the
agent loop itself feeds _compress_context.

Port of PR #3854 onto current main (the manual-compress handler moved
from run.py to slash_commands.py since the PR branched); regression test
asserts tool messages reach the compressor.

Authored-by: David Zhang <david.d.zhang@gmail.com> (@Git-on-my-level)

Co-authored-by: David Zhang <david.d.zhang@gmail.com>
This commit is contained in:
Teknium 2026-07-05 00:43:48 -07:00 committed by GitHub
parent d810ff2f2b
commit 485ae54c9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 7 deletions

View file

@ -10794,11 +10794,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
user_config=_hyg_data if isinstance(_hyg_data, dict) else None,
)
if _hyg_runtime.get("api_key"):
# Pass the FULL transcript (tool results included).
# Filtering to user/assistant-only starved the
# compressor: tool results are usually the bulk of
# the context, _prune_old_tool_results never saw
# them, and short filtered histories tripped the
# protect-first/last early-return so nothing was
# compressed at all (#3854). The agent loop passes
# its full message list to _compress_context — the
# gateway now matches.
_hyg_msgs = [
{"role": m.get("role"), "content": m.get("content")}
for m in history
if m.get("role") in {"user", "assistant"}
and m.get("content")
m for m in history
if m.get("role") in {"user", "assistant", "tool"}
]
if len(_hyg_msgs) >= 4:

View file

@ -3140,10 +3140,14 @@ class GatewaySlashCommandsMixin:
if not runtime_kwargs.get("api_key"):
return t("gateway.compress.no_provider")
# Pass the FULL transcript (tool results included) — same
# rationale as the session-hygiene auto-compress in
# gateway/run.py (#3854): filtering to user/assistant-only
# starves the compressor's tool-result pruning and can trip the
# protect-first/last early-return on short filtered histories.
msgs = [
{"role": m.get("role"), "content": m.get("content")}
for m in history
if m.get("role") in {"user", "assistant"} and m.get("content")
m for m in history
if m.get("role") in {"user", "assistant", "tool"}
]
# Boundary-aware split: only the head is summarized; the most

View file

@ -483,3 +483,51 @@ async def test_compress_command_overrides_stale_resolver_identity():
# Source-derived identity overrides the stale resolver values, passed once.
assert kwargs["platform"] == "telegram"
assert kwargs["gateway_session_key"] == runner._session_key_for_source(_make_source())
@pytest.mark.asyncio
async def test_compress_command_passes_tool_messages_to_compressor():
"""Tool results must reach _compress_context (#3854).
Filtering the transcript to user/assistant-only starved the
compressor's tool-result pruning — tool messages are usually the bulk
of the context.
"""
history = [
{"role": "user", "content": "run it"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "x", "arguments": "{}"}}],
},
{"role": "tool", "content": "BIG RESULT " * 50, "tool_call_id": "t1"},
{"role": "assistant", "content": "done"},
{"role": "user", "content": "thanks"},
{"role": "assistant", "content": "np"},
]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100),
):
await runner._handle_compress_command(_make_event())
args, _kwargs = agent_instance._compress_context.call_args
passed = args[0]
roles = [m.get("role") for m in passed]
assert "tool" in roles, f"tool messages filtered out: {roles}"
# Assistant tool_calls stubs (content=None) must survive too, or the
# tool message would dangle without its call.
assert any(m.get("tool_calls") for m in passed), "assistant tool_calls stub dropped"