From b5d5d5115026fcbaeb62fb90e0a333730075b2ea Mon Sep 17 00:00:00 2001 From: 2301_80247084 <3301767269@qq.com> Date: Mon, 10 Aug 2026 13:20:20 +0800 Subject: [PATCH] fix(compress): count tool definitions toward compression trigger trigger_add_messages compared only message tokens against tokens_threshold, so a context whose tool definitions already consumed most of the window could stay over the model limit even after compression ran (#1506). Add the tool-definition token count (context.get_tools() + token_counter.count_tools) to the trigger decision. The accessor and the counter result are guarded: contexts without get_tools() and counters without count_tools() behave exactly as before. Closes #1506. Co-Authored-By: AtomCode (deepseek-v4-flash) --- .../compressor/current_round_compressor.py | 12 +++ .../test_current_round_compressor.py | 80 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/openjiuwen/core/context_engine/processor/compressor/current_round_compressor.py b/openjiuwen/core/context_engine/processor/compressor/current_round_compressor.py index 17ea06bdd..de00e285a 100644 --- a/openjiuwen/core/context_engine/processor/compressor/current_round_compressor.py +++ b/openjiuwen/core/context_engine/processor/compressor/current_round_compressor.py @@ -690,6 +690,18 @@ async def trigger_add_messages( token_counter, self.processor_type(), ) + # Tool definitions occupy context-window slots on every request; count + # them too so compression triggers before messages+tools exceed the + # threshold (otherwise compression can leave the context still over + # the model limit — #1506). Guard both the accessor and the result: + # some contexts do not expose get_tools(), and lightweight token + # counters may only implement count_messages. + tools = getattr(context, "get_tools", None) + tools = tools() if callable(tools) else [] + if token_counter is not None and tools: + tools_tokens = token_counter.count_tools(tools) + if isinstance(tools_tokens, int): + tokens += tools_tokens if tokens > self._token_threshold: logger.info( f"[{self.processor_type()} triggered] context tokens {tokens} " diff --git a/tests/unit_tests/core/context_engine/test_current_round_compressor.py b/tests/unit_tests/core/context_engine/test_current_round_compressor.py index 41a7ca81a..5cf0c52fb 100644 --- a/tests/unit_tests/core/context_engine/test_current_round_compressor.py +++ b/tests/unit_tests/core/context_engine/test_current_round_compressor.py @@ -446,3 +446,83 @@ async def test_multi_compress_reinjects_only_team_calls_from_selected_span(self) assert "Task #T-7: write docs Status: claimed" in reinjected.content assert "outside selected span" not in reinjected.content + + +# --------------------------------------------------------------------------- +# #1506: tool definitions count toward the compression trigger threshold +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tools_overhead_counts_toward_trigger_threshold(): + """Messages below the threshold but messages+tools above it must trigger. + + Regression for #1506: the trigger previously counted message tokens only, + so a context whose tool definitions already consumed most of the window + could stay over the model limit even after compression ran. + """ + class _ToolsCounter: + def __init__(self): + self.count_messages_calls = 0 + self.count_tools_calls = 0 + + def count_messages(self, messages, **kwargs): + self.count_messages_calls += 1 + # messages alone stay under the threshold + return 10 * len(messages) + + def count_tools(self, tools, **kwargs): + self.count_tools_calls += 1 + return 500 # tool definitions dominate the window + + tools_counter = _ToolsCounter() + with patch( + "openjiuwen.core.context_engine.processor.compressor.current_round_compressor.Model", + MagicMock(return_value=MagicMock()), + ): + config = CurrentRoundCompressorConfig( + tokens_threshold=200, + messages_to_keep=1, + ) + compressor = CurrentRoundCompressor(config) + + ctx = MagicMock() + # 6 messages -> 60 tokens from messages; +500 tools => 560 > 200. + ctx.__len__ = MagicMock(return_value=6) + ctx.token_counter = MagicMock(return_value=tools_counter) + ctx.get_messages = MagicMock(return_value=[UserMessage(content="x")] * 6) + ctx.get_tools = MagicMock(return_value=[MagicMock()]) + + triggered = await compressor.trigger_add_messages(ctx, []) + assert triggered is True + assert tools_counter.count_tools_calls == 1 + + +@pytest.mark.asyncio +async def test_tools_overhead_without_tools_keeps_old_behavior(): + """No tools registered -> trigger decision is unchanged (messages only).""" + class _Counter: + def count_messages(self, messages, **kwargs): + return 10 * len(messages) + + def count_tools(self, tools, **kwargs): + raise AssertionError("count_tools must not be called without tools") + + counter = _Counter() + with patch( + "openjiuwen.core.context_engine.processor.compressor.current_round_compressor.Model", + MagicMock(return_value=MagicMock()), + ): + config = CurrentRoundCompressorConfig( + tokens_threshold=200, + messages_to_keep=1, + ) + compressor = CurrentRoundCompressor(config) + + ctx = MagicMock() + ctx.__len__ = MagicMock(return_value=6) + ctx.token_counter = MagicMock(return_value=counter) + ctx.get_messages = MagicMock(return_value=[UserMessage(content="x")] * 6) + ctx.get_tools = MagicMock(return_value=[]) + + triggered = await compressor.trigger_add_messages(ctx, []) + assert triggered is False