diff --git a/gateway/run.py b/gateway/run.py index 6f5fa65a57f..6fc7ded69db 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4b366aa51ac..cd1761bf862 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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 diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index 88e53e212ef..e8e8d582887 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -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"