From cc1725cbe50feef6d452bddc42784effae4373e5 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:17:12 +0300 Subject: [PATCH 001/552] fix(moa): stop reference_max_tokens from also capping the aggregator aggregate_moa_context's single max_tokens parameter was applied to both the reference fan-out (_run_references_parallel) and the aggregator's own synthesis call_llm. #53580 explicitly removed a hardcoded cap from the aggregator call because it truncated long aggregator syntheses; #56756 (reference_max_tokens, added to speed up the advisor fan-out) reintroduced the same shared cap by passing it to both calls, silently regressing #53580's fix. Rename the parameter to reference_max_tokens (matching the caller's own moa_config key) and stop forwarding it to the aggregator's call_llm invocation, which now always runs uncapped as intended. --- agent/conversation_loop.py | 2 +- agent/moa_loop.py | 24 +++--- tests/agent/test_moa_context_max_tokens.py | 99 ++++++++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 tests/agent/test_moa_context_max_tokens.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bcaa80dce8c..cf196b3814a 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1099,7 +1099,7 @@ def run_conversation( aggregator=moa_config.get("aggregator") or {}, temperature=_preset_temperature(moa_config, "reference_temperature"), aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"), - max_tokens=moa_config.get("reference_max_tokens"), + reference_max_tokens=moa_config.get("reference_max_tokens"), ) if _moa_context: for _msg in reversed(api_messages): diff --git a/agent/moa_loop.py b/agent/moa_loop.py index e075b001807..182630fa2c4 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -687,23 +687,26 @@ def aggregate_moa_context( aggregator: dict[str, str], temperature: float | None = None, aggregator_temperature: float | None = None, - max_tokens: int | None = None, + reference_max_tokens: int | None = None, ) -> str: """Run configured reference models and synthesize their advice. Failures are returned as model-specific notes instead of aborting the normal agent loop; the main model can still act with partial context. - ``max_tokens`` is ``None`` by default: MoA does not cap reference or - aggregator output, so each model uses its own maximum. ``call_llm`` omits - the parameter entirely when it is ``None`` (see its docstring), which also - sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap - here previously truncated long aggregator syntheses. + ``reference_max_tokens`` applies ONLY to the reference fan-out — the + aggregator's own synthesis call is never capped, so it always uses its + model's own maximum. ``call_llm`` omits the parameter entirely when it + is ``None`` (see its docstring), which also sidesteps providers that + reject ``max_tokens`` outright. A hardcoded cap on the aggregator call + previously truncated long aggregator syntheses (#53580) — passing + ``reference_max_tokens`` to both calls here would silently reintroduce + that regression. ``temperature`` / ``aggregator_temperature`` are ``None`` by default: - like max_tokens, ``call_llm`` omits temperature when None so the - provider default applies — matching single-model agent behavior. Presets - may still pin explicit values. + like ``reference_max_tokens``, ``call_llm`` omits temperature when None + so the provider default applies — matching single-model agent behavior. + Presets may still pin explicit values. """ reference_outputs: list[tuple[str, str, Any]] = [] ref_messages = _reference_messages(api_messages) @@ -711,7 +714,7 @@ def aggregate_moa_context( reference_models, ref_messages, temperature=temperature, - max_tokens=max_tokens, + max_tokens=reference_max_tokens, ) joined = "\n\n".join( @@ -748,7 +751,6 @@ def aggregate_moa_context( task="moa_aggregator", messages=agg_messages, temperature=aggregator_temperature, - max_tokens=max_tokens, reasoning_config=_aggregator_reasoning_config(aggregator), **agg_runtime, ) diff --git a/tests/agent/test_moa_context_max_tokens.py b/tests/agent/test_moa_context_max_tokens.py new file mode 100644 index 00000000000..5800b2a7764 --- /dev/null +++ b/tests/agent/test_moa_context_max_tokens.py @@ -0,0 +1,99 @@ +"""Regression test for aggregate_moa_context's reference/aggregator max_tokens split. + +PR #53580 removed a hardcoded ``max_tokens`` cap from the aggregator's +synthesis call because it truncated long aggregator syntheses. PR #56756 +(feat(moa): add reference_max_tokens to cap advisor output and cut turn +latency) later reintroduced a single ``max_tokens`` parameter shared by BOTH +the reference fan-out and the aggregator call in ``aggregate_moa_context`` — +silently regressing the exact bug #53580 fixed: setting +``reference_max_tokens`` to speed up the advisors also truncates the +aggregator's own synthesis, which is the context the main agent actually +uses. + +``aggregate_moa_context`` and its reference/aggregator calls both go through +``call_llm`` (task="moa_reference" vs task="moa_aggregator"), so mocking +just that one function exercises the real fan-out/aggregation code path. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + + +def _response(content: str = "ok"): + message = SimpleNamespace(content=content, tool_calls=[]) + choice = SimpleNamespace(message=message, finish_reason="stop") + return SimpleNamespace(choices=[choice], usage=None, model="fake") + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def test_aggregator_call_never_receives_reference_max_tokens(hermes_home, monkeypatch): + """reference_max_tokens must cap only the reference fan-out — the + aggregator's own call_llm invocation must not receive max_tokens at all + (call_llm omits it entirely when None; see its own docstring).""" + from agent.moa_loop import aggregate_moa_context + + calls: list[dict] = [] + + def fake_call_llm(**kwargs): + calls.append(kwargs) + return _response("advice" if kwargs.get("task") == "moa_reference" else "synthesis") + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + + aggregate_moa_context( + user_prompt="clean the db", + api_messages=[{"role": "user", "content": "clean the db"}], + reference_models=[{"provider": "openrouter", "model": "openai/gpt-5.5"}], + aggregator={"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + reference_max_tokens=600, + ) + + reference_calls = [c for c in calls if c.get("task") == "moa_reference"] + aggregator_calls = [c for c in calls if c.get("task") == "moa_aggregator"] + assert len(reference_calls) == 1 + assert len(aggregator_calls) == 1 + + # The reference fan-out is capped as configured. + assert reference_calls[0]["max_tokens"] == 600 + # The aggregator's synthesis call must be uncapped — not even max_tokens=None, + # the kwarg must be absent entirely (matches call_llm's omit-when-None contract). + assert "max_tokens" not in aggregator_calls[0] + + +def test_aggregator_call_uncapped_when_reference_max_tokens_unset(hermes_home, monkeypatch): + """Sanity check: with no reference_max_tokens configured, the reference + call still explicitly passes max_tokens=None (call_llm itself decides + whether to omit it on the wire), while the aggregator call structurally + never carries a max_tokens kwarg at all — the pre-#56756 default MoA + behavior for the aggregator, preserved regardless of the reference cap.""" + from agent.moa_loop import aggregate_moa_context + + calls: list[dict] = [] + + def fake_call_llm(**kwargs): + calls.append(kwargs) + return _response("advice" if kwargs.get("task") == "moa_reference" else "synthesis") + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + + aggregate_moa_context( + user_prompt="clean the db", + api_messages=[{"role": "user", "content": "clean the db"}], + reference_models=[{"provider": "openrouter", "model": "openai/gpt-5.5"}], + aggregator={"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + ) + + reference_calls = [c for c in calls if c.get("task") == "moa_reference"] + aggregator_calls = [c for c in calls if c.get("task") == "moa_aggregator"] + assert reference_calls[0]["max_tokens"] is None + assert "max_tokens" not in aggregator_calls[0] From 32a4faa2d5b0eb66a6c85bf9be6436c5afc76318 Mon Sep 17 00:00:00 2001 From: Janig88 Date: Sat, 4 Jul 2026 20:28:25 +0300 Subject: [PATCH 002/552] fix(auxiliary): honor max_tokens for MoA reference/aggregator tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #56756 added reference_max_tokens to cap MoA advisor output and cut turn latency. The value is correctly threaded through five layers of MoA code (moa_config → conversation_loop → aggregate_moa_context → _run_references_parallel → _run_reference → call_llm(task='moa_reference', max_tokens=800, ...)). However, _build_call_kwargs() in auxiliary_client.py silently drops max_tokens for all OpenAI-compatible providers (PR #34845, which fixed endpoints and NVIDIA NIM keep it. This means reference_max_tokens never reached the API for the vast majority of providers. The bug affects every OpenAI-compatible MoA reference/aggregator slot: Z.AI (coding plan), OpenRouter, OpenAI, GitHub Copilot, and local providers. Only Anthropic-compat endpoints (MiniMax, /anthropic URLs) worked — by coincidence, not MoA-aware design. Fix: thread the 'task' parameter through all six _build_call_kwargs() call sites. When task starts with 'moa_', max_tokens is always included in the request kwargs regardless of provider. Non-MoA auxiliary tasks (compression, titles, vision, etc.) keep PR #34845 behavior unchanged. Verified end-to-end: - Z.AI GLM-5.2 with max_tokens=50 → returned exactly 50 tokens - Z.AI GLM-5.2 with max_tokens=20 → returned exactly 20 tokens - Z.AI GLM-5.2 uncapped → returned 315 tokens - 7 new regression tests covering 4 providers, Anthropic wire, non-MoA tasks, and prefix-matching boundary - 288 auxiliary_client tests pass (was 281, +7 new), 84 MoA tests pass - Zero regressions --- agent/auxiliary_client.py | 9 ++- contributors/emails/janig88@gmail.com | 2 + tests/agent/test_auxiliary_client.py | 95 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 contributors/emails/janig88@gmail.com diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c678bc2a379..832559aa638 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3683,6 +3683,7 @@ def _retry_same_provider_sync( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, + task=task, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) @@ -3742,6 +3743,7 @@ async def _retry_same_provider_async( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, + task=task, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) @@ -6776,6 +6778,7 @@ def _build_call_kwargs( extra_body: Optional[dict] = None, reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, + task: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" kwargs: Dict[str, Any] = { @@ -6830,9 +6833,11 @@ def _build_call_kwargs( _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") ) + _is_moa = bool(task) and str(task).startswith("moa_") if ( _is_anthropic_compat_endpoint(provider, _effective_base) or _is_nvidia_nim + or _is_moa ): kwargs["max_tokens"] = max_tokens @@ -7203,7 +7208,7 @@ def call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_base_info or resolved_base_url) + base_url=_base_info or resolved_base_url, task=task) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) _client_base = str(getattr(client, "base_url", "") or "") @@ -7819,7 +7824,7 @@ async def async_call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_client_base or resolved_base_url) + base_url=_client_base or resolved_base_url, task=task) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) if _is_anthropic_compat_endpoint(resolved_provider, _client_base): diff --git a/contributors/emails/janig88@gmail.com b/contributors/emails/janig88@gmail.com new file mode 100644 index 00000000000..2eb927a7e08 --- /dev/null +++ b/contributors/emails/janig88@gmail.com @@ -0,0 +1,2 @@ +Janig88 +# PR #58402 salvage diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 9f61ff9668d..fc4f8f562dc 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -656,6 +656,101 @@ class TestBuildCallKwargsMaxTokens: ) assert kwargs["max_tokens"] == 4096 + # ── MoA task should honor max_tokens on ALL providers (#reference_max_tokens) ── + + @pytest.mark.parametrize( + "provider,model,base_url", + [ + ("zai", "glm-5.2", "https://api.z.ai/api/coding/paas/v4"), + ("openrouter", "deepseek/deepseek-v4-flash:nitro", "https://openrouter.ai/api/v1"), + ("copilot", "gpt-5.5", "https://api.githubcopilot.com"), + ("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"), + ], + ) + def test_moa_task_sends_max_tokens_on_openai_compatible(self, provider, model, base_url): + """MoA reference/aggregator tasks must honor max_tokens regardless of provider. + + The ``reference_max_tokens`` config option (PR #56756) caps advisor output + to reduce turn latency. Before the fix, ``_build_call_kwargs`` silently + dropped the value for OpenAI-compatible providers (PR #34845), so the cap + never reached the API. With the ``task`` parameter threaded through, + any task starting with ``moa_`` must include ``max_tokens`` in kwargs. + """ + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + provider=provider, + model=model, + messages=[{"role": "user", "content": "hi"}], + max_tokens=800, + base_url=base_url, + task="moa_reference", + ) + assert kwargs["max_tokens"] == 800 + + def test_moa_task_sends_max_tokens_on_anthropic_wire(self): + """MoA tasks on Anthropic-compat endpoints keep max_tokens (unchanged behavior).""" + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + provider="minimax", + model="minimax-m2", + messages=[{"role": "user", "content": "hi"}], + max_tokens=600, + base_url="https://api.minimax.io/v1", + task="moa_aggregator", + ) + assert kwargs["max_tokens"] == 600 + + def test_non_moa_tasks_still_omit_max_tokens(self): + """Regression guard: compression/titles/vision keep PR #34845 behavior.""" + from agent.auxiliary_client import _build_call_kwargs + + for task in ("compression", "vision", "title_generation", None, ""): + kwargs = _build_call_kwargs( + provider="openrouter", + model="deepseek/deepseek-v4-flash:nitro", + messages=[{"role": "user", "content": "hi"}], + max_tokens=800, + base_url="https://openrouter.ai/api/v1", + task=task, + ) + assert "max_tokens" not in kwargs, f"max_tokens should be dropped for task={task!r}" + + def test_moa_prefix_matching(self): + """Only tasks prefixed with 'moa_' trigger the cap — not arbitrary task names.""" + from agent.auxiliary_client import _build_call_kwargs + + # 'moa_reference' → honored + kw = _build_call_kwargs( + provider="zai", model="glm-5.2", + messages=[{"role": "user", "content": "hi"}], + max_tokens=500, + base_url="https://api.z.ai/api/coding/paas/v4", + task="moa_reference", + ) + assert kw["max_tokens"] == 500 + + # 'moa_xyz' → honored (prefix match) + kw2 = _build_call_kwargs( + provider="zai", model="glm-5.2", + messages=[{"role": "user", "content": "hi"}], + max_tokens=500, + base_url="https://api.z.ai/api/coding/paas/v4", + task="moa_custom_future", + ) + assert kw2["max_tokens"] == 500 + + # 'mopha_reference' (similar but not moa_) → dropped + kw3 = _build_call_kwargs( + provider="zai", model="glm-5.2", + messages=[{"role": "user", "content": "hi"}], + max_tokens=500, + base_url="https://api.z.ai/api/coding/paas/v4", + task="mopha_reference", + ) + assert "max_tokens" not in kw3 + class TestNousTagsScoping: def test_tags_injected_when_provider_is_nous(self, monkeypatch): From 3616ce006aeb85190cfe5bfc5b8df731422e535f Mon Sep 17 00:00:00 2001 From: Janig88 Date: Sat, 4 Jul 2026 21:03:36 +0300 Subject: [PATCH 003/552] fix: use auxiliary_max_tokens_param for Copilot GPT-5 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review pointed out that hardcoding kwargs['max_tokens'] would 400 on models requiring max_completion_tokens (GPT-5 family, Copilot). The existing auxiliary_max_tokens_param() helper already selects the correct parameter name per model — use it instead of hardcoding. Test updated to parametrize expected_key so the Copilot gpt-5.5 case correctly asserts max_completion_tokens instead of max_tokens. Addresses Copilot review comments on both files. --- agent/auxiliary_client.py | 5 ++++- tests/agent/test_auxiliary_client.py | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 832559aa638..93358604b5a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6839,7 +6839,10 @@ def _build_call_kwargs( or _is_nvidia_nim or _is_moa ): - kwargs["max_tokens"] = max_tokens + # Use auxiliary_max_tokens_param() so models that require + # max_completion_tokens (GPT-5 family, Copilot) get the right + # parameter name instead of a hardcoded max_tokens that 400s. + kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model)) if tools: # Defensive dedup: providers like Google Vertex, Azure, and Bedrock diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index fc4f8f562dc..6bffb0e66c3 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -659,22 +659,25 @@ class TestBuildCallKwargsMaxTokens: # ── MoA task should honor max_tokens on ALL providers (#reference_max_tokens) ── @pytest.mark.parametrize( - "provider,model,base_url", + "provider,model,base_url,expected_key", [ - ("zai", "glm-5.2", "https://api.z.ai/api/coding/paas/v4"), - ("openrouter", "deepseek/deepseek-v4-flash:nitro", "https://openrouter.ai/api/v1"), - ("copilot", "gpt-5.5", "https://api.githubcopilot.com"), - ("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"), + ("zai", "glm-5.2", "https://api.z.ai/api/coding/paas/v4", "max_tokens"), + ("openrouter", "deepseek/deepseek-v4-flash:nitro", "https://openrouter.ai/api/v1", "max_tokens"), + ("copilot", "gpt-5.5", "https://api.githubcopilot.com", "max_completion_tokens"), + ("nous", "hermes-4", "https://inference-api.nousresearch.com/v1", "max_tokens"), ], ) - def test_moa_task_sends_max_tokens_on_openai_compatible(self, provider, model, base_url): + def test_moa_task_sends_max_tokens_on_openai_compatible(self, provider, model, base_url, expected_key): """MoA reference/aggregator tasks must honor max_tokens regardless of provider. The ``reference_max_tokens`` config option (PR #56756) caps advisor output to reduce turn latency. Before the fix, ``_build_call_kwargs`` silently dropped the value for OpenAI-compatible providers (PR #34845), so the cap never reached the API. With the ``task`` parameter threaded through, - any task starting with ``moa_`` must include ``max_tokens`` in kwargs. + any task starting with ``moa_`` must include the output cap in kwargs. + + Models that require ``max_completion_tokens`` (GPT-5 family, Copilot) + get the correct parameter name via ``auxiliary_max_tokens_param()``. """ from agent.auxiliary_client import _build_call_kwargs @@ -686,7 +689,7 @@ class TestBuildCallKwargsMaxTokens: base_url=base_url, task="moa_reference", ) - assert kwargs["max_tokens"] == 800 + assert kwargs[expected_key] == 800 def test_moa_task_sends_max_tokens_on_anthropic_wire(self): """MoA tasks on Anthropic-compat endpoints keep max_tokens (unchanged behavior).""" From 289fad1868fd6bfa368fef93b2577273a5ffe94a Mon Sep 17 00:00:00 2001 From: Janig88 Date: Tue, 7 Jul 2026 16:01:59 +0300 Subject: [PATCH 004/552] fix(auxiliary): thread task=task through _build_call_kwargs in fallback helpers --- agent/auxiliary_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 93358604b5a..abfd9d9b418 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3932,7 +3932,7 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base) + base_url=fb_base, task=task) try: return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) @@ -3949,7 +3949,7 @@ def _call_fallback_candidate_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) try: return _validate_llm_response( retry_client.chat.completions.create(**retry_kwargs), task) @@ -3998,7 +3998,7 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base) + base_url=fb_base, task=task) try: return _validate_llm_response( await fb_client.chat.completions.create(**fb_kwargs), task) @@ -4016,7 +4016,7 @@ async def _call_fallback_candidate_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) try: return _validate_llm_response( await retry_client.chat.completions.create(**retry_kwargs), task) From 3dce1b967f336a46dcdf7462863943ed9acbe222 Mon Sep 17 00:00:00 2001 From: Janig88 Date: Wed, 15 Jul 2026 21:19:38 +0300 Subject: [PATCH 005/552] fix(auxiliary): scope max_tokens to moa_reference only (not aggregator) Per review feedback from teknium1: reference_max_tokens is an advisors-only contract. The aggregator is the acting model and must not be capped by the reference budget. Changed _is_moa from startswith('moa_') to exact match on 'moa_reference'. Added regression test proving aggregator does NOT receive max_tokens. --- agent/auxiliary_client.py | 2 +- tests/agent/test_auxiliary_client.py | 43 +++++++++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index abfd9d9b418..5fb00555d25 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6833,7 +6833,7 @@ def _build_call_kwargs( _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") ) - _is_moa = bool(task) and str(task).startswith("moa_") + _is_moa = bool(task) and str(task) == "moa_reference" if ( _is_anthropic_compat_endpoint(provider, _effective_base) or _is_nvidia_nim diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 6bffb0e66c3..44ae1202651 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -668,13 +668,13 @@ class TestBuildCallKwargsMaxTokens: ], ) def test_moa_task_sends_max_tokens_on_openai_compatible(self, provider, model, base_url, expected_key): - """MoA reference/aggregator tasks must honor max_tokens regardless of provider. + """MoA reference tasks must honor max_tokens regardless of provider. The ``reference_max_tokens`` config option (PR #56756) caps advisor output to reduce turn latency. Before the fix, ``_build_call_kwargs`` silently dropped the value for OpenAI-compatible providers (PR #34845), so the cap never reached the API. With the ``task`` parameter threaded through, - any task starting with ``moa_`` must include the output cap in kwargs. + ``task == "moa_reference"`` includes the output cap in kwargs. Models that require ``max_completion_tokens`` (GPT-5 family, Copilot) get the correct parameter name via ``auxiliary_max_tokens_param()``. @@ -692,7 +692,7 @@ class TestBuildCallKwargsMaxTokens: assert kwargs[expected_key] == 800 def test_moa_task_sends_max_tokens_on_anthropic_wire(self): - """MoA tasks on Anthropic-compat endpoints keep max_tokens (unchanged behavior).""" + """MoA reference tasks on Anthropic-compat endpoints keep max_tokens (unchanged behavior).""" from agent.auxiliary_client import _build_call_kwargs kwargs = _build_call_kwargs( @@ -701,10 +701,30 @@ class TestBuildCallKwargsMaxTokens: messages=[{"role": "user", "content": "hi"}], max_tokens=600, base_url="https://api.minimax.io/v1", - task="moa_aggregator", + task="moa_reference", ) assert kwargs["max_tokens"] == 600 + def test_moa_aggregator_does_not_get_max_tokens_on_openai_compat(self): + """``reference_max_tokens`` is an advisors-only contract (#56756). + + The aggregator is the acting model — it must NOT be capped by the + reference token budget. Only ``task == "moa_reference"`` triggers + the exception in ``_build_call_kwargs``. + """ + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + provider="zai", + model="glm-5.2", + messages=[{"role": "user", "content": "hi"}], + max_tokens=800, + base_url="https://api.z.ai/api/coding/paas/v4", + task="moa_aggregator", + ) + assert "max_tokens" not in kwargs + assert "max_completion_tokens" not in kwargs + def test_non_moa_tasks_still_omit_max_tokens(self): """Regression guard: compression/titles/vision keep PR #34845 behavior.""" from agent.auxiliary_client import _build_call_kwargs @@ -720,8 +740,9 @@ class TestBuildCallKwargsMaxTokens: ) assert "max_tokens" not in kwargs, f"max_tokens should be dropped for task={task!r}" - def test_moa_prefix_matching(self): - """Only tasks prefixed with 'moa_' trigger the cap — not arbitrary task names.""" + def test_moa_task_exact_match(self): + """Only task == "moa_reference" triggers the cap — not the aggregator, + not arbitrary 'moa_' prefixed tasks.""" from agent.auxiliary_client import _build_call_kwargs # 'moa_reference' → honored @@ -734,23 +755,23 @@ class TestBuildCallKwargsMaxTokens: ) assert kw["max_tokens"] == 500 - # 'moa_xyz' → honored (prefix match) + # 'moa_aggregator' → dropped (aggregator is the acting model, not an advisor) kw2 = _build_call_kwargs( provider="zai", model="glm-5.2", messages=[{"role": "user", "content": "hi"}], max_tokens=500, base_url="https://api.z.ai/api/coding/paas/v4", - task="moa_custom_future", + task="moa_aggregator", ) - assert kw2["max_tokens"] == 500 + assert "max_tokens" not in kw2 - # 'mopha_reference' (similar but not moa_) → dropped + # 'moa_custom_future' → dropped (only moa_reference is whitelisted) kw3 = _build_call_kwargs( provider="zai", model="glm-5.2", messages=[{"role": "user", "content": "hi"}], max_tokens=500, base_url="https://api.z.ai/api/coding/paas/v4", - task="mopha_reference", + task="moa_custom_future", ) assert "max_tokens" not in kw3 From 4ee74fa5dfb3369da7c6fe1f6448b023ba89f581 Mon Sep 17 00:00:00 2001 From: aui Date: Sat, 4 Jul 2026 20:20:25 +0800 Subject: [PATCH 006/552] fix: forward max_tokens to gemini-native so MoA reference cap applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_call_kwargs omitted max_tokens for every provider except anthropic-compat endpoints and NVIDIA NIM. Gemini's native generateContent maps max_tokens -> maxOutputTokens and, when it is omitted, applies a fixed 65,535-token ceiling (not "the model's full budget"), so dropping the value made MoA's reference_max_tokens a silent no-op for gemini advisors — they ran effectively uncapped (observed ~2900 output tokens against a configured cap of 600), inflating per-turn MoA latency. Forward max_tokens for the gemini-native path (provider name or native base_url). Gemini supports maxOutputTokens, so the cap is safe here; providers that reject max_tokens (Copilot, GPT-5 max_completion_tokens, ZAI vision) are unaffected — they still omit it as before. --- agent/auxiliary_client.py | 16 ++++++++++++++++ contributors/emails/awain7@gmail.com | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 contributors/emails/awain7@gmail.com diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5fb00555d25..cd18779e8ea 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6834,10 +6834,26 @@ def _build_call_kwargs( or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") ) _is_moa = bool(task) and str(task) == "moa_reference" + # Gemini's native generateContent maps max_tokens → maxOutputTokens and, + # when it is omitted, applies a fixed 65,535-token ceiling rather than + # "the model's full budget" (see gemini_native_adapter.build_gemini_request). + # So an explicit cap is both safe and the ONLY way to honor it here — + # dropping max_tokens silently makes MoA's reference_max_tokens a no-op + # for gemini advisors (they run effectively uncapped). + _is_gemini_native = _provider_norm in { + "gemini", "google", "google-gemini", "google-ai-studio", + } + if not _is_gemini_native and _effective_base: + try: + from agent.gemini_native_adapter import is_native_gemini_base_url + _is_gemini_native = is_native_gemini_base_url(_effective_base) + except Exception: + pass if ( _is_anthropic_compat_endpoint(provider, _effective_base) or _is_nvidia_nim or _is_moa + or _is_gemini_native ): # Use auxiliary_max_tokens_param() so models that require # max_completion_tokens (GPT-5 family, Copilot) get the right diff --git a/contributors/emails/awain7@gmail.com b/contributors/emails/awain7@gmail.com new file mode 100644 index 00000000000..8d7ed80296f --- /dev/null +++ b/contributors/emails/awain7@gmail.com @@ -0,0 +1,2 @@ +awain7 +# PR #58261 salvage From ead9d7b256390876a2170e2751365fdf2fb6cc5f Mon Sep 17 00:00:00 2001 From: aui Date: Thu, 16 Jul 2026 08:14:13 +0800 Subject: [PATCH 007/552] test: cover gemini-native max_tokens forwarding in _build_call_kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested in review: builder-level assertions that the gemini-native branch forwards max_tokens (provider names and the native generativelanguage.googleapis.com base_url, max_tokens=600), plus a control showing gemini models on OpenAI-compatible endpoints — including Gemini's own /openai compatibility endpoint — keep the existing omission behavior (#34530). Co-Authored-By: Claude Fable 5 --- tests/agent/test_auxiliary_client.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 44ae1202651..86b5ae4adc5 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -775,6 +775,55 @@ class TestBuildCallKwargsMaxTokens: ) assert "max_tokens" not in kw3 + @pytest.mark.parametrize( + "provider,model,base_url", + [ + ("gemini", "gemini-2.5-pro", None), + ("google", "gemini-2.5-flash", None), + ( + "custom", + "gemini-2.5-pro", + "https://generativelanguage.googleapis.com/v1beta", + ), + ], + ) + def test_keeps_max_tokens_for_gemini_native(self, provider, model, base_url): + # Native generateContent maps max_tokens → maxOutputTokens; when it is + # omitted Gemini applies a fixed 65,535-token ceiling, which silently + # turned MoA's reference_max_tokens into a no-op for gemini advisors. + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + provider=provider, + model=model, + messages=[{"role": "user", "content": "hi"}], + max_tokens=600, + base_url=base_url, + ) + assert kwargs["max_tokens"] == 600 + assert "max_completion_tokens" not in kwargs + + def test_omits_max_tokens_for_gemini_model_on_openai_compatible_endpoint(self): + # Control: the gemini branch keys on provider/base_url, never the model + # name. A gemini model served through an OpenAI-compatible endpoint + # keeps the default omission behavior (#34530), including Gemini's own + # /openai compatibility endpoint. + from agent.auxiliary_client import _build_call_kwargs + + for provider, base_url in [ + ("openrouter", "https://openrouter.ai/api/v1"), + ("custom", "https://generativelanguage.googleapis.com/v1beta/openai"), + ]: + kwargs = _build_call_kwargs( + provider=provider, + model="google/gemini-2.5-pro", + messages=[{"role": "user", "content": "hi"}], + max_tokens=600, + base_url=base_url, + ) + assert "max_tokens" not in kwargs + assert "max_completion_tokens" not in kwargs + class TestNousTagsScoping: def test_tags_injected_when_provider_is_nous(self, monkeypatch): From bc7212cf93020f1571c09b7ec35ee2b331b4857f Mon Sep 17 00:00:00 2001 From: Rain Date: Tue, 7 Jul 2026 18:18:23 +0200 Subject: [PATCH 008/552] feat(moa): per-reference-model max_tokens override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MoA reference_max_tokens is preset-level — one cap for all reference models. When mixing a verbose model with a terse one, a single cap is either too tight for the terse model or too loose for the verbose one. Now each reference slot can optionally carry its own max_tokens: reference_models: - provider: openrouter model: deepseek/deepseek-v4-pro max_tokens: *** # per-slot cap, overrides preset-level - provider: openai-codex model: gpt-5.5 # no max_tokens → falls back to preset-level reference_max_tokens _clean_slot (moa_config.py) preserves an optional max_tokens field on the slot dict, coerced via _coerce_int_or_none. _run_reference (moa_loop.py) reads slot-level max_tokens first, falling back to the preset-level cap passed by the caller. Slots without the field are unaffected — backward compatible. Type hints on slot-handling functions updated from dict[str, str] to dict[str, Any] to reflect the now-heterogeneous slot shape. --- agent/moa_loop.py | 15 +++-- contributors/emails/rain@synth.kitchen | 2 + hermes_cli/moa_config.py | 8 +++ tests/agent/test_moa_slot_max_tokens.py | 82 +++++++++++++++++++++++++ tests/hermes_cli/test_moa_config.py | 76 +++++++++++++++++++++++ 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 contributors/emails/rain@synth.kitchen create mode 100644 tests/agent/test_moa_slot_max_tokens.py diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 182630fa2c4..3d9373b823c 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -281,7 +281,7 @@ def _maybe_apply_moa_cache_control( def _run_reference( - slot: dict[str, str], + slot: dict[str, Any], ref_messages: list[dict[str, Any]], *, temperature: float | None = None, @@ -333,11 +333,16 @@ def _run_reference( # (their caching is automatic; markers are ignored harmlessly, but we # only decorate when the policy says the route honors them). messages = _maybe_apply_moa_cache_control(messages, runtime) + # Per-slot max_tokens takes precedence over the preset-level + # reference_max_tokens passed in by the caller. This lets each + # reference model have its own output cap independently. + _slot_max_tokens: int | None = slot.get("max_tokens") + _effective_max_tokens = _slot_max_tokens if _slot_max_tokens is not None else max_tokens response = call_llm( task="moa_reference", messages=messages, temperature=temperature, - max_tokens=max_tokens, + max_tokens=_effective_max_tokens, reasoning_config=_slot_reasoning_config(slot), **runtime, ) @@ -398,7 +403,7 @@ def _run_reference( def _run_references_parallel( - reference_models: list[dict[str, str]], + reference_models: list[dict[str, Any]], ref_messages: list[dict[str, Any]], *, temperature: float | None = None, @@ -683,8 +688,8 @@ def aggregate_moa_context( *, user_prompt: str, api_messages: list[dict[str, Any]], - reference_models: list[dict[str, str]], - aggregator: dict[str, str], + reference_models: list[dict[str, Any]], + aggregator: dict[str, Any], temperature: float | None = None, aggregator_temperature: float | None = None, reference_max_tokens: int | None = None, diff --git a/contributors/emails/rain@synth.kitchen b/contributors/emails/rain@synth.kitchen new file mode 100644 index 00000000000..de1f34f8281 --- /dev/null +++ b/contributors/emails/rain@synth.kitchen @@ -0,0 +1,2 @@ +matarbot +# PR #60391 salvage diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index a5401c52589..bf40976dc2a 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -105,6 +105,14 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None: effort = _clean_reasoning_effort(slot.get("reasoning_effort")) if effort: clean["reasoning_effort"] = effort + # Optional per-slot max_tokens: overrides the preset-level + # reference_max_tokens for this specific reference model. None (the + # default) = no cap, so existing slots are unaffected. Allows tuning + # each advisor's output length independently — useful when one model + # is verbose and another is terse. + slot_mt = _coerce_int_or_none(slot.get("max_tokens")) + if slot_mt is not None: + clean["max_tokens"] = slot_mt return clean diff --git a/tests/agent/test_moa_slot_max_tokens.py b/tests/agent/test_moa_slot_max_tokens.py new file mode 100644 index 00000000000..1b3a6813c30 --- /dev/null +++ b/tests/agent/test_moa_slot_max_tokens.py @@ -0,0 +1,82 @@ +"""Tests for per-slot max_tokens in MoA reference calls. + +Verifies that a ``max_tokens`` field on a reference slot dict takes +precedence over the preset-level ``reference_max_tokens``, and that +slot-level max_tokens=None falls back to the preset-level cap. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestRunReferenceSlotMaxTokens: + """_run_reference should prefer slot-level max_tokens over preset-level.""" + + def test_slot_max_tokens_overrides_preset_level(self): + """When slot has max_tokens, it overrides the preset-level cap.""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=2000) + + assert captured_kwargs.get("max_tokens") == 600 + + def test_slot_max_tokens_absent_falls_back_to_preset(self): + """When slot has no max_tokens, the preset-level cap is used.""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=2000) + + assert captured_kwargs.get("max_tokens") == 2000 + + def test_both_none_means_uncapped(self): + """When neither slot nor preset has max_tokens, it's None (uncapped).""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=None) + + assert captured_kwargs.get("max_tokens") is None diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index e6b4714e225..88c7896f155 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -461,3 +461,79 @@ def test_validate_moa_payload_rejects_non_dict(): assert validate_moa_payload(None) assert validate_moa_payload([1, 2]) assert validate_moa_payload({"presets": {"p": "not-a-dict"}}) + + +# ── Per-slot max_tokens ──────────────────────────────────────────────────── + + +def test_slot_max_tokens_preserved(): + """A max_tokens field on a reference slot survives normalization.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600}, + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + } + } + ) + refs = cfg["presets"]["p"]["reference_models"] + assert refs[0] == {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600} + assert refs[1] == {"provider": "openai-codex", "model": "gpt-5.5"} + + +def test_slot_max_tokens_coerced_from_string(): + """Hand-edited YAML string '600' coerces to int on a slot.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": "600"}, + ], + } + } + } + ) + refs = cfg["presets"]["p"]["reference_models"] + assert refs[0]["max_tokens"] == 600 + + +def test_slot_max_tokens_invalid_dropped(): + """Non-positive / non-numeric slot max_tokens is dropped (slot kept).""" + for bad in (0, -5, "abc", "", None): + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": bad}, + ], + } + } + } + ) + ref = cfg["presets"]["p"]["reference_models"][0] + assert "max_tokens" not in ref, bad + assert ref["provider"] == "openrouter" + + +def test_slot_max_tokens_absent_by_default(): + """Slots without max_tokens don't get the field — backward compat.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + ], + } + } + } + ) + ref = cfg["presets"]["p"]["reference_models"][0] + assert "max_tokens" not in ref From 69339aab9106a3d83c289e4677e57f03343de113 Mon Sep 17 00:00:00 2001 From: golldyck <127680312+golldyck@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:28:08 +0300 Subject: [PATCH 009/552] fix(compaction): skip compression when it can't reduce tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compress_trajectory (and _async) replaced the compressible middle region with a [CONTEXT SUMMARY] turn without checking that the region is actually larger than the summary. When a large protected system prompt dominates the budget, the compressible middle can be tiny; replacing e.g. a 2-token middle with a ~60-token summary GROWS the trajectory (tokens_saved negative), marks it was_compressed, and still spends a summarization call — the opposite of the intent, on exactly the hard over-budget cases. Add a net-savings guard mirroring the code's own comment (net_savings = region_tokens - summary_target_tokens): if the safely-compressible region is no larger than summary_target_tokens, return the trajectory unchanged. Applied to both the sync and async paths. Add sync+async regression tests. --- tests/test_trajectory_compressor.py | 64 +++++++++++++++++++++++++++++ trajectory_compressor.py | 24 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/tests/test_trajectory_compressor.py b/tests/test_trajectory_compressor.py index 8fcbfc38cfe..b4e4c876a3f 100644 --- a/tests/test_trajectory_compressor.py +++ b/tests/test_trajectory_compressor.py @@ -628,3 +628,67 @@ class TestCompressionToolPairIntegrity: {"from": "tool", "value": "a"}, ] assert tc._snap_boundary(trajectory, 1, 0, 1) == 0 + + +# --------------------------------------------------------------------------- +# TrajectoryCompressor — compression must never increase the token count +# --------------------------------------------------------------------------- + + +class TestCompressionNetSavingsGuard: + """When the compressible middle is no larger than the summary that would + replace it, compression cannot help — it must be skipped rather than grow + the trajectory (and burn a summarization call).""" + + def _tiny_middle_trajectory(self): + # Large protected head (system+human), tiny compressible middle. + big = "w " * 400 # ~200 tokens each (1 token / 4 chars) + small = "ok " * 2 + return [ + {"from": "system", "value": big}, # protected (first_system) + {"from": "human", "value": big}, # protected (first_human) + {"from": "gpt", "value": small}, # protected (first_gpt) + {"from": "tool", "value": small}, # protected (first_tool) + {"from": "gpt", "value": small}, # compressible middle + {"from": "tool", "value": small}, # compressible middle + {"from": "gpt", "value": small}, # protected (last 2) + {"from": "human", "value": small}, # protected (last 2) + ] + + def _config(self): + config = CompressionConfig() + config.protect_last_n_turns = 2 + config.summary_target_tokens = 20 + config.target_max_tokens = 100 # trajectory is far over this + return config + + def test_sync_skips_compression_when_middle_smaller_than_summary(self): + tc = _make_compressor(self._config()) + tc._generate_summary = MagicMock( + return_value="[CONTEXT SUMMARY]: " + "blah " * 30 + ) + trajectory = self._tiny_middle_trajectory() + before = sum(tc.count_turn_tokens(trajectory)) + + compressed, metrics = tc.compress_trajectory(trajectory) + + assert metrics.was_compressed is False + assert compressed == trajectory + assert sum(tc.count_turn_tokens(compressed)) == before + tc._generate_summary.assert_not_called() + + @pytest.mark.asyncio + async def test_async_skips_compression_when_middle_smaller_than_summary(self): + tc = _make_compressor(self._config()) + tc._generate_summary_async = AsyncMock( + return_value="[CONTEXT SUMMARY]: " + "blah " * 30 + ) + trajectory = self._tiny_middle_trajectory() + before = sum(tc.count_turn_tokens(trajectory)) + + compressed, metrics = await tc.compress_trajectory_async(trajectory) + + assert metrics.was_compressed is False + assert compressed == trajectory + assert sum(tc.count_turn_tokens(compressed)) == before + tc._generate_summary_async.assert_not_called() diff --git a/trajectory_compressor.py b/trajectory_compressor.py index ca1c86c5b68..83248983a2c 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -831,6 +831,18 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" metrics.still_over_limit = total_tokens > self.config.target_max_tokens return trajectory, metrics + # If the region we can safely compress is no larger than the summary + # that would replace it, compression cannot reduce the token count -- + # it would grow the trajectory and still spend a summarization call. + if ( + sum(turn_tokens[compress_start:compress_until]) + <= self.config.summary_target_tokens + ): + metrics.compressed_tokens = total_tokens + metrics.compressed_turns = len(trajectory) + metrics.still_over_limit = total_tokens > self.config.target_max_tokens + return trajectory, metrics + # Record compression region metrics.turns_compressed_start_idx = compress_start metrics.turns_compressed_end_idx = compress_until @@ -946,6 +958,18 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" metrics.still_over_limit = total_tokens > self.config.target_max_tokens return trajectory, metrics + # If the region we can safely compress is no larger than the summary + # that would replace it, compression cannot reduce the token count -- + # it would grow the trajectory and still spend a summarization call. + if ( + sum(turn_tokens[compress_start:compress_until]) + <= self.config.summary_target_tokens + ): + metrics.compressed_tokens = total_tokens + metrics.compressed_turns = len(trajectory) + metrics.still_over_limit = total_tokens > self.config.target_max_tokens + return trajectory, metrics + # Record compression region metrics.turns_compressed_start_idx = compress_start metrics.turns_compressed_end_idx = compress_until From 8cd49c496fe7d6f6cfd82bfb623f84308d437015 Mon Sep 17 00:00:00 2001 From: 3ASiC Date: Thu, 16 Jul 2026 23:48:09 +0800 Subject: [PATCH 010/552] fix(compression): reclaim locks from dead processes --- hermes_state.py | 80 +++++++++++++++++--- tests/test_hermes_state_compression_locks.py | 49 ++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index a4f57d32e74..a4d9b9429ac 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -33,6 +33,36 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar logger = logging.getLogger(__name__) +_COMPRESSION_LOCK_HOLDER_PID_RE = re.compile(r"(?:^|:)pid=(\d+)(?::|$)") + + +def _compression_lock_holder_process_is_dead(holder: str) -> bool: + """Return True only when a structured lock holder's local PID is gone. + + Compression locks are stored in a host-local SQLite database and holder + IDs created by ``conversation_compression`` start with ``pid=``. A + process killed during gateway shutdown cannot release its lease, so waiting + for the full TTL makes every new turn repeatedly attempt compaction. Reclaim + only when the kernel proves that PID no longer exists; legacy/unstructured + holders and permission errors remain protected until normal TTL expiry. + """ + match = _COMPRESSION_LOCK_HOLDER_PID_RE.search(holder or "") + if match is None: + return False + try: + pid = int(match.group(1)) + except (TypeError, ValueError): + return False + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + except (PermissionError, OSError): + return False + return False + def _scrub_surrogates(value: Any) -> Any: """Replace lone surrogates when *value* is text; pass anything else through. @@ -4152,10 +4182,10 @@ class SessionDB: MUST NOT proceed with compression in that case (its rotation would race against the holder's, splitting the session lineage). - Expired locks (``expires_at < now``) are reclaimed transparently: - the stale row is deleted and the new holder acquires it. This - prevents a crashed compressor from permanently blocking the - session. + Expired locks (``expires_at < now``) are reclaimed transparently. + Structured holders whose local ``pid=`` no longer exists are reclaimed + immediately, so a gateway killed during compression does not stall the + replacement process for the full lease TTL. Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE, followed by a SELECT to confirm we got the row. SQLite serialises @@ -4167,12 +4197,29 @@ class SessionDB: expires_at = now + ttl_seconds def _do(conn): - # First: reclaim any expired lock for this session_id. - conn.execute( - "DELETE FROM compression_locks " - "WHERE session_id = ? AND expires_at < ?", - (session_id, now), - ) + reclaimed_holder = None + row = conn.execute( + "SELECT holder, expires_at FROM compression_locks " + "WHERE session_id = ?", + (session_id,), + ).fetchone() + if row is not None: + current_holder = ( + row["holder"] if isinstance(row, sqlite3.Row) else row[0] + ) + current_expires_at = ( + row["expires_at"] if isinstance(row, sqlite3.Row) else row[1] + ) + if ( + current_expires_at < now + or _compression_lock_holder_process_is_dead(current_holder) + ): + conn.execute( + "DELETE FROM compression_locks " + "WHERE session_id = ? AND holder = ?", + (session_id, current_holder), + ) + reclaimed_holder = current_holder # Then: try to insert. INSERT OR IGNORE returns no rowcount # difference — verify ownership via SELECT. conn.execute( @@ -4185,12 +4232,21 @@ class SessionDB: "SELECT holder FROM compression_locks WHERE session_id = ?", (session_id,), ).fetchone() - return row is not None and ( + acquired = row is not None and ( row["holder"] if isinstance(row, sqlite3.Row) else row[0] ) == holder + return acquired, reclaimed_holder try: - return bool(self._execute_write(_do)) + acquired, reclaimed_holder = self._execute_write(_do) + if reclaimed_holder: + logger.warning( + "Reclaimed stale compression lock for session=%s " + "(holder=%s)", + session_id, + reclaimed_holder, + ) + return bool(acquired) except sqlite3.Error as exc: logger.warning( "try_acquire_compression_lock(%s) failed: %s", diff --git a/tests/test_hermes_state_compression_locks.py b/tests/test_hermes_state_compression_locks.py index 4e44d92a445..2655a75c05d 100644 --- a/tests/test_hermes_state_compression_locks.py +++ b/tests/test_hermes_state_compression_locks.py @@ -12,12 +12,14 @@ diagnostic accessor) — not the wiring into compression. from __future__ import annotations +import os import threading import time from pathlib import Path import pytest +import hermes_state from hermes_state import SessionDB @@ -99,6 +101,53 @@ def test_non_expired_lock_is_held(db: SessionDB) -> None: assert db.try_acquire_compression_lock("sess1", "holder2") is False +def test_non_expired_lock_from_dead_pid_is_reclaimed( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + dead_holder = "pid=424242:tid=1:agent=abc:nonce=deadbeef" + assert db.try_acquire_compression_lock( + "sess1", dead_holder, ttl_seconds=300 + ) is True + + def process_is_gone(pid: int, signal: int) -> None: + assert pid == 424242 + assert signal == 0 + raise ProcessLookupError + + monkeypatch.setattr(hermes_state.os, "kill", process_is_gone) + + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=fresh", ttl_seconds=300 + ) is True + + +def test_non_expired_lock_from_live_pid_is_not_reclaimed(db: SessionDB) -> None: + live_holder = f"pid={os.getpid()}:tid=1:agent=abc:nonce=live" + assert db.try_acquire_compression_lock( + "sess1", live_holder, ttl_seconds=300 + ) is True + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 + ) is False + + +def test_unstructured_holder_waits_for_ttl( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + assert db.try_acquire_compression_lock( + "sess1", "legacy_holder", ttl_seconds=300 + ) is True + kill = monkeypatch.setattr( + hermes_state.os, + "kill", + lambda *_args: pytest.fail("unstructured holder must not probe a PID"), + ) + assert kill is None + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 + ) is False + + # ---------------------------------------------------------------------- # Empty / invalid input # ---------------------------------------------------------------------- From 6ab8428b88d92d4dcb3242ed1d783429ff60c33e Mon Sep 17 00:00:00 2001 From: 3ASiC Date: Fri, 17 Jul 2026 00:04:12 +0800 Subject: [PATCH 011/552] fix(compression): keep PID probing POSIX-only --- hermes_state.py | 7 ++++++- tests/test_hermes_state_compression_locks.py | 22 ++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index a4d9b9429ac..a2a019a7a98 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -46,6 +46,11 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool: only when the kernel proves that PID no longer exists; legacy/unstructured holders and permission errors remain protected until normal TTL expiry. """ + # Python's os.kill(pid, 0) is a non-destructive liveness probe on POSIX. + # On Windows, any non-CTRL signal value is implemented with + # TerminateProcess, so fall back to TTL-only recovery there. + if os.name == "nt": + return False match = _COMPRESSION_LOCK_HOLDER_PID_RE.search(holder or "") if match is None: return False @@ -59,7 +64,7 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool: os.kill(pid, 0) except ProcessLookupError: return True - except (PermissionError, OSError): + except (PermissionError, OSError, OverflowError): return False return False diff --git a/tests/test_hermes_state_compression_locks.py b/tests/test_hermes_state_compression_locks.py index 2655a75c05d..05aaaeb3ab1 100644 --- a/tests/test_hermes_state_compression_locks.py +++ b/tests/test_hermes_state_compression_locks.py @@ -137,12 +137,30 @@ def test_unstructured_holder_waits_for_ttl( assert db.try_acquire_compression_lock( "sess1", "legacy_holder", ttl_seconds=300 ) is True - kill = monkeypatch.setattr( + monkeypatch.setattr( hermes_state.os, "kill", lambda *_args: pytest.fail("unstructured holder must not probe a PID"), ) - assert kill is None + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 + ) is False + + +def test_windows_uses_ttl_only_without_os_kill( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + holder = "pid=424242:tid=1:agent=abc:nonce=windows" + assert db.try_acquire_compression_lock( + "sess1", holder, ttl_seconds=300 + ) is True + monkeypatch.setattr(hermes_state.os, "name", "nt") + monkeypatch.setattr( + hermes_state.os, + "kill", + lambda *_args: pytest.fail("Windows must not use os.kill as a PID probe"), + ) + assert db.try_acquire_compression_lock( "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 ) is False From fdefb2d38ca8fd27f4adf23ebd741b1c9fdb44d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:52:36 -0700 Subject: [PATCH 012/552] fix(compression): prefer psutil.pid_exists for lease liveness probe; add same-pid self-reclaim guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening on top of the salvaged dead-PID lease reclamation from PR #65775 (@the3asic): - Probe via psutil.pid_exists (hard dependency; CONTRIBUTING.md critical rule #1) with the contributor's os.kill(pid, 0) POSIX probe retained only as a scaffold-phase fallback when psutil is missing. - Same-process holders (pid == os.getpid()) are never probed and never self-reclaimed — another thread's live lease is owned by the lease refresher/release path. - Any probe doubt (exceptions, permission errors) conservatively keeps the lease until normal TTL expiry; Windows stays TTL-only. - Tests: psutil-first dead-pid reclaim (probe call pinned), os.kill fallback path, probe-doubt keeps lease, same-pid no self-reclaim, legacy holder + Windows paths assert NO probe via either API. --- hermes_state.py | 32 +++++- tests/test_hermes_state_compression_locks.py | 100 ++++++++++++++++++- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index a2a019a7a98..3393e26c656 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -31,6 +31,11 @@ from agent.message_sanitization import _sanitize_surrogates from hermes_constants import get_hermes_home from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar +try: # Hard dependency, but tolerate scaffold-phase imports before pip install. + import psutil +except ImportError: # pragma: no cover - stripped/scaffold installs only + psutil = None # type: ignore[assignment] + logger = logging.getLogger(__name__) _COMPRESSION_LOCK_HOLDER_PID_RE = re.compile(r"(?:^|:)pid=(\d+)(?::|$)") @@ -44,11 +49,14 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool: process killed during gateway shutdown cannot release its lease, so waiting for the full TTL makes every new turn repeatedly attempt compaction. Reclaim only when the kernel proves that PID no longer exists; legacy/unstructured - holders and permission errors remain protected until normal TTL expiry. + holders, same-process holders, permission errors, and any probe doubt + remain protected until normal TTL expiry (conservative: PID reuse must + never steal a live lease, and a wrongly-kept lease self-heals via TTL). """ - # Python's os.kill(pid, 0) is a non-destructive liveness probe on POSIX. - # On Windows, any non-CTRL signal value is implemented with - # TerminateProcess, so fall back to TTL-only recovery there. + # Windows stays TTL-only: stdlib os.kill(pid, 0) is NOT a no-op probe + # there (bpo-14484 — sig=0 maps to CTRL_C_EVENT and can kill the target's + # console group), and PID recycling semantics make liveness a weaker + # deadness signal. The 300s lease TTL remains the recovery path. if os.name == "nt": return False match = _COMPRESSION_LOCK_HOLDER_PID_RE.search(holder or "") @@ -60,8 +68,22 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool: return False if pid <= 0: return False + if pid == os.getpid(): + # Same-process holder (e.g. another thread's live lease): never + # self-reclaim — the lease refresher and release path own it. + return False + if psutil is not None: + try: + # psutil is the canonical cross-platform liveness answer + # (CONTRIBUTING.md "Critical rules" #1). pid_exists() reports + # recycled PIDs as alive — conservative, the TTL still applies. + return not psutil.pid_exists(pid) + except Exception: + return False # any doubt → keep the lease until TTL expiry + # Scaffold-phase fallback only (psutil missing). POSIX-only by the + # os.name gate above. try: - os.kill(pid, 0) + os.kill(pid, 0) # windows-footgun: ok — function early-returns on nt above except ProcessLookupError: return True except (PermissionError, OSError, OverflowError): diff --git a/tests/test_hermes_state_compression_locks.py b/tests/test_hermes_state_compression_locks.py index 05aaaeb3ab1..c82b2a706a2 100644 --- a/tests/test_hermes_state_compression_locks.py +++ b/tests/test_hermes_state_compression_locks.py @@ -16,6 +16,7 @@ import os import threading import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -109,6 +110,33 @@ def test_non_expired_lock_from_dead_pid_is_reclaimed( "sess1", dead_holder, ttl_seconds=300 ) is True + probed: list[int] = [] + + def process_is_gone(pid: int) -> bool: + probed.append(pid) + return False + + monkeypatch.setattr( + hermes_state, "psutil", SimpleNamespace(pid_exists=process_is_gone) + ) + + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=fresh", ttl_seconds=300 + ) is True + assert probed == [424242] + + +def test_dead_pid_reclaim_via_os_kill_fallback_when_psutil_missing( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scaffold-phase installs (no psutil) fall back to os.kill(pid, 0).""" + dead_holder = "pid=424242:tid=1:agent=abc:nonce=deadbeef" + assert db.try_acquire_compression_lock( + "sess1", dead_holder, ttl_seconds=300 + ) is True + + monkeypatch.setattr(hermes_state, "psutil", None) + def process_is_gone(pid: int, signal: int) -> None: assert pid == 424242 assert signal == 0 @@ -121,6 +149,28 @@ def test_non_expired_lock_from_dead_pid_is_reclaimed( ) is True +def test_probe_doubt_keeps_lease_until_ttl( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + """A probe that errors out is doubt, not proof of death → TTL protects.""" + holder = "pid=424242:tid=1:agent=abc:nonce=doubt" + assert db.try_acquire_compression_lock( + "sess1", holder, ttl_seconds=300 + ) is True + + def probe_blows_up(pid: int) -> bool: + raise RuntimeError("transient probe failure") + + monkeypatch.setattr( + hermes_state, "psutil", SimpleNamespace(pid_exists=probe_blows_up) + ) + + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 + ) is False + assert db.get_compression_lock_holder("sess1") == holder + + def test_non_expired_lock_from_live_pid_is_not_reclaimed(db: SessionDB) -> None: live_holder = f"pid={os.getpid()}:tid=1:agent=abc:nonce=live" assert db.try_acquire_compression_lock( @@ -131,12 +181,51 @@ def test_non_expired_lock_from_live_pid_is_not_reclaimed(db: SessionDB) -> None: ) is False +def test_same_process_holder_is_never_self_reclaimed( + db: SessionDB, monkeypatch: pytest.MonkeyPatch +) -> None: + """A holder from THIS pid is never probed — even a lying probe can't steal it.""" + live_holder = f"pid={os.getpid()}:tid=1:agent=abc:nonce=self" + assert db.try_acquire_compression_lock( + "sess1", live_holder, ttl_seconds=300 + ) is True + # Even if a (broken) probe were to claim our own PID is dead, the + # same-process guard short-circuits before any probe runs. + monkeypatch.setattr( + hermes_state, + "psutil", + SimpleNamespace( + pid_exists=lambda _pid: pytest.fail( + "same-process holder must not be probed" + ) + ), + ) + monkeypatch.setattr( + hermes_state.os, + "kill", + lambda *_args: pytest.fail("same-process holder must not be probed"), + ) + assert db.try_acquire_compression_lock( + "sess1", "pid=525252:tid=2:agent=def:nonce=other", ttl_seconds=300 + ) is False + assert db.get_compression_lock_holder("sess1") == live_holder + + def test_unstructured_holder_waits_for_ttl( db: SessionDB, monkeypatch: pytest.MonkeyPatch ) -> None: assert db.try_acquire_compression_lock( "sess1", "legacy_holder", ttl_seconds=300 ) is True + monkeypatch.setattr( + hermes_state, + "psutil", + SimpleNamespace( + pid_exists=lambda _pid: pytest.fail( + "unstructured holder must not probe a PID" + ) + ), + ) monkeypatch.setattr( hermes_state.os, "kill", @@ -147,7 +236,7 @@ def test_unstructured_holder_waits_for_ttl( ) is False -def test_windows_uses_ttl_only_without_os_kill( +def test_windows_uses_ttl_only_without_pid_probe( db: SessionDB, monkeypatch: pytest.MonkeyPatch ) -> None: holder = "pid=424242:tid=1:agent=abc:nonce=windows" @@ -155,6 +244,15 @@ def test_windows_uses_ttl_only_without_os_kill( "sess1", holder, ttl_seconds=300 ) is True monkeypatch.setattr(hermes_state.os, "name", "nt") + monkeypatch.setattr( + hermes_state, + "psutil", + SimpleNamespace( + pid_exists=lambda _pid: pytest.fail( + "Windows must stay TTL-only — no PID probe" + ) + ), + ) monkeypatch.setattr( hermes_state.os, "kill", From 056a40aa4d070f414cdf6dad5ae3513c1322a4e2 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:59:19 -0700 Subject: [PATCH 013/552] fix(agent): defer turns during compression lock contention instead of exhausting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lock-loser compression pass returns its input unchanged, which the automatic compression sites misread as 'cannot compress further': the preflight loop armed the insufficient-progress blocker, the pre-API gate burned a shared attempt, and a lock-contended 413/overflow retried into the attempt cap and returned compression_exhausted — which the gateway answers with a full session auto-reset (#9893/#35809). A temporary concurrent-compression defer wiped the session. Consume the landed #69870 lock-skip signal on every automatic path (preflight in turn_context, pre-API pressure gate, 413 handler, overflow handler, post-tool compaction): when a pass no-ops AND the type-pinned lock-skip flag is set, refund the attempt (never count it toward the cap or the insufficient-progress blocker), and when the turn cannot proceed (provider already proved the request does not fit) end it with a soft compression_deferred result — distinct from compression_exhausted — so the gateway keeps the session intact and the next message retries after the concurrent compressor finishes. The new compression_skipped_due_to_lock() reader is type-pinned (is True or isinstance(str)) per the MagicMock auto-attribute rule, and compress_context() now also clears the signal at the very top of every attempt (per-attempt state rule, #58629/#69853) so a stale value can never make a later breaker/codex no-op look like lock contention. Salvaged from PR #49874; rebuilt on main's #69870 _compression_skipped_due_to_lock signal instead of the PR's parallel _compression_deferred_by_lock triple. --- agent/conversation_compression.py | 26 +++++ agent/conversation_loop.py | 159 +++++++++++++++++++++++++----- agent/turn_context.py | 20 ++++ 3 files changed, 178 insertions(+), 27 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index b2357d26be5..59f95be9f29 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -355,6 +355,24 @@ def _emit_compression_attempt_telemetry( logger.debug("failed to emit compression attempt telemetry: %s", exc) +def compression_skipped_due_to_lock(agent: Any) -> bool: + """Type-pinned read of the #69870 lock-skip signal. + + ``agent._compression_skipped_due_to_lock`` is set by ``compress_context`` + when a compression pass no-ops because another path holds the per-session + compression lock (holder string when the holder was confirmed, ``True`` + otherwise) and cleared to ``None`` at the entry of every call. + + The read MUST be type-pinned (``is True or isinstance(x, str)``), never + bare truthiness: MagicMock test-double agents auto-create truthy + attributes, and a bare ``if getattr(agent, ...)`` would hijack every + mocked agent in sibling suites into the lock-skip branch (the + #69870 × #69840 type-ahead incident). + """ + _sig = getattr(agent, "_compression_skipped_due_to_lock", None) + return _sig is True or isinstance(_sig, str) + + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -1143,6 +1161,14 @@ def compress_context( # boundary, so the previous flush baseline remains authoritative. agent._last_compression_attempt_recorded = True agent._last_compression_attempt_in_place = None + # Clear the lock-skip signal at the VERY TOP, before the codex route and + # the breaker gates below can early-return (per-attempt state rule, + # #58630/#69853). A stale ``True``/holder value from a prior lock-skip + # must never make a later breaker/codex no-op look like lock contention + # to the automatic-path consumers (compression_deferred, #49874) — the + # second clear before lock acquisition below stays for the same reason + # it was added in #69870 and is simply idempotent now. + agent._compression_skipped_due_to_lock = None _attempt_started_at = time.monotonic() _attempt_id = uuid.uuid4().hex diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cf196b3814a..7bd958612b6 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -34,6 +34,7 @@ from agent.conversation_compression import ( COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE, COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE, PRE_API_COMPRESSION_STATUS_TEMPLATE, + compression_skipped_due_to_lock, conversation_history_after_compression, ) from agent.context_engine import automatic_compaction_status_message @@ -640,6 +641,55 @@ def _content_policy_blocked_result( } +def _compression_deferred_result( + agent, + messages: List[Dict], + api_call_count: int, +) -> Dict[str, Any]: + """Build the soft turn result for a lock-contended compression defer. + + Another path (a sibling turn, a background review fork, a manual + ``/compress``) holds this session's compression lock, so every + compression pass this turn no-oped and the request still does not fit. + This is a TEMPORARY condition — the lock winner is actively shrinking + the same session — so the turn must end as a soft defer + (``compression_deferred``), never as ``compression_exhausted``: the + gateway auto-resets (wipes) the session on exhaustion (#9893/#35809), + which would destroy a session that the concurrent compressor is about + to make healthy again. + + ``failed`` stays False so the gateway persists the user turn (transient + branch) and retry-next-message semantics apply. + """ + holder = getattr(agent, "_compression_skipped_due_to_lock", None) + logger.info( + "turn deferred: compression lock held by another path " + "(session=%s holder=%s) — not counting as compression exhaustion", + agent.session_id or "none", + holder if isinstance(holder, str) else "unconfirmed", + ) + try: + agent._flush_status_buffer() + except Exception: + pass + _final = ( + "Context compression is already running for this session. " + "Please retry in a moment — your next message will be processed " + "once the concurrent compression finishes." + ) + return { + "final_response": _final, + "messages": messages, + "completed": False, + "api_calls": api_call_count, + "error": _final, + "partial": True, + "failed": False, + "compression_deferred": True, + "session_id": agent.session_id, + } + + def _sync_failover_system_message(agent, api_messages, active_system_prompt): """Refresh the in-flight system message after a provider failover. @@ -1354,36 +1404,52 @@ def run_conversation( if _pre_api_status: agent._emit_status(_pre_api_status) _last_preflight_pressure = request_pressure_tokens + _pre_api_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=request_pressure_tokens, task_id=effective_task_id, ) - # Reset retry/empty-response state so the compacted request - # gets a fresh chance instead of inheriting stale recovery - # counters from the pre-compaction history. - agent._empty_content_retries = 0 - agent._thinking_prefill_retries = 0 - agent._last_content_with_tools = None - agent._last_content_tools_all_housekeeping = False - agent._mute_post_response = False - # Re-baseline the flush cursor for the compaction mode that just - # ran. Legacy session-rotation returns None (the child session has - # not seen the compacted transcript, so the next flush writes it - # whole); in-place compaction returns list(messages) because the - # compacted rows are already persisted under the same session id — - # leaving None there would re-append them, doubling the active - # context and retriggering compression. Mirrors the post-response - # and preflight compaction sites; see - # conversation_history_after_compression(). - conversation_history = conversation_history_after_compression( - agent, messages, conversation_history - ) - api_call_count -= 1 - agent._api_call_count = api_call_count - agent.iteration_budget.refund() - continue + if messages is _pre_api_input and compression_skipped_due_to_lock(agent): + # #69870 lock-skip: another path holds this session's + # compression lock, so this pass no-oped. That is a temporary + # DEFER, not evidence about compressibility — refund the + # attempt (it must not burn the shared overflow-recovery + # budget toward compression_exhausted → gateway auto-reset, + # #9893/#35809) and leave the insufficient-progress blocker + # unarmed. Proceed with the current request: if it truly does + # not fit, the provider's 413/overflow handler returns the + # soft compression_deferred result with that stronger signal. + compression_attempts -= 1 + _last_preflight_pressure = None + if pending_moa_prepared_request is _moa_prepared_request: + pending_moa_prepared_request = None + else: + # Reset retry/empty-response state so the compacted request + # gets a fresh chance instead of inheriting stale recovery + # counters from the pre-compaction history. + agent._empty_content_retries = 0 + agent._thinking_prefill_retries = 0 + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + agent._mute_post_response = False + # Re-baseline the flush cursor for the compaction mode that just + # ran. Legacy session-rotation returns None (the child session has + # not seen the compacted transcript, so the next flush writes it + # whole); in-place compaction returns list(messages) because the + # compacted rows are already persisted under the same session id — + # leaving None there would re-append them, doubling the active + # context and retriggering compression. Mirrors the post-response + # and preflight compaction sites; see + # conversation_history_after_compression(). + conversation_history = conversation_history_after_compression( + agent, messages, conversation_history + ) + api_call_count -= 1 + agent._api_call_count = api_call_count + agent.iteration_budget.refund() + continue elif ( agent.compression_enabled and len(messages) > 1 @@ -3841,10 +3907,23 @@ def run_conversation( original_len = len(messages) original_tokens = estimate_messages_tokens_rough(messages) + _overflow_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, ) + if messages is _overflow_input and compression_skipped_due_to_lock(agent): + # #69870 lock-skip: the provider proved the request + # does not fit, but this compression pass no-oped only + # because another path holds the session's compression + # lock. Temporary defer, not exhaustion — refund the + # attempt and end the turn softly so the gateway does + # NOT auto-reset the session (#9893/#35809). + compression_attempts -= 1 + agent._persist_session(messages, conversation_history) + return _compression_deferred_result( + agent, messages, api_call_count + ) conversation_history = conversation_history_after_compression( agent, messages, conversation_history ) @@ -4082,10 +4161,23 @@ def run_conversation( original_len = len(messages) original_tokens = estimate_messages_tokens_rough(messages) + _overflow_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, ) + if messages is _overflow_input and compression_skipped_due_to_lock(agent): + # #69870 lock-skip: the provider proved the request + # does not fit, but this compression pass no-oped only + # because another path holds the session's compression + # lock. Temporary defer, not exhaustion — refund the + # attempt and end the turn softly so the gateway does + # NOT auto-reset the session (#9893/#35809). + compression_attempts -= 1 + agent._persist_session(messages, conversation_history) + return _compression_deferred_result( + agent, messages, api_call_count + ) conversation_history = conversation_history_after_compression( agent, messages, conversation_history ) @@ -5488,14 +5580,27 @@ def run_conversation( if callable(_clear_warn): _clear_warn() agent._safe_print(" ⟳ compacting context…") + _post_tool_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=agent.context_compressor.last_prompt_tokens, task_id=effective_task_id, ) - conversation_history = conversation_history_after_compression( - agent, messages, conversation_history - ) + if ( + messages is _post_tool_input + and compression_skipped_due_to_lock(agent) + ): + # #69870 lock-skip: this pass no-oped because another + # path holds the session's compression lock — a + # temporary defer, not evidence about compressibility. + # Refund the attempt so a lock-loser tool loop does not + # burn the shared per-turn budget toward + # compression_exhausted (#9893/#35809). + compression_attempts -= 1 + else: + conversation_history = conversation_history_after_compression( + agent, messages, conversation_history + ) elif agent.compression_enabled: # Over threshold but compression is blocked (summary-LLM # cooldown or anti-thrashing). Surface a deduped warning so diff --git a/agent/turn_context.py b/agent/turn_context.py index c5a392eb920..6b2d0585882 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Mapping, Optional from agent.conversation_compression import ( IDLE_COMPACTION_STATUS_TEMPLATE, PREFLIGHT_COMPRESSION_STATUS_TEMPLATE, + compression_skipped_due_to_lock, conversation_history_after_compression, ) from agent.context_engine import automatic_compaction_status_message @@ -833,10 +834,29 @@ def build_turn_context( for _pass in range(_max_preflight_passes): _orig_len = len(messages) _orig_tokens = _preflight_tokens + _preflight_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=_preflight_tokens, task_id=effective_task_id, ) + if ( + messages is _preflight_input + and compression_skipped_due_to_lock(agent) + ): + # #69870 lock-skip: another path holds this session's + # compression lock, so the pass no-oped. That is a + # temporary DEFER, not proof the transcript cannot + # compress — do NOT arm the insufficient-progress + # blocker (the loop's error handlers must keep their + # provider-proven retry budget) and stop preflight + # passes for this turn; the lock winner is shrinking + # the same session concurrently. + logger.info( + "Preflight compression deferred: compression lock " + "held by another path (session %s)", + agent.session_id or "none", + ) + break # Re-estimate now so size-only compression (same row count, # lower token count — e.g. summarising tool outputs) is # recognised as progress instead of being misread as From eebc2286fcdf7339653d130dd7914c295f8d7c2c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:59:33 -0700 Subject: [PATCH 014/552] fix(gateway): retry-next-message semantics for compression_deferred + regression suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway half of the #49874 salvage: pass compression_deferred through both _run_agent_inner result dicts and guard the compression-exhausted auto-reset block with it — a lock-contended defer keeps the session intact (the concurrent compressor is actively shrinking it) instead of wiping it via reset_session. Regression tests: - tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and 400-overflow turns whose compression pass lost the lock end as compression_deferred (failed=False, no compression_exhausted); flag unset keeps the terminal exhaustion path byte-identical; type-pin tests vs MagicMock agents and junk flag values; cap=1 e2e proving the refunded pre-API defer leaves the budget for the provider-proven 413 retry. - tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight pass stops the loop WITHOUT arming preflight_compression_blocked; plain no-op still arms it; MagicMock junk does not defer. - tests/gateway/test_compression_deferred_soft_result.py — AST pin that the deferred branch guards the auto-reset chain and performs no session mutation (mirrors test_35809_auto_reset_clean_context.py). --- gateway/run.py | 23 +- tests/agent/test_preflight_lock_defer.py | 121 ++++++ .../test_compression_deferred_soft_result.py | 98 +++++ .../run_agent/test_compression_lock_defer.py | 374 ++++++++++++++++++ 4 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_preflight_lock_defer.py create mode 100644 tests/gateway/test_compression_deferred_soft_result.py create mode 100644 tests/run_agent/test_compression_lock_defer.py diff --git a/gateway/run.py b/gateway/run.py index 84ec11e945e..f085c3710fe 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13915,7 +13915,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # large to process. Auto-reset it so the next message starts # fresh instead of replaying the same oversized context in an # infinite fail loop. (#9893) - if agent_result.get("compression_exhausted") and session_entry and session_key: + # + # A lock-contended defer is the OPPOSITE case: the session is + # temporarily uncompressible only because a concurrent path holds + # the compression lock and is actively shrinking it. Never wipe + # the session for that — retry-next-message semantics apply + # (#69870 lock-skip consumer; salvaged from #49874). + if agent_result.get("compression_deferred"): + logger.info( + "Compression deferred for session %s — the compression " + "lock is held by a concurrent compressor. Keeping the " + "session intact; the next message retries normally.", + session_entry.session_id if session_entry else "?", + ) + elif agent_result.get("compression_exhausted") and session_entry and session_key: logger.info( "Auto-resetting session %s after compression exhaustion.", session_entry.session_id, @@ -21996,6 +22009,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "interrupt_message": result.get("interrupt_message"), "error": result.get("error"), "compression_exhausted": result.get("compression_exhausted", False), + "compression_deferred": result.get("compression_deferred", False), "tools": tools_holder[0] or [], "history_offset": _effective_history_offset, "compacted_in_place": _compacted_in_place, @@ -22113,6 +22127,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "partial": result_holder[0].get("partial", False) if result_holder[0] else False, "error": result_holder[0].get("error") if result_holder[0] else None, "interrupt_message": result_holder[0].get("interrupt_message") if result_holder[0] else None, + # Soft lock-contention defer (#69870 consumer): distinct from + # compression_exhausted so the gateway never auto-resets a + # session that a concurrent compressor is about to shrink. + "compression_deferred": ( + result_holder[0].get("compression_deferred", False) + if result_holder[0] else False + ), "tools": tools_holder[0] or [], "history_offset": _effective_history_offset, "compacted_in_place": _compacted_in_place, diff --git a/tests/agent/test_preflight_lock_defer.py b/tests/agent/test_preflight_lock_defer.py new file mode 100644 index 00000000000..8560bd2ec45 --- /dev/null +++ b/tests/agent/test_preflight_lock_defer.py @@ -0,0 +1,121 @@ +"""Preflight lock-defer must not arm the insufficient-progress blocker. + +Companion to ``tests/run_agent/test_compression_lock_defer.py`` — pins the +``build_turn_context`` preflight loop's handling of a lock-contended +compression no-op (#69870 lock-skip signal, consumer salvaged from #49874): + +* the pass stops the preflight loop (the lock winner is already shrinking + the session) WITHOUT setting ``preflight_compression_blocked``, so the + loop's provider-proven error handlers keep their full retry budget; +* a genuine no-progress no-op (flag unset) still arms the blocker exactly + as before; +* a MagicMock-style truthy junk flag value does NOT take the defer branch + (type-pin rule). +""" + +from __future__ import annotations + +import types +from unittest.mock import MagicMock, patch + +import pytest + +from tests.agent.test_turn_context import _FakeAgent, _build + + +@pytest.fixture(autouse=True) +def _stub_runtime_main(): + with patch("agent.auxiliary_client.set_runtime_main", lambda *a, **k: None): + yield + + +def _pressured_compressor(): + """Over-threshold compressor stub that opens the preflight threshold path.""" + return types.SimpleNamespace( + protect_first_n=0, + protect_last_n=0, + threshold_tokens=1, + context_length=100_000, + last_prompt_tokens=0, + should_compress=lambda _tokens=None: True, + should_compress_info=lambda _tokens=None: (True, None), + should_defer_preflight_to_real_usage=lambda _t: False, + get_active_compression_failure_cooldown=lambda: None, + ) + + +def _make_agent(): + agent = _FakeAgent() + agent.compression_enabled = True + agent.context_compressor = _pressured_compressor() + agent._emit_status = MagicMock() + return agent + + +_HISTORY = [{"role": "user", "content": "old"}, {"role": "assistant", "content": "older"}] + + +def test_preflight_lock_skip_does_not_set_blocked_flag(): + agent = _make_agent() + calls = [] + + def _lock_skip_compress(messages, _system_message, **_kwargs): + calls.append(1) + agent._compression_skipped_due_to_lock = "pid=1:tid=2:agent=aa:nonce=bb" + return messages, "SYSTEM" + + agent._compress_context = _lock_skip_compress + + ctx = _build(agent, conversation_history=list(_HISTORY)) + + # Exactly one pass: the defer stops the loop without arming the blocker. + assert calls == [1] + assert ctx.preflight_compression_blocked is False + + +def test_preflight_lock_skip_true_unconfirmed_holder_also_defers(): + agent = _make_agent() + calls = [] + + def _lock_skip_compress(messages, _system_message, **_kwargs): + calls.append(1) + agent._compression_skipped_due_to_lock = True + return messages, "SYSTEM" + + agent._compress_context = _lock_skip_compress + + ctx = _build(agent, conversation_history=list(_HISTORY)) + + assert calls == [1] + assert ctx.preflight_compression_blocked is False + + +def test_preflight_plain_noop_still_arms_blocker(): + """Control: flag unset → unchanged pre-fix behavior (blocker armed).""" + agent = _make_agent() + + def _noop_compress(messages, _system_message, **_kwargs): + agent._compression_skipped_due_to_lock = None + return messages, "SYSTEM" + + agent._compress_context = _noop_compress + + ctx = _build(agent, conversation_history=list(_HISTORY)) + + assert ctx.preflight_compression_blocked is True + + +def test_preflight_magicmock_flag_value_is_not_a_defer(): + """Type-pin: truthy junk (MagicMock auto-attribute shape) must not be + treated as lock contention — the blocker arms as for a plain no-op.""" + agent = _make_agent() + + def _junk_flag_compress(messages, _system_message, **_kwargs): + agent._compression_skipped_due_to_lock = MagicMock() + return messages, "SYSTEM" + + agent._compress_context = _junk_flag_compress + + ctx = _build(agent, conversation_history=list(_HISTORY)) + + assert ctx.preflight_compression_blocked is True diff --git a/tests/gateway/test_compression_deferred_soft_result.py b/tests/gateway/test_compression_deferred_soft_result.py new file mode 100644 index 00000000000..7cb25a0023f --- /dev/null +++ b/tests/gateway/test_compression_deferred_soft_result.py @@ -0,0 +1,98 @@ +"""Gateway must treat ``compression_deferred`` as a soft result (#49874). + +A lock-contended compression defer means a CONCURRENT compressor is actively +shrinking the session — the opposite of ``compression_exhausted`` (session +permanently too large). The gateway's auto-reset (#9893/#35809) must never +fire for a deferred turn: the session stays intact and the next message +retries normally. + +AST invariants on ``gateway/run.py`` (mirrors +``test_35809_auto_reset_clean_context.py``'s load-bearing pin style): + +* the ``compression_deferred`` branch guards the auto-reset block — a + deferred result can never reach ``reset_session``; +* the deferred branch itself performs NO session mutation (no + ``reset_session``, no ``_evict_cached_agent``, no + ``_clear_conversation_scope``). +""" + +from __future__ import annotations + +import ast +import inspect + +from gateway import run as gateway_run + + +def _calls(node: ast.AST) -> set[str]: + return { + n.func.attr + for n in ast.walk(node) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + } + + +def _find_deferred_guarded_reset_chain() -> ast.If: + """Return the ``if agent_result.get('compression_deferred') ... elif + agent_result.get('compression_exhausted') ... reset_session`` chain.""" + tree = ast.parse(inspect.getsource(gateway_run)) + + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + test_consts = [ + n.value + for n in ast.walk(node.test) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + ] + if "compression_deferred" not in test_consts: + continue + # The reset must live in the orelse (elif compression_exhausted ...), + # never in the deferred body. + orelse_calls = set() + for sub in node.orelse: + orelse_calls |= _calls(sub) + if "reset_session" in orelse_calls: + return node + raise AssertionError( + "Could not locate the compression_deferred guard in front of the " + "compression-exhausted auto-reset block in gateway/run.py. The " + "soft-defer contract (#49874: lock-contended defer must never " + "auto-reset the session) is no longer structurally guaranteed." + ) + + +class TestCompressionDeferredIsSoft: + def test_deferred_branch_guards_the_auto_reset(self): + """The auto-reset (``reset_session``) must be unreachable when + ``compression_deferred`` is set: the deferred check comes FIRST and + the reset lives only in its elif chain.""" + node = _find_deferred_guarded_reset_chain() + # The exhaustion reset is in the orelse — verified by the finder. + # The deferred body must not mutate the session in any way. + body_calls = set() + for sub in node.body: + body_calls |= _calls(sub) + forbidden = { + "reset_session", + "_evict_cached_agent", + "_clear_conversation_scope", + } + assert not (body_calls & forbidden), ( + f"The compression_deferred branch in gateway/run.py performs " + f"session mutation ({body_calls & forbidden}). A lock-contended " + f"defer is transient — the session must stay intact so the next " + f"message retries against the freshly compressed context " + f"(#49874, #69870)." + ) + + def test_deferred_result_key_is_passed_through_run_agent_inner(self): + """``_run_agent_inner``'s result dicts must carry the + ``compression_deferred`` key so the persistence block can see it — + the exact gap that made the exhaustion misclassification possible + (the flag existed but nothing consumed it).""" + src = inspect.getsource(gateway_run) + assert src.count('"compression_deferred"') >= 3, ( + "gateway/run.py must read AND pass through compression_deferred " + "(persistence-block guard + both _run_agent_inner result dicts)." + ) diff --git a/tests/run_agent/test_compression_lock_defer.py b/tests/run_agent/test_compression_lock_defer.py new file mode 100644 index 00000000000..e012f01af3d --- /dev/null +++ b/tests/run_agent/test_compression_lock_defer.py @@ -0,0 +1,374 @@ +"""Lock-contended compression no-ops must soft-DEFER, never exhaust (#49874). + +On main before this fix, nothing on the automatic compression paths consumed +the #69870 lock-skip signal (``agent._compression_skipped_due_to_lock``): + +* a lock-loser preflight/pre-API no-op counted as "insufficient progress", +* the oversized request went to the provider anyway, and +* the lock-contended 413/overflow retry burned ``compression_attempts`` to + the cap and returned ``compression_exhausted`` — which the gateway answers + with a full session auto-reset (#9893/#35809). + +A temporary lock defer misclassified as exhaustion == session wipe. + +These tests pin the fix: when a compression pass returns its input unchanged +AND the type-pinned lock-skip flag is set, the attempt is refunded and the +turn ends (when it cannot proceed) with a soft ``compression_deferred`` +result distinct from ``compression_exhausted``. + +Salvaged from PR #49874 (@helix4u), rebuilt on the landed #69870 signal. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from agent.conversation_compression import compression_skipped_due_to_lock +from run_agent import AIAgent +import run_agent + + +LOCK_HOLDER = "pid=4242:tid=1:agent=deadbeef:nonce=abcd1234" + + +# --------------------------------------------------------------------------- +# Helpers (mirrors tests/run_agent/test_413_compression.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _no_compression_sleep(monkeypatch): + import time as _time + + monkeypatch.setattr(_time, "sleep", lambda *_a, **_k: None) + monkeypatch.setattr(run_agent, "jittered_backoff", lambda *a, **k: 0.0) + + +def _make_tool_defs(*names: str) -> list: + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +def _mock_response(content="Hello", finish_reason="stop"): + msg = SimpleNamespace( + content=content, + tool_calls=None, + reasoning_content=None, + reasoning=None, + ) + choice = SimpleNamespace(message=msg, finish_reason=finish_reason) + resp = SimpleNamespace(choices=[choice], model="test/model") + resp.usage = None + return resp + + +def _make_413_error(message="Request entity too large"): + err = Exception(message) + err.status_code = 413 + return err + + +def _make_overflow_error(): + return Exception( + "Error code: 400 - {'type': 'error', 'error': {'type': " + "'invalid_request_error', 'message': 'prompt is too long: " + "233153 tokens > 200000 maximum'}}" + ) + + +@pytest.fixture() +def agent(): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + a._cached_system_prompt = "You are helpful." + a._use_prompt_caching = False + a.tool_delay = 0 + a.compression_enabled = True + a.save_trajectories = False + return a + + +_PREFILL = [ + {"role": "user", "content": "previous question"}, + {"role": "assistant", "content": "previous answer"}, +] + + +def _lock_skipping_compress(agent, *, holder=LOCK_HOLDER): + """A compress double that no-ops because 'another path holds the lock'. + + Mirrors the real ``compress_context`` lock-contended abort: returns the + INPUT list object unchanged and sets the #69870 lock-skip signal. + """ + + def _compress(messages, _system_message, **_kwargs): + agent._compression_skipped_due_to_lock = holder + return messages, "You are helpful." + + return _compress + + +def _plain_noop_compress(agent): + """A compress double that no-ops WITHOUT lock contention (real no-progress).""" + + def _compress(messages, _system_message, **_kwargs): + agent._compression_skipped_due_to_lock = None + return messages, "You are helpful." + + return _compress + + +# --------------------------------------------------------------------------- +# Type-pinned signal read (MagicMock test-double immunity) +# --------------------------------------------------------------------------- + + +class TestLockSkipSignalTypePin: + def test_true_and_holder_string_are_lock_skips(self): + a = SimpleNamespace(_compression_skipped_due_to_lock=True) + assert compression_skipped_due_to_lock(a) is True + a = SimpleNamespace(_compression_skipped_due_to_lock=LOCK_HOLDER) + assert compression_skipped_due_to_lock(a) is True + + def test_none_and_missing_are_not_lock_skips(self): + assert compression_skipped_due_to_lock( + SimpleNamespace(_compression_skipped_due_to_lock=None) + ) is False + assert compression_skipped_due_to_lock(SimpleNamespace()) is False + + def test_magicmock_agent_auto_attribute_is_not_a_lock_skip(self): + """MagicMock agents auto-create truthy attributes; bare truthiness + would hijack every mocked agent in sibling suites into the lock-skip + branch (the #69870 × #69840 incident). The read must be type-pinned.""" + assert compression_skipped_due_to_lock(MagicMock()) is False + + def test_truthy_non_true_non_str_values_are_not_lock_skips(self): + for junk in (1, 1.0, ["holder"], {"holder": True}, object(), MagicMock()): + a = SimpleNamespace(_compression_skipped_due_to_lock=junk) + assert compression_skipped_due_to_lock(a) is False, junk + + +# --------------------------------------------------------------------------- +# 413 handler: lock-contended no-op → soft defer, no exhaustion +# --------------------------------------------------------------------------- + + +class TestLockContended413Defer: + def test_lock_contended_413_returns_compression_deferred(self, agent): + """A 413 whose compression pass lost the lock must end the turn as a + soft ``compression_deferred`` — never ``compression_exhausted``.""" + agent.client.chat.completions.create.side_effect = _make_413_error() + + with ( + patch.object( + agent, "_compress_context", + side_effect=_lock_skipping_compress(agent), + ) as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + mock_compress.assert_called_once() + assert result.get("compression_deferred") is True + assert not result.get("compression_exhausted") + # Soft defer: transient, retry-next-message semantics — the gateway + # persists the user turn (failed=False) and never auto-resets. + assert result.get("failed") is False + assert result.get("completed") is False + assert result.get("partial") is True + + def test_lock_contended_overflow_returns_compression_deferred(self, agent): + """Same contract on the context-length (400 prompt-too-long) handler.""" + agent.client.chat.completions.create.side_effect = _make_overflow_error() + + with ( + patch.object( + agent, "_compress_context", + side_effect=_lock_skipping_compress(agent), + ) as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + mock_compress.assert_called_once() + assert result.get("compression_deferred") is True + assert not result.get("compression_exhausted") + assert result.get("failed") is False + + def test_unconfirmed_lock_skip_true_also_defers(self, agent): + """``_compression_skipped_due_to_lock = True`` (holder unconfirmed — + ``try_acquire`` swallowed a sqlite error) is still a lock skip.""" + agent.client.chat.completions.create.side_effect = _make_413_error() + + with ( + patch.object( + agent, "_compress_context", + side_effect=_lock_skipping_compress(agent, holder=True), + ), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + assert result.get("compression_deferred") is True + assert not result.get("compression_exhausted") + + def test_plain_noop_413_still_exhausts_unchanged(self, agent): + """Control: flag unset (real no-progress compression) keeps the + pre-fix behavior byte-for-byte — terminal ``compression_exhausted``.""" + agent.client.chat.completions.create.side_effect = _make_413_error() + + with ( + patch.object( + agent, "_compress_context", + side_effect=_plain_noop_compress(agent), + ), + patch.object( + agent, "_try_strip_image_parts_from_tool_messages", + return_value=False, + ), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + assert result.get("compression_exhausted") is True + assert not result.get("compression_deferred") + assert result.get("failed") is True + + def test_magicmock_flag_value_does_not_defer(self, agent): + """Type-pin at the consumer site: a truthy non-True/non-str flag value + (e.g. a MagicMock auto-attribute) must NOT take the defer branch.""" + agent.client.chat.completions.create.side_effect = _make_413_error() + + def _junk_flag_compress(messages, _system_message, **_kwargs): + agent._compression_skipped_due_to_lock = MagicMock() # truthy junk + return messages, "You are helpful." + + with ( + patch.object(agent, "_compress_context", side_effect=_junk_flag_compress), + patch.object( + agent, "_try_strip_image_parts_from_tool_messages", + return_value=False, + ), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + assert not result.get("compression_deferred") + assert result.get("compression_exhausted") is True + + +# --------------------------------------------------------------------------- +# Pre-API gate: a lock-skipped pass must not burn the shared attempt budget +# --------------------------------------------------------------------------- + + +class TestPreApiLockDeferDoesNotBurnBudget: + def test_lock_loser_turn_recovers_after_lock_release(self, agent): + """End-to-end shape of the live bug at cap=1: + + 1. Pre-API pressure gate fires; the compression pass loses the lock + (no-op + lock-skip flag). Pre-fix this burned the single shared + attempt. + 2. The oversized request goes to the provider → 413. + 3. The 413 handler compresses again — the lock has been released and + the pass now succeeds — and the retry completes. + + Pre-fix, step 3 found ``compression_attempts`` already at the cap and + returned ``compression_exhausted`` → gateway session wipe. The defer + refund keeps the budget intact for the provider-proven retry. + """ + agent.max_compression_attempts = 1 + # Compressor stub: pressure only on the fully-assembled request + # (pre-API site); the turn-context preflight stands down via the + # cheap-gate (small message count) and low turn-context estimate. + agent.context_compressor = SimpleNamespace( + protect_first_n=3, + protect_last_n=20, + threshold_tokens=100_000, + context_length=1_000_000, + last_prompt_tokens=0, + should_compress=lambda t: t >= 100_000, + should_defer_preflight_to_real_usage=lambda _t: False, + get_active_compression_failure_cooldown=lambda: None, + ) + + agent.client.chat.completions.create.side_effect = [ + _make_413_error(), + _mock_response(content="Recovered after lock release"), + ] + + compress_calls = [] + + def _lock_then_success(messages, _system_message, **_kwargs): + compress_calls.append(len(messages)) + if len(compress_calls) == 1: + # Lock loser: no-op + #69870 signal. + agent._compression_skipped_due_to_lock = LOCK_HOLDER + return messages, "You are helpful." + # Lock released: real compaction (entry clears the signal). + agent._compression_skipped_due_to_lock = None + return ( + [{"role": "user", "content": "hello"}], + "You are helpful.", + ) + + with ( + patch( + "agent.turn_context.estimate_request_tokens_rough", + return_value=10, + ), + patch( + "agent.conversation_loop.estimate_request_tokens_rough", + return_value=500_000, + ), + patch( + "agent.conversation_loop.estimate_messages_tokens_rough", + return_value=500_000, + ), + patch.object(agent, "_compress_context", side_effect=_lock_then_success), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=list(_PREFILL)) + + # Pass 1: pre-API (lock defer, refunded). Pass 2: 413 handler + # (succeeds within the cap because the defer did not count). + assert len(compress_calls) == 2 + assert result.get("completed") is True + assert result["final_response"] == "Recovered after lock release" + assert not result.get("compression_exhausted") + assert not result.get("compression_deferred") From 34678d2f2edd46cf930b8d3f6164133e79995eb4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 17:23:46 +0800 Subject: [PATCH 015/552] fix(compression): skip empty post-handoff summary windows --- agent/context_compressor.py | 33 ++++++++++++++ ...t_context_compressor_summary_continuity.py | 45 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e282a6c7a45..76c4260b69f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4521,6 +4521,39 @@ This compaction should PRIORITISE preserving all information related to the focu ) telemetry["chunk_count"] = 1 if turns_to_summarize else 0 + if not turns_to_summarize: + # The newest handoff summary consumed the entire compressible + # window (every window row was a standalone handoff that strips + # to None, and nothing follows it before compress_end) — there + # is nothing new to summarize. Skip the summary call entirely: + # without this guard the empty window still reached + # _generate_summary, wasting an aux LLM call that aborts + # noisily on empty input (#59496). Mirrors the sibling + # "no compressable window" guard above (#40803): record an + # ineffective strike through the durable write-through helper + # so the anti-thrash breaker in should_compress() can stop the + # loop — this shape cannot shrink, so every subsequent turn + # would otherwise re-fire the same no-op. The rehydrated + # _previous_summary is deliberately KEPT (not rolled back as + # the summary-abort path does for #57835): it came from a + # handoff genuinely present in this transcript, which is + # returned unchanged. + telemetry["failure_class"] = "empty_post_handoff_window" + self._record_ineffective_compression_verdict( + self._ineffective_compression_count + 1, + ) + self._last_compression_savings_pct = 0.0 + if not self.quiet_mode: + logger.warning( + "Compression skipped: latest context summary leaves no " + "new turns to summarize in window %d-%d. " + "ineffective_compression_count=%d", + compress_start, + compress_end, + self._ineffective_compression_count, + ) + return messages + if not self.quiet_mode: logger.info( "Context compression triggered (%d tokens >= %d threshold)", diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index 18b4c76b625..642ee2dbdc0 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -725,3 +725,48 @@ def test_metadata_summary_decay_also_rehydrates_previous_summary(): assert "metadata-only prior summary" in prompt # Grounding may prepend a task-snapshot section; pin the fresh body. assert (compressor._previous_summary or "").endswith("fresh summary") + + +def test_empty_post_handoff_window_noops_without_summary_call(): + """A latest handoff that consumes the window must not trigger an empty summary. + + Regression test from PR #59526 (#59496), fixture adapted to current main: + the standalone handoff sits alone in the compressible window, strips to + None via _strip_context_summary_handoff_message, and leaves + turns_to_summarize empty — the guard must skip _generate_summary + entirely instead of wasting an aux LLM call on empty input. + """ + compressor = _compressor() + old_summary = "WINDOW-END-SUMMARY durable facts already captured" + messages = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "assistant", "content": "recent tail response"}, + {"role": "user", "content": "tail request"}, + {"role": "assistant", "content": "tail answer"}, + {"role": "user", "content": "latest tail request"}, + {"role": "assistant", "content": "latest tail answer"}, + ] + + with ( + patch.object(compressor, "_find_tail_cut_by_tokens", return_value=2), + patch.object(compressor, "_generate_summary") as mock_generate_summary, + ): + result = compressor.compress(messages, current_tokens=90_000) + + mock_generate_summary.assert_not_called() + assert result == messages + # The rehydrated summary state is deliberately kept: the handoff is + # genuinely present in the returned (unchanged) transcript. + assert compressor._previous_summary == old_summary + assert compressor.compression_count == 0 + # Mirrors the sibling no-compressible-window guard (#40803): the shape + # cannot shrink, so it counts as an ineffective strike (routed through + # the durable write-through helper) to arm the anti-thrash breaker. + assert compressor._ineffective_compression_count == 1 + assert compressor._last_compression_savings_pct == 0.0 + assert compressor._last_summary_dropped_count == 0 + assert compressor._last_summary_fallback_used is False + assert compressor._last_compress_aborted is False + telemetry = compressor._last_compression_telemetry or {} + assert telemetry.get("failure_class") == "empty_post_handoff_window" From 6a8d31856fa8e14350157c2b051790839602842d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:55:44 -0700 Subject: [PATCH 016/552] chore: map kinsonnee@gmail.com -> WOLIKIMCHENG for #59526 salvage --- contributors/emails/kinsonnee@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/kinsonnee@gmail.com diff --git a/contributors/emails/kinsonnee@gmail.com b/contributors/emails/kinsonnee@gmail.com new file mode 100644 index 00000000000..8094ea9fd25 --- /dev/null +++ b/contributors/emails/kinsonnee@gmail.com @@ -0,0 +1 @@ +WOLIKIMCHENG From 17a81ac89e6399ffcab9853d2edb983ccb46bed4 Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Mon, 29 Jun 2026 18:12:46 +0800 Subject: [PATCH 017/552] fix(context_compression): roll back interrupted preflight state pollution Interrupted turns can seed a speculative display token count before the provider receives the request. Restore that display-only seed when interruption wins the race, while preserving completed post-compaction state and treating a successful provider response independently of optional usage metadata. Constraint: #54776 remains reproducible on current main, while review #4702305384 identifies anti-thrashing rollback as stale and usage receipt as an unreliable response-completion signal. Rejected: Restore anti-thrashing counters from a preflight snapshot | current main derives their verdict from real provider usage after a completed compaction boundary. Confidence: high Scope-risk: narrow Directive: Keep interrupted preflight rollback display-only, and never infer provider completion from the presence of usage metadata. Tested: ./.venv/bin/python -m pytest -q tests/run_agent/test_413_compression.py (29 passed); turn-finalizer/conversation-loop tests (31 passed); context-compressor targeted tests (12 passed); infinite-compaction targeted tests (3 passed); ruff; git diff --check. Not-tested: End-to-end interactive interrupt through CLI or gateway transport. --- agent/context_compressor.py | 10 ++ agent/conversation_loop.py | 2 + agent/turn_context.py | 5 + agent/turn_finalizer.py | 20 ++++ tests/run_agent/test_413_compression.py | 121 ++++++++++++++++++++++++ 5 files changed, 158 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 76c4260b69f..2e0f66260d3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1856,6 +1856,16 @@ class ContextCompressor(ContextEngine): self._verify_compaction_cleared_threshold = False self.awaiting_real_usage_after_compression = False + def snapshot_preflight_display_tokens(self) -> int: + """Capture the display token count before a speculative preflight seed.""" + return self.last_prompt_tokens + + def rollback_interrupted_preflight_display_tokens(self, snapshot: int) -> None: + """Restore a speculative display seed without touching compaction state.""" + if self.awaiting_real_usage_after_compression and self.last_prompt_tokens == -1: + return + self.last_prompt_tokens = snapshot + def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool: """Return True when a high rough preflight estimate is known-noisy. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 7bd958612b6..a3deadd1048 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2065,6 +2065,8 @@ def run_conversation( ) continue # Retry the API call + agent._turn_received_provider_response = True + # Check finish_reason before proceeding if agent.api_mode == "codex_responses": status = getattr(response, "status", None) diff --git a/agent/turn_context.py b/agent/turn_context.py index 6b2d0585882..f3f5e46b21f 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -706,6 +706,8 @@ def build_turn_context( # issue #27405 (a few very large messages slipping past the count gate). _preflight_compressed = False _preflight_compression_blocked = False + agent._turn_received_provider_response = False + agent._turn_preflight_display_snapshot = None if agent.compression_enabled and _should_run_preflight_estimate( messages, agent.context_compressor.protect_first_n, @@ -718,6 +720,9 @@ def build_turn_context( tools=agent.tools or None, ) _compressor = agent.context_compressor + agent._turn_preflight_display_snapshot = ( + _compressor.snapshot_preflight_display_tokens() + ) _defer_preflight = getattr( _compressor, "should_defer_preflight_to_real_usage", diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 1a7b52ff516..17b49479a88 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -201,6 +201,23 @@ def finalize_turn( ) ) + # Preflight can seed the display count before the provider receives the + # request. Roll that estimate back only when an interrupt wins the race + # before any successful provider response. Compaction state remains owned + # by the real-usage/post-compaction path, including its ``-1`` sentinel. + _preflight_snapshot = getattr( + agent, "_turn_preflight_display_snapshot", None + ) + if ( + interrupted + and _preflight_snapshot is not None + and not getattr(agent, "_turn_received_provider_response", False) + and getattr(agent, "context_compressor", None) is not None + ): + agent.context_compressor.rollback_interrupted_preflight_display_tokens( + _preflight_snapshot + ) + # Post-loop cleanup must never lose the response. Trajectory save, # resource teardown, and session persistence all touch fallible # surfaces — file I/O / JSON serialization (_save_trajectory), remote @@ -625,4 +642,7 @@ def finalize_turn( except Exception as exc: logger.warning("on_session_end hook failed: %s", exc) + agent._turn_preflight_display_snapshot = None + agent._turn_received_provider_response = False + return result diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 1d259e23901..cf05a9a9054 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -1465,6 +1465,127 @@ class TestPreflightCompression: assert mock_compress.call_count == 2 + def test_interrupt_before_first_provider_call_restores_preflight_display_seed(self, agent): + """Interrupted turns must not keep a speculative preflight display seed. + + Preflight runs before the main loop checks ``_interrupt_requested``. + If the user interrupts during that window, no provider usage ever + arrives to validate the rough estimate, so the old display token count + must be restored instead of leaking the speculative value forward. + """ + agent.compression_enabled = True + agent._interrupt_requested = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.threshold_tokens = 130_000 + agent.context_compressor.last_prompt_tokens = 74_400 + + big_history = [] + for i in range(20): + big_history.append({"role": "user", "content": f"Message {i} padded text"}) + big_history.append({"role": "assistant", "content": f"Response {i} padded text"}) + + with ( + patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669), + patch.object(agent.context_compressor, "should_compress", return_value=False), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=big_history) + + assert result["interrupted"] is True + assert agent.client.chat.completions.create.call_count == 0 + assert agent.context_compressor.last_prompt_tokens == 74_400 + + def test_usage_less_provider_response_prevents_display_seed_rollback(self, agent): + """A successful response counts even when the provider omits usage.""" + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.threshold_tokens = 130_000 + agent.context_compressor.last_prompt_tokens = 74_400 + + big_history = [] + for i in range(20): + big_history.append({"role": "user", "content": f"Message {i} padded text"}) + big_history.append({"role": "assistant", "content": f"Response {i} padded text"}) + + tool_call = SimpleNamespace( + id="tc1", + type="function", + function=SimpleNamespace(name="web_search", arguments='{"query":"test"}'), + ) + agent.client.chat.completions.create.side_effect = [ + _mock_response( + content=None, + finish_reason="tool_calls", + tool_calls=[tool_call], + usage=None, + ) + ] + + def _interrupt_after_tool(*_args, **_kwargs): + agent._interrupt_requested = True + + with ( + patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669), + patch.object(agent.context_compressor, "should_compress", return_value=False), + patch.object(agent, "_execute_tool_calls", side_effect=_interrupt_after_tool), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=big_history) + + assert result["interrupted"] is True + assert agent.client.chat.completions.create.call_count == 1 + assert agent.context_compressor.last_prompt_tokens == 144_669 + + def test_interrupt_keeps_post_compression_state(self, agent): + """Display rollback must not restore real post-compaction state. + + A completed preflight compaction still leaves the conversation in the + post-compression ``-1`` sentinel state. Its anti-thrashing verdict also + remains owned by the completed compaction boundary rather than the + speculative display snapshot. + """ + agent.compression_enabled = True + agent._interrupt_requested = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.threshold_tokens = 130_000 + agent.context_compressor.last_prompt_tokens = 74_400 + agent.context_compressor._ineffective_compression_count = 1 + + big_history = [] + for i in range(20): + big_history.append({"role": "user", "content": f"Message {i} padded text"}) + big_history.append({"role": "assistant", "content": f"Response {i} padded text"}) + + def _fake_preflight_compress(msgs, *_args, **_kwargs): + agent.context_compressor.last_prompt_tokens = -1 + agent.context_compressor.awaiting_real_usage_after_compression = True + agent.context_compressor.compression_count += 1 + agent.context_compressor._ineffective_compression_count = 2 + agent.context_compressor._last_compression_savings_pct = 0.0 + return msgs, agent._cached_system_prompt + + with ( + patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669), + patch.object(agent.context_compressor, "should_compress", return_value=True), + patch.object(agent, "_compress_context", side_effect=_fake_preflight_compress), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=big_history) + + assert result["interrupted"] is True + assert agent.client.chat.completions.create.call_count == 0 + assert agent.context_compressor.last_prompt_tokens == -1 + assert agent.context_compressor.awaiting_real_usage_after_compression is True + assert agent.context_compressor._ineffective_compression_count == 2 + assert agent.context_compressor._last_compression_savings_pct == 0.0 + + class TestToolResultPreflightCompression: """Compression should trigger when tool results push context past the threshold.""" From 66fdcfa3bd5aa82173c43d55fa4b5db29af42fe7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:49:23 -0700 Subject: [PATCH 018/552] fix: harden salvaged preflight display rollback for test-double density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #54805 cherry-pick (skill guard rules): - turn_context.py: snapshot_preflight_display_tokens gets a getattr+callable guard (SimpleNamespace compressor doubles / plugin context engines lack the ContextCompressor-only method) and the snapshot value is type-pinned to a real int (bool excluded) before arming the rollback — MagicMock compressors return truthy Mocks. - turn_finalizer.py: interrupted pinned 'is True', the _turn_received_provider_response read pinned 'is not True' (MagicMock auto-attrs are truthy), and the compressor rollback method call gets a getattr+callable guard. Rollback stays display-only: it never touches _ineffective_compression_count or any durable guard, and preserves the -1 post-compaction sentinel. --- agent/turn_context.py | 16 ++++++++++++++-- agent/turn_finalizer.py | 23 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/agent/turn_context.py b/agent/turn_context.py index f3f5e46b21f..59914befe0c 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -720,9 +720,21 @@ def build_turn_context( tools=agent.tools or None, ) _compressor = agent.context_compressor - agent._turn_preflight_display_snapshot = ( - _compressor.snapshot_preflight_display_tokens() + # getattr guard: minimal compressor doubles (SimpleNamespace in the + # engine-preflight tests) and plugin context engines lack this + # ContextCompressor-only method — absence means no snapshot, and the + # finalizer's rollback stays disarmed for the turn (display-only). + _snapshot_fn = getattr( + _compressor, "snapshot_preflight_display_tokens", None ) + if callable(_snapshot_fn): + _snapshot_val = _snapshot_fn() + # Type pin: MagicMock compressors return truthy Mock objects — + # only a real int snapshot may arm the interrupted-turn rollback. + if isinstance(_snapshot_val, int) and not isinstance( + _snapshot_val, bool + ): + agent._turn_preflight_display_snapshot = _snapshot_val _defer_preflight = getattr( _compressor, "should_defer_preflight_to_real_usage", diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 17b49479a88..2126a9afdc2 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -205,18 +205,31 @@ def finalize_turn( # request. Roll that estimate back only when an interrupt wins the race # before any successful provider response. Compaction state remains owned # by the real-usage/post-compaction path, including its ``-1`` sentinel. + # Guard rules (test-double density on this path is high): + # - snapshot is type-pinned to a real int — MagicMock agents auto-create + # truthy Mock attributes that must never arm the rollback; + # - the received-response flag is pinned to ``is not True`` — its real + # domain is True/False, and only a literal True means a provider + # response completed; + # - the compressor method gets a getattr+callable guard — SimpleNamespace + # compressor doubles and plugin context engines lack it. _preflight_snapshot = getattr( agent, "_turn_preflight_display_snapshot", None ) if ( - interrupted - and _preflight_snapshot is not None - and not getattr(agent, "_turn_received_provider_response", False) + interrupted is True + and isinstance(_preflight_snapshot, int) + and not isinstance(_preflight_snapshot, bool) + and getattr(agent, "_turn_received_provider_response", False) is not True and getattr(agent, "context_compressor", None) is not None ): - agent.context_compressor.rollback_interrupted_preflight_display_tokens( - _preflight_snapshot + _rollback_fn = getattr( + agent.context_compressor, + "rollback_interrupted_preflight_display_tokens", + None, ) + if callable(_rollback_fn): + _rollback_fn(_preflight_snapshot) # Post-loop cleanup must never lose the response. Trajectory save, # resource teardown, and session persistence all touch fallible From cb481e2f2b78c00ec4968b6171aa7e29c189e92c Mon Sep 17 00:00:00 2001 From: Kolektori <256073454+Kolektori@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:37:39 +0400 Subject: [PATCH 019/552] feat(compression): proactive tool-result pruning for large-window models The phase-1 tool-result prune only runs inside compress(), which fires near 50% of the context window, so it never triggers on large-window models; old tool outputs then ride in history and are re-sent every turn. Add prune_tool_results_only(): the same no-LLM prune on a separate, low proactive_prune_tokens trigger, run as an elif to the compression branch. Opt-in (default 0), protects the recent tail by message count. Add the method to the ContextEngine base as a no-op default so pluggable engines inherit it safely (the post-tool-call path never AttributeErrors on a non-built-in engine); the built-in compressor supplies the real prune. Register both keys under the top-level compression config with defaults and document them. --- agent/agent_init.py | 36 ++++ agent/context_compressor.py | 102 ++++++++++- agent/context_engine.py | 21 +++ agent/conversation_loop.py | 42 +++++ hermes_cli/config.py | 23 +++ tests/agent/test_context_engine.py | 15 ++ .../test_proactive_tool_result_pruning.py | 164 ++++++++++++++++++ website/docs/user-guide/configuration.md | 5 + 8 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_proactive_tool_result_pruning.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 241d3689ebd..e239c48cfbd 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1838,6 +1838,39 @@ def init_agent( if compression_max_attempts < 1: compression_max_attempts = 3 compression_max_attempts = min(compression_max_attempts, 10) + + def _parse_prune_int(raw, default): + # Same parser semantics as compression.max_attempts above: reject + # booleans (bool subclasses int — YAML `true` would coerce to 1), + # reject fractional floats rather than truncating them, accept + # integral floats and numeric strings, fall back to the default on + # anything else. + if isinstance(raw, bool): + return default + if isinstance(raw, int): + return raw + if isinstance(raw, float): + return int(raw) if raw.is_integer() else default + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return default + + # Opt-in proactive tool-result prune trigger (0 = disabled — the + # default, so an unset key is behavior-neutral). Negative values are + # treated as disabled rather than erroring. + compression_proactive_prune_tokens = max( + 0, _parse_prune_int(_compression_cfg.get("proactive_prune_tokens", 0), 0) + ) + compression_proactive_prune_min_chars = _parse_prune_int( + _compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000 + ) + compression_proactive_prune_min_reclaim = max( + 0, + _parse_prune_int( + _compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096 + ), + ) # protect_first_n is the number of non-system messages to protect at # the head, in addition to the system prompt (which is always # implicitly protected by the compressor). Floor at 0 — a value of @@ -2312,6 +2345,9 @@ def init_agent( max_tokens=agent.max_tokens, model_thresholds=compression_model_thresholds, threshold_tokens_cap=compression_threshold_tokens, + proactive_prune_tokens=compression_proactive_prune_tokens, + proactive_prune_min_result_chars=compression_proactive_prune_min_chars, + proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim, ) _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) if callable(_bind_session_state): diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2e0f66260d3..2bb98aee77e 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1629,6 +1629,9 @@ class ContextCompressor(ContextEngine): max_tokens: int | None = None, model_thresholds: dict[str, float] | None = None, threshold_tokens_cap: Any = None, + proactive_prune_tokens: int = 0, + proactive_prune_min_result_chars: int = 8000, + proactive_prune_min_reclaim_tokens: int = 4096, ): self.model = model self.base_url = base_url @@ -1658,6 +1661,31 @@ class ContextCompressor(ContextEngine): ) self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n + # Proactive tool-result pruning (cost-oriented; runs INDEPENDENTLY of the + # full-compression trigger, via prune_tool_results_only()). 0 = disabled. + self.proactive_prune_tokens = int(proactive_prune_tokens or 0) + # Floor the summarize threshold at 200 chars (matching + # _prune_old_tool_results' dedup floor). Below ~200 a generated summary + # can be longer than the floor it replaces, so Pass 2 would re-summarize + # its own output every turn (corrupting it and never converging); a + # negative value would strip every non-tail tool result outright. A + # configured 0 keeps the 8000 default via `or`. Keep the floor well above + # typical summary length (default 8000) to stay idempotent. + self.proactive_prune_min_result_chars = max( + 200, int(proactive_prune_min_result_chars or 8000) + ) + # Minimum estimated token reclaim before a proactive prune COMMITS. + # Every commit rewrites messages the provider has already seen, which + # invalidates the prompt-cache prefix from the earliest rewritten + # message forward. Without this gate a busy tool loop would re-fire + # the prune nearly every iteration (each new tool pair ages an old one + # out of the protected tail), breaking the cache per turn. Requiring a + # meaningful batch of reclaimable tokens makes fires episodic and + # amortized — the same way full compression is the one sanctioned + # cache break. 0 disables the gate (commit any non-zero prune). + self.proactive_prune_min_reclaim_tokens = max( + 0, int(proactive_prune_min_reclaim_tokens or 0) + ) self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) self.quiet_mode = quiet_mode # Output-token reservation: the provider carves max_tokens out of the @@ -2059,6 +2087,7 @@ class ContextCompressor(ContextEngine): def _prune_old_tool_results( self, messages: List[Dict[str, Any]], protect_tail_count: int, protect_tail_tokens: int | None = None, + min_prune_chars: int = 200, ) -> tuple[List[Dict[str, Any]], int]: """Replace old tool result contents with informative 1-line summaries. @@ -2201,7 +2230,9 @@ class ContextCompressor(ContextEngine): return False if content.startswith("[screenshot removed"): return False - if len(content) <= 200: + # Only prune if the content is substantial (default >200 chars; the + # proactive path raises this floor via min_prune_chars). + if len(content) <= min_prune_chars: return False call_id = msg.get("tool_call_id", "") tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", "")) @@ -2317,6 +2348,75 @@ class ContextCompressor(ContextEngine): return result, pruned + def prune_tool_results_only( + self, messages: List[Dict[str, Any]], current_tokens: int | None = None, + ) -> tuple[List[Dict[str, Any]], int]: + """Deterministic, no-LLM tool-result prune for the cost-oriented path. + + Runs the Phase-1 prune (``_prune_old_tool_results``) WITHOUT the + compression summary phase, gated on ``proactive_prune_tokens`` rather + than the (much higher) full-compression threshold. On large-window + models ``should_compress()`` (≈50% of the window) rarely fires, so old + tool outputs otherwise ride in history and are re-sent verbatim on every + subsequent turn; this reclaims them early with no quality-risky LLM + summarization. + + Protects the recent tail by message COUNT (``protect_last_n``), never by + ``tail_token_budget`` — the latter is derived from the 50% compression + threshold (≈100K tokens on a 1M window) and would protect the entire + session, pruning nothing. + + ``_prune_old_tool_results`` runs all three deterministic passes: + (1) dedup byte-identical tool results — keeps the newest full copy and + back-references older exact duplicates ANYWHERE in the list (including + the protected tail), so no unique content is ever lost; (2) summarize + non-tail tool results larger than ``min_prune_chars``; (3) truncate + oversized tool_call arguments on non-tail assistant messages. Only + pass (2)'s floor is raised by ``proactive_prune_min_result_chars``; + passes (1) and (3) keep their own fixed floors. The recent-tail + protection applies to passes (2) and (3); pass (1) is tail-agnostic by + design because dedup is lossless. + + PROMPT-CACHE CONTRACT: a committed prune rewrites message bodies the + provider has already seen, invalidating the cached prefix from the + earliest rewritten message forward — exactly like a compression + boundary. To keep that break episodic rather than per-turn, the prune + only COMMITS when the estimated reclaim meets + ``proactive_prune_min_reclaim_tokens`` (measured on the actual pruned + output, not guessed up front). Below the gate the INPUT list object is + returned unchanged — the standard no-op caller contract (callers gate + bookkeeping on ``result is not input``). + + Returns ``(messages, 0)`` — the input object — when disabled, below + the trigger, or when the reclaim gate rejects the commit. + """ + if self.proactive_prune_tokens <= 0: + return messages, 0 + if current_tokens is not None and current_tokens < self.proactive_prune_tokens: + return messages, 0 + # Nothing to reclaim until there are messages outside the protected tail. + if len(messages) <= self.protect_last_n + self._protect_head_size(messages) + 1: + return messages, 0 + pruned_msgs, pruned_count = self._prune_old_tool_results( + messages, + protect_tail_count=self.protect_last_n, + protect_tail_tokens=None, + min_prune_chars=self.proactive_prune_min_result_chars, + ) + if not pruned_count: + # Standard no-op contract: hand back the INPUT object so callers + # can gate bookkeeping on `result is not input`. + return messages, 0 + # Measured-savings gate (prompt-cache hysteresis): only commit when + # the prune reclaims a meaningful batch of tokens. Estimated on the + # real before/after messages so dedup + arg truncation count too. + if self.proactive_prune_min_reclaim_tokens > 0: + before = sum(_estimate_msg_budget_tokens(m) for m in messages) + after = sum(_estimate_msg_budget_tokens(m) for m in pruned_msgs) + if (before - after) < self.proactive_prune_min_reclaim_tokens: + return messages, 0 + return pruned_msgs, pruned_count + # ------------------------------------------------------------------ # Summarization # ------------------------------------------------------------------ diff --git a/agent/context_engine.py b/agent/context_engine.py index 28d41e43161..2225f25473d 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -189,6 +189,27 @@ class ContextEngine(ABC): host filters unsupported optional arguments by signature. """ + # -- Optional: proactive tool-result prune ----------------------------- + + def prune_tool_results_only( + self, + messages: List[Dict[str, Any]], + current_tokens: int | None = None, + ) -> tuple[List[Dict[str, Any]], int]: + """Deterministically trim old tool-result payloads without an LLM call. + + Runs on a low, cost-oriented trigger independent of ``should_compress`` + so large-window engines can reclaim re-sent tool output long before full + compaction would fire. Returns ``(messages, n_pruned)``. + + Default is a safe no-op: the list is returned unchanged with ``0`` + pruned. Engines that don't implement a cheap prune — and any engine that + predates this hook — inherit this default, so the agent loop's + post-tool-call prune path never raises ``AttributeError`` on them. The + built-in ContextCompressor overrides this with the real implementation. + """ + return messages, 0 + # -- Optional: pre-flight check ---------------------------------------- def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a3deadd1048..62b675aa33b 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -5622,6 +5622,48 @@ def run_conversation( _real_tokens, int(getattr(_compressor, "threshold_tokens", 0) or 0), ) + # Proactive tool-result prune: reclaim re-sent history on + # large-window models long before should_compress() (≈50% of + # the window) would ever fire. Deterministic, no LLM call; + # protects the recent tail. No-op unless proactive_prune_tokens + # is configured and _real_tokens is above it — and even then + # the prune only commits when it reclaims at least + # proactive_prune_min_reclaim_tokens, so prompt-cache breaks + # stay episodic like compression's (the one sanctioned cache + # break) instead of firing every tool iteration. See + # ContextCompressor.prune_tool_results_only. + # getattr guard: plugin context engines predating the hook and + # minimal test doubles (SimpleNamespace compressors) lack the + # method — treat absence as a no-op. + _prune = getattr(_compressor, "prune_tool_results_only", None) + if callable(_prune): + try: + _pruned_msgs, _pruned_n = _prune( + messages, current_tokens=_real_tokens + ) + except Exception: + logger.debug( + "proactive tool-result prune failed; skipping", + exc_info=True, + ) + _pruned_msgs, _pruned_n = messages, 0 + # Standard no-op caller contract: only commit when the + # engine returned a NEW list object with a non-zero count. + if _pruned_n and _pruned_msgs is not messages: + # Do NOT rebuild conversation_history here. Unlike the + # compression branch, the prune neither rotates the session + # nor calls archive_and_compact(), so there is no new + # persistence baseline to establish. _prune_old_tool_results + # returns per-message copies that preserve the + # _DB_PERSISTED_MARKER, so the marker-based flush dedup (see + # _flush_messages_to_session_db) already prevents both + # duplicate writes and dropped rows. Calling + # conversation_history_after_compression (a compaction-only + # helper keyed on the _last_compaction_in_place flag) would be + # a no-op at best, and on a stale in-place flag could seed + # this turn's fresh, not-yet-persisted rows into history_ids + # and skip writing them. + messages = _pruned_msgs # Save session log incrementally (so progress is visible even if interrupted) agent._session_messages = messages diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fc690e910ed..640c184f0cc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1414,6 +1414,29 @@ DEFAULT_CONFIG = { # (e.g. 6) for tool-schema-heavy sessions where 3 # rounds cannot clear the request estimate. # Validated >= 1, hard-capped at 10. + "proactive_prune_tokens": 0, # opt-in trigger (tokens) for the deterministic, + # no-LLM tool-result prune, run independently of + # `threshold` above. On large-window models + # `threshold` (≈50% of the window) rarely fires, + # so old tool output otherwise rides in history + # and is re-sent every turn; a low value like + # 48000 reclaims it early. 0 = off. Recent tail + # protected by `protect_last_n`. Built-in + # compressor only (other engines inherit a no-op). + # NOTE: each committed prune rewrites already-sent + # history, breaking the provider prompt-cache + # prefix — the min_reclaim gate below keeps those + # breaks episodic rather than per-turn. + "proactive_prune_min_result_chars": 8000, # the prune's summarize pass only + # touches tool results larger than this (chars); + # clamped to >= 200 so a generated summary can't + # itself be re-summarized. + "proactive_prune_min_reclaim_tokens": 4096, # a proactive prune only commits + # when it reclaims at least this many tokens + # (measured on the pruned output). Keeps + # prompt-cache invalidation amortized: one big + # episodic break instead of a tiny break every + # tool iteration. 0 = commit any non-zero prune. "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count "hygiene_timeout_seconds": 30, # max seconds gateway waits for pre-agent hygiene compression "hygiene_failure_cooldown_seconds": 300, # skip repeated failed hygiene attempts for this session diff --git a/tests/agent/test_context_engine.py b/tests/agent/test_context_engine.py index 70eb8c71cad..c4250bcd129 100644 --- a/tests/agent/test_context_engine.py +++ b/tests/agent/test_context_engine.py @@ -181,6 +181,21 @@ class TestStubEngine: assert engine.last_prompt_tokens == 1000 assert engine.last_completion_tokens == 200 + def test_prune_tool_results_only_defaults_to_safe_noop(self): + # An engine implementing only the required interface (no prune override) + # must inherit the base no-op instead of raising AttributeError: the + # agent loop calls prune_tool_results_only() on the active engine after a + # tool call whenever full compression does not fire, so every pluggable + # ContextEngine reaches this path (see conversation_loop proactive-prune). + engine = StubEngine() + msgs = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + result, pruned = engine.prune_tool_results_only(msgs, current_tokens=10_000_000) + assert pruned == 0 + assert result is msgs + # --------------------------------------------------------------------------- # ContextCompressor session reset via ABC diff --git a/tests/agent/test_proactive_tool_result_pruning.py b/tests/agent/test_proactive_tool_result_pruning.py new file mode 100644 index 00000000000..b59ebd62747 --- /dev/null +++ b/tests/agent/test_proactive_tool_result_pruning.py @@ -0,0 +1,164 @@ +"""Tests for proactive tool-result pruning. + +``ContextCompressor.prune_tool_results_only`` runs the cheap, deterministic +Phase-1 prune (summarize old tool outputs, dedup repeats) on a cost-oriented +trigger that is INDEPENDENT of the full-compression threshold. On large-window +models ``should_compress()`` (~50% of the window) rarely fires, so without this +the old tool outputs ride in history and are re-sent verbatim every turn. + +Mirrors the construction/patching conventions in test_context_compressor.py. +""" + +from unittest.mock import patch + +from agent.context_compressor import ContextCompressor, _PRUNED_TOOL_PLACEHOLDER + +LARGE_WINDOW = 1_000_000 + + +def _compressor(**kw): + defaults = dict( + model="test", + quiet_mode=True, + threshold_percent=0.50, + protect_first_n=2, + protect_last_n=4, + ) + defaults.update(kw) + with patch( + "agent.context_compressor.get_model_context_length", + return_value=LARGE_WINDOW, + ): + return ContextCompressor(**defaults) + + +def _assistant_call(cid, name="terminal", args='{"cmd":"ls"}'): + return { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": cid, "type": "function", + "function": {"name": name, "arguments": args}} + ], + } + + +def _tool_msg(cid, content): + return {"role": "tool", "tool_call_id": cid, "content": content} + + +def _build(n_pairs, big_indices, big_chars=9000, small="ok"): + """system + n_pairs of (assistant tool_call, tool result). + + Tool results whose pair index is in ``big_indices`` get a distinct payload + of ``big_chars`` characters; the rest get a tiny payload. + """ + msgs = [{"role": "system", "content": "sys"}] + for i in range(n_pairs): + cid = f"call_{i}" + msgs.append(_assistant_call(cid)) + if i in big_indices: + msgs.append(_tool_msg(cid, chr(65 + (i % 26)) * big_chars)) + else: + msgs.append(_tool_msg(cid, small)) + return msgs + + +def _tool_by_id(msgs, cid): + return [m for m in msgs if m.get("role") == "tool" and m.get("tool_call_id") == cid][0] + + +def test_prunes_below_compression_threshold(): + """The whole point: prune fires at 120k tokens, far below the ~500k + (50% of 1M) full-compression trigger that would otherwise never run.""" + c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + assert c.should_compress(prompt_tokens=120_000) is False # compression would NOT run + msgs = _build(8, big_indices={0, 1, 2}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert pruned >= 3 + assert len(result) == len(msgs) + for cid in ("call_0", "call_1", "call_2"): + m = _tool_by_id(result, cid) + assert len(m["content"]) < 9000 # summarized + assert m["content"] != _PRUNED_TOOL_PLACEHOLDER # informative, not a blank placeholder + + +def test_disabled_by_default_is_noop(): + c = _compressor() # proactive_prune_tokens defaults to 0 + assert c.proactive_prune_tokens == 0 + msgs = _build(8, big_indices={0, 1, 2}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=500_000) + assert pruned == 0 + assert [m.get("content") for m in result] == [m.get("content") for m in msgs] + + +def test_below_trigger_is_noop(): + c = _compressor(proactive_prune_tokens=48_000) + msgs = _build(8, big_indices={0, 1, 2}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000) + assert pruned == 0 + + +def test_recent_tail_is_protected(): + c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + # pair 0 tool is old (index 2); pair 7 tool is in the last-4 protected tail (index 16) + msgs = _build(8, big_indices={0, 7}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert len(_tool_by_id(result, "call_7")["content"]) == 9000 # protected, untouched + assert len(_tool_by_id(result, "call_0")["content"]) < 9000 # old, summarized + + +def test_size_floor_spares_small_results(): + c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + msgs = _build(8, big_indices={1}, big_chars=9000) + for m in msgs: # make pair 0's tool 5000 chars (< 8000 floor), still old + if m.get("tool_call_id") == "call_0": + m["content"] = "Z" * 5000 + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert len(_tool_by_id(result, "call_0")["content"]) == 5000 # under floor -> untouched + assert len(_tool_by_id(result, "call_1")["content"]) < 9000 # over floor -> summarized + + +def test_structure_preserved(): + c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + msgs = _build(8, big_indices={0, 1, 2}) + roles_before = [m["role"] for m in msgs] + ids_before = [m.get("tool_call_id") for m in msgs] + result, _ = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert len(result) == len(msgs) + assert [m["role"] for m in result] == roles_before + assert [m.get("tool_call_id") for m in result] == ids_before + + +def test_idempotent(): + c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + msgs = _build(8, big_indices={0, 1, 2}) + first, n1 = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert n1 >= 3 + second, n2 = c.prune_tool_results_only(first, current_tokens=120_000) + assert n2 == 0 + assert [m.get("content") for m in second] == [m.get("content") for m in first] + + +def test_prune_old_tool_results_default_floor_unchanged(): + """Backward-compat: without min_prune_chars, _prune_old_tool_results still + prunes >200-char results (the compression Phase-1 caller's behavior).""" + c = _compressor() + msgs = _build(8, big_indices=set()) + for m in msgs: # a 300-char old tool result + if m.get("tool_call_id") == "call_0": + m["content"] = "Q" * 300 + result, pruned = c._prune_old_tool_results(msgs, protect_tail_count=4) + assert len(_tool_by_id(result, "call_0")["content"]) < 300 + assert pruned >= 1 + + +def test_min_result_chars_floor_is_clamped(): + """Config-robustness: a floor below 200 (or negative) is clamped up to 200, + while a configured 0 falls back to the 8000 default via ``or``. Without the + clamp, a tiny floor lets Pass 2 re-summarize its own (short) summary every + turn, and a negative floor strips every non-tail tool result.""" + assert _compressor(proactive_prune_min_result_chars=0).proactive_prune_min_result_chars == 8000 + assert _compressor(proactive_prune_min_result_chars=50).proactive_prune_min_result_chars == 200 + assert _compressor(proactive_prune_min_result_chars=-1).proactive_prune_min_result_chars == 200 + assert _compressor(proactive_prune_min_result_chars=8000).proactive_prune_min_result_chars == 8000 diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 7956d07fc35..6bac8bcda42 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -752,6 +752,9 @@ compression: hygiene_hard_message_limit: 5000 # Gateway safety valve — see below hygiene_timeout_seconds: 30 # Max seconds gateway waits for pre-agent hygiene compression hygiene_failure_cooldown_seconds: 300 # Skip repeated failed hygiene attempts for this session + proactive_prune_tokens: 0 # Opt-in tokens trigger for the no-LLM tool-result prune (0 = off; see below) + proactive_prune_min_result_chars: 8000 # Prune's summarize pass only touches tool results larger than this (clamped >= 200) + proactive_prune_min_reclaim_tokens: 4096 # Prune only commits when it reclaims at least this many tokens (0 = commit any) # The summarization model/provider is configured under auxiliary: auxiliary: @@ -777,6 +780,8 @@ Older configs with `compression.summary_model`, `compression.summary_provider`, `idle_compact_after_seconds` is an **opt-in, time-based** trigger that complements the size-based `threshold`. Default `0` (disabled). When set above 0, a session that resumes after at least that many seconds of inactivity compacts its accumulated history up front, before the first reply — so a long-lived thread (e.g. a Telegram conversation you come back to hours later) doesn't re-read its full stale context on every subsequent turn. It never fires when the context is already at or below the post-compression target (`threshold × target_ratio`), and it honors the same failure-cooldown, anti-thrash, and per-session lock guards as every automatic compaction. Example: `idle_compact_after_seconds: 1800` compacts after 30 minutes idle. +`proactive_prune_tokens` enables a deterministic, no-LLM prune of old tool-result payloads that runs independently of `threshold`. On large-window models the `threshold` compaction (≈50% of the window) rarely fires, so bulky tool outputs (terminal dumps, file reads, web extracts) ride along in history and get re-sent on every subsequent turn. When re-sent history exceeds `proactive_prune_tokens` (default `0` = off; try `48000` to enable), the prune dedupes identical results, summarizes older oversized ones, and truncates large tool-call arguments — protecting the most recent `protect_last_n` messages and never calling the model. Full outputs stay recoverable from the session store. `proactive_prune_min_result_chars` (default `8000`, clamped to ≥ 200) sets the size below which a tool result is left untouched. `proactive_prune_min_reclaim_tokens` (default `4096`) prevents a prune from committing unless it reclaims at least that many tokens — a committed prune rewrites already-sent history and invalidates the provider's prompt-cache prefix, so this gate keeps those cache breaks episodic and amortized (one meaningful break, like a compression boundary) instead of firing on every tool iteration. This runs only under the built-in `compressor` engine; other context engines inherit a no-op. + :::tip Gateway hot-reload of compression and context length As of recent releases, editing `model.context_length` or any `compression.*` key in `config.yaml` on a running gateway takes effect on the next message — no gateway restart, no `/reset`, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths. ::: From fa4800414cf7d6d28a535315a67858bfd6e30db3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:16:57 -0700 Subject: [PATCH 020/552] feat(compression): prompt-cache reclaim gate + hardened wiring for proactive prune Follow-ups on top of the cherry-picked #62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the #62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising). --- cli-config.yaml.example | 28 +++ gateway/run.py | 3 + tests/agent/test_proactive_prune_config.py | 111 +++++++++ .../test_proactive_tool_result_pruning.py | 117 +++++++++- .../test_proactive_prune_loop_wiring.py | 214 ++++++++++++++++++ 5 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_proactive_prune_config.py create mode 100644 tests/run_agent/test_proactive_prune_loop_wiring.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index c3990962f06..847bc98693f 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -484,6 +484,34 @@ compression: # summarization on a short idle thread. Example: 1800 = compact after 30 min idle. idle_compact_after_seconds: 0 + # Proactive tool-result prune (default: 0 = disabled). Opt-in token trigger + # for a deterministic, no-LLM prune of OLD tool-result payloads, run + # independently of `threshold` above. On large-window models (512K/1M) the + # ratio threshold rarely fires, so bulky tool outputs (terminal dumps, file + # reads, web extracts) ride along in history and get re-billed every turn. + # When re-sent history exceeds this many tokens, the prune dedupes identical + # results, summarizes older oversized ones, and truncates large tool-call + # arguments — protecting the most recent `protect_last_n` messages and never + # calling the model. Try 48000 to enable. Built-in compressor engine only; + # other context engines inherit a safe no-op. + # NOTE: a committed prune rewrites already-sent history, which invalidates + # the provider's prompt-cache prefix — the min_reclaim gate below keeps + # those cache breaks episodic (like a compression boundary) instead of + # per-turn. + proactive_prune_tokens: 0 + + # The prune's summarize pass only touches tool results larger than this many + # characters (clamped to >= 200 so a generated summary can't be + # re-summarized). Default 8000. + proactive_prune_min_result_chars: 8000 + + # A proactive prune only COMMITS when it reclaims at least this many tokens + # (measured on the pruned output). This is the prompt-cache hysteresis gate: + # one meaningful, amortized cache break per batch of stale tool output + # instead of a tiny break on every tool iteration. 0 = commit any non-zero + # prune. Default 4096. + proactive_prune_min_reclaim_tokens: 4096 + # To pin a specific model/provider for compression summaries, use the # auxiliary section below (auxiliary.compression.provider / model). diff --git a/gateway/run.py b/gateway/run.py index f085c3710fe..98f6b86da78 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18122,6 +18122,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ("compression", "codex_app_server_auto"), ("compression", "target_ratio"), ("compression", "protect_last_n"), + ("compression", "proactive_prune_tokens"), + ("compression", "proactive_prune_min_result_chars"), + ("compression", "proactive_prune_min_reclaim_tokens"), ("agent", "disabled_toolsets"), ("memory", "provider"), ("checkpoints", "enabled"), diff --git a/tests/agent/test_proactive_prune_config.py b/tests/agent/test_proactive_prune_config.py new file mode 100644 index 00000000000..104dfb020c2 --- /dev/null +++ b/tests/agent/test_proactive_prune_config.py @@ -0,0 +1,111 @@ +"""compression.proactive_prune_* — config parse seam for the proactive prune. + +Mirrors ``test_compression_max_attempts_config.py``: the three knobs are +parsed in ``agent_init`` with the same hardened semantics (booleans rejected, +fractional floats rejected — not truncated, integral floats and numeric +strings accepted) and attached to the built-in compressor. Default is +0 / 8000 / 4096, i.e. the feature is OFF and behavior-neutral unless +``proactive_prune_tokens`` is set above 0. +""" + +from __future__ import annotations + +import contextlib +import io +from pathlib import Path + +from hermes_state import SessionDB +from run_agent import AIAgent + + +def _config(**prune_keys) -> dict: + compression = { + "enabled": True, + "threshold": 0.50, + "target_ratio": 0.20, + "protect_first_n": 3, + "protect_last_n": 20, + } + compression.update(prune_keys) + return { + "compression": compression, + "prompt_caching": {"cache_ttl": "5m"}, + "sessions": {}, + "bedrock": {}, + } + + +def _make_agent(monkeypatch, tmp_path: Path, **prune_keys): + from hermes_cli import config as config_mod + + monkeypatch.setattr(config_mod, "load_config", lambda: _config(**prune_keys)) + db = SessionDB(db_path=tmp_path / "state.db") + with contextlib.redirect_stdout(io.StringIO()): + agent = AIAgent( + base_url="https://chatgpt.com/backend-api/codex", + api_key="test-key", + provider="openai-codex", + model="gpt-5.5", + enabled_toolsets=[], + disabled_toolsets=[], + quiet_mode=True, + skip_memory=True, + session_db=db, + session_id="proactive-prune-config-test", + ) + return agent + + +class TestProactivePruneConfig: + def test_default_is_disabled_when_unset(self, monkeypatch, tmp_path): + agent = _make_agent(monkeypatch, tmp_path) + cc = agent.context_compressor + assert cc.proactive_prune_tokens == 0 + assert cc.proactive_prune_min_result_chars == 8000 + assert cc.proactive_prune_min_reclaim_tokens == 4096 + + def test_custom_values_are_honored(self, monkeypatch, tmp_path): + agent = _make_agent( + monkeypatch, + tmp_path, + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=12_000, + proactive_prune_min_reclaim_tokens=8_192, + ) + cc = agent.context_compressor + assert cc.proactive_prune_tokens == 48_000 + assert cc.proactive_prune_min_result_chars == 12_000 + assert cc.proactive_prune_min_reclaim_tokens == 8_192 + + def test_boolean_is_rejected_not_coerced(self, monkeypatch, tmp_path): + # bool subclasses int: YAML `proactive_prune_tokens: true` must fall + # back to disabled, never coerce to 1 token. + agent = _make_agent(monkeypatch, tmp_path, proactive_prune_tokens=True) + assert agent.context_compressor.proactive_prune_tokens == 0 + + def test_fractional_float_is_rejected_not_truncated(self, monkeypatch, tmp_path): + agent = _make_agent(monkeypatch, tmp_path, proactive_prune_tokens=48_000.7) + assert agent.context_compressor.proactive_prune_tokens == 0 + + def test_integral_float_and_numeric_string_accepted(self, monkeypatch, tmp_path): + agent = _make_agent(monkeypatch, tmp_path, proactive_prune_tokens=48_000.0) + assert agent.context_compressor.proactive_prune_tokens == 48_000 + agent = _make_agent(monkeypatch, tmp_path, proactive_prune_tokens="32000") + assert agent.context_compressor.proactive_prune_tokens == 32_000 + + def test_negative_trigger_treated_as_disabled(self, monkeypatch, tmp_path): + agent = _make_agent(monkeypatch, tmp_path, proactive_prune_tokens=-100) + assert agent.context_compressor.proactive_prune_tokens == 0 + + def test_garbage_falls_back_to_defaults(self, monkeypatch, tmp_path): + agent = _make_agent( + monkeypatch, + tmp_path, + proactive_prune_tokens="lots", + proactive_prune_min_result_chars=None, + proactive_prune_min_reclaim_tokens="???", + ) + cc = agent.context_compressor + assert cc.proactive_prune_tokens == 0 + assert cc.proactive_prune_min_result_chars == 8000 + assert cc.proactive_prune_min_reclaim_tokens == 4096 diff --git a/tests/agent/test_proactive_tool_result_pruning.py b/tests/agent/test_proactive_tool_result_pruning.py index b59ebd62747..f56caf5af31 100644 --- a/tests/agent/test_proactive_tool_result_pruning.py +++ b/tests/agent/test_proactive_tool_result_pruning.py @@ -100,7 +100,11 @@ def test_below_trigger_is_noop(): def test_recent_tail_is_protected(): - c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + c = _compressor( + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=8_000, + proactive_prune_min_reclaim_tokens=0, # gate off: this test pins tail semantics + ) # pair 0 tool is old (index 2); pair 7 tool is in the last-4 protected tail (index 16) msgs = _build(8, big_indices={0, 7}) result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) @@ -109,7 +113,11 @@ def test_recent_tail_is_protected(): def test_size_floor_spares_small_results(): - c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000) + c = _compressor( + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=8_000, + proactive_prune_min_reclaim_tokens=0, # gate off: this test pins the size floor + ) msgs = _build(8, big_indices={1}, big_chars=9000) for m in msgs: # make pair 0's tool 5000 chars (< 8000 floor), still old if m.get("tool_call_id") == "call_0": @@ -162,3 +170,108 @@ def test_min_result_chars_floor_is_clamped(): assert _compressor(proactive_prune_min_result_chars=50).proactive_prune_min_result_chars == 200 assert _compressor(proactive_prune_min_result_chars=-1).proactive_prune_min_result_chars == 200 assert _compressor(proactive_prune_min_result_chars=8000).proactive_prune_min_result_chars == 8000 + + +# --------------------------------------------------------------------------- +# Salvage follow-ups: no-op caller contract, prompt-cache hysteresis gate, +# no-orphan pairing invariant, and the default-off behavior pin. +# --------------------------------------------------------------------------- + + +def test_noop_paths_return_input_object(): + """Standard caller contract: every no-op path hands back the INPUT list + object so callers can gate bookkeeping on ``result is not input``.""" + msgs = _build(8, big_indices={0, 1, 2}) + # Disabled (default) + c = _compressor() + result, pruned = c.prune_tool_results_only(msgs, current_tokens=500_000) + assert pruned == 0 and result is msgs + # Below trigger + c = _compressor(proactive_prune_tokens=48_000) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000) + assert pruned == 0 and result is msgs + # Above trigger but nothing prunable (all results tiny) + c = _compressor(proactive_prune_tokens=48_000) + tiny = _build(8, big_indices=set()) + result, pruned = c.prune_tool_results_only(tiny, current_tokens=120_000) + assert pruned == 0 and result is tiny + + +def test_min_reclaim_gate_blocks_small_prunes(): + """Prompt-cache hysteresis: a prune that would reclaim less than + ``proactive_prune_min_reclaim_tokens`` must NOT commit (returns the input + object) — rewriting already-sent history for a trivial saving would break + the provider's cached prefix every tool iteration.""" + c = _compressor( + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=8_000, + proactive_prune_min_reclaim_tokens=1_000_000, # unreachably high + ) + msgs = _build(8, big_indices={0, 1, 2}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert pruned == 0 + assert result is msgs # input object — caller commits nothing + + +def test_min_reclaim_gate_allows_large_prunes(): + """A prune reclaiming more than the gate commits normally.""" + c = _compressor( + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=8_000, + proactive_prune_min_reclaim_tokens=1_000, # 3×9000 chars ≈ 6.7K tokens reclaimed + ) + msgs = _build(8, big_indices={0, 1, 2}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert pruned >= 3 + assert result is not msgs + + +def test_min_reclaim_gate_default_and_clamp(): + """Default 4096; negative/None coerce to disabled (0).""" + assert _compressor().proactive_prune_min_reclaim_tokens == 4096 + assert _compressor(proactive_prune_min_reclaim_tokens=0).proactive_prune_min_reclaim_tokens == 0 + assert _compressor(proactive_prune_min_reclaim_tokens=-5).proactive_prune_min_reclaim_tokens == 0 + assert _compressor(proactive_prune_min_reclaim_tokens=None).proactive_prune_min_reclaim_tokens == 0 + + +def test_no_orphans_both_directions(): + """tool_call_id pairing survives the prune in BOTH directions: every + surviving tool result has its assistant call, and every assistant tool_call + has its result row (the #69830 test-pin rule — never assert exact surviving + pair counts, only the pairing invariant).""" + c = _compressor( + proactive_prune_tokens=48_000, + proactive_prune_min_result_chars=8_000, + proactive_prune_min_reclaim_tokens=0, + ) + msgs = _build(10, big_indices={0, 1, 2, 3, 4}) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000) + assert pruned >= 1 + call_ids = set() + for m in result: + if m.get("role") == "assistant": + for tc in m.get("tool_calls") or []: + call_ids.add(tc["id"] if isinstance(tc, dict) else tc.id) + result_ids = {m["tool_call_id"] for m in result if m.get("role") == "tool"} + assert result_ids <= call_ids, "orphan tool results without a matching call" + assert call_ids <= result_ids, "orphan tool calls without a matching result" + + +def test_unset_config_zero_behavior_change(): + """Pin: with the config knobs unset, the compressor behaves byte-identically + to pre-feature main — the prune path is dead code and the full-compression + Phase-1 caller keeps its 200-char floor.""" + c = _compressor() # nothing configured + assert c.proactive_prune_tokens == 0 + msgs = _build(8, big_indices={0, 1, 2}) + import copy + snapshot = copy.deepcopy(msgs) + result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000_000) + assert pruned == 0 + assert result is msgs + assert msgs == snapshot # input never mutated + # And the compression-path caller still prunes at the 200-char default floor + # (min_prune_chars default unchanged). + import inspect + sig = inspect.signature(c._prune_old_tool_results) + assert sig.parameters["min_prune_chars"].default == 200 diff --git a/tests/run_agent/test_proactive_prune_loop_wiring.py b/tests/run_agent/test_proactive_prune_loop_wiring.py new file mode 100644 index 00000000000..60b28b94483 --- /dev/null +++ b/tests/run_agent/test_proactive_prune_loop_wiring.py @@ -0,0 +1,214 @@ +"""Behavioral tests for the post-tool proactive tool-result prune wiring. + +The conversation loop's post-tool gate now has a prune arm inside the +``elif agent.compression_enabled`` branch: when full compression does NOT +fire (the usual case on a large-window model), the deterministic no-LLM +prune gets one shot per tool iteration, committing only when the engine +returns a NEW list object with a non-zero prune count. + +These tests drive ``run_conversation()`` through real tool iterations and pin: +- the prune is consulted when compression stands down; +- a committed prune replaces ``messages`` for subsequent iterations; +- a no-op (input object returned) commits nothing; +- a compressor WITHOUT the method (plugin engine predating the hook / + SimpleNamespace test double) does not raise — getattr-guarded; +- a raising prune is swallowed (debug log), never fails the turn. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _tool_call(i: int): + return SimpleNamespace( + id=f"call_{i}", + type="function", + function=SimpleNamespace(name="web_search", arguments='{"query": "x"}'), + ) + + +def _tool_response(i: int): + msg = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning=None, + tool_calls=[_tool_call(i)], + ) + choice = SimpleNamespace(message=msg, finish_reason="tool_calls") + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +def _stop_response(): + msg = SimpleNamespace( + content="done", + reasoning_content=None, + reasoning=None, + tool_calls=None, + ) + choice = SimpleNamespace(message=msg, finish_reason="stop") + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +def _make_tool_defs(*names: str) -> list: + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +def _quiet_compressor() -> MagicMock: + """A compressor that never demands full compression. + + ``should_compress`` False routes the post-tool gate into the ``elif`` + branch where the proactive prune arm lives. ``should_compress_info`` + reports unblocked (no block reason) so the overflow warning stays quiet. + """ + compressor = MagicMock() + compressor.protect_first_n = 3 + compressor.protect_last_n = 20 + compressor.threshold_tokens = 500_000 + compressor.context_length = 1_000_000 + compressor.last_prompt_tokens = 120_000 + compressor.should_compress.return_value = False + compressor.should_compress_info.return_value = (False, None) + compressor.should_defer_preflight_to_real_usage.return_value = True + compressor.get_active_compression_failure_cooldown.return_value = None + return compressor + + +@pytest.fixture() +def agent(): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + max_iterations=10, + ) + a.client = MagicMock() + a._cached_system_prompt = "You are helpful." + a._use_prompt_caching = False + a._disable_streaming = True + a.tool_delay = 0 + a.save_trajectories = False + a.compression_enabled = True + a.context_compressor = _quiet_compressor() + return a + + +def _run_tool_loop(agent, n_tool_iterations: int): + responses = [_tool_response(i) for i in range(n_tool_iterations)] + responses.append(_stop_response()) + agent.client.chat.completions.create.side_effect = responses + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch( + "run_agent.handle_function_call", + lambda name, args, task_id=None, **kwargs: json.dumps({"ok": True}), + ), + ): + result = agent.run_conversation("do a lot of tool work") + + return result + + +class TestProactivePruneLoopWiring: + def test_prune_consulted_when_compression_stands_down(self, agent): + calls = [] + + def _prune(messages, current_tokens=None): + calls.append(current_tokens) + return messages, 0 # no-op contract: input object back + + agent.context_compressor.prune_tool_results_only = _prune + result = _run_tool_loop(agent, n_tool_iterations=3) + assert result["completed"] is True + assert len(calls) == 3 # one shot per tool iteration + assert all(t == 120_000 for t in calls) # fed the real usage reading + + def test_committed_prune_replaces_messages(self, agent): + marker = "[old tool output pruned]" + + def _prune(messages, current_tokens=None): + pruned = [dict(m) for m in messages] + changed = 0 + for m in pruned: + if m.get("role") == "tool" and m.get("content") != marker: + m["content"] = marker + changed += 1 + if not changed: + return messages, 0 + return pruned, changed + + agent.context_compressor.prune_tool_results_only = _prune + result = _run_tool_loop(agent, n_tool_iterations=2) + assert result["completed"] is True + tool_rows = [m for m in result["messages"] if m.get("role") == "tool"] + assert tool_rows, "expected tool rows in the final transcript" + assert all(m["content"] == marker for m in tool_rows) + + def test_noop_input_object_commits_nothing(self, agent): + """Engine returns the INPUT object with a (bogus) non-zero count — + the caller's ``result is not input`` gate must refuse the commit.""" + def _prune(messages, current_tokens=None): + return messages, 5 # lies about count but returns input object + + agent.context_compressor.prune_tool_results_only = _prune + result = _run_tool_loop(agent, n_tool_iterations=2) + assert result["completed"] is True + tool_rows = [m for m in result["messages"] if m.get("role") == "tool"] + # tool output may be wrapped in an untrusted_tool_result envelope — + # assert the original payload survived un-pruned. + assert all('"ok": true' in m["content"] for m in tool_rows) + + def test_engine_without_method_does_not_raise(self, agent): + """Plugin engines predating the hook / minimal doubles lack the + method entirely — the getattr guard treats absence as a no-op.""" + compressor = SimpleNamespace( + protect_first_n=3, + protect_last_n=20, + threshold_tokens=500_000, + context_length=1_000_000, + last_prompt_tokens=120_000, + should_compress=lambda _t: False, + should_defer_preflight_to_real_usage=lambda _t: True, + get_active_compression_failure_cooldown=lambda: None, + ) + agent.context_compressor = compressor + result = _run_tool_loop(agent, n_tool_iterations=2) + assert result["completed"] is True + + def test_raising_prune_is_swallowed(self, agent): + def _prune(messages, current_tokens=None): + raise RuntimeError("boom") + + agent.context_compressor.prune_tool_results_only = _prune + result = _run_tool_loop(agent, n_tool_iterations=2) + assert result["completed"] is True + tool_rows = [m for m in result["messages"] if m.get("role") == "tool"] + # tool output may be wrapped in an untrusted_tool_result envelope — + # assert the original payload survived un-pruned. + assert all('"ok": true' in m["content"] for m in tool_rows) From 80ece3867b8b53324c18e5ab8918f377df64f661 Mon Sep 17 00:00:00 2001 From: Cluster2 Date: Mon, 18 May 2026 00:26:58 -0400 Subject: [PATCH 021/552] fix: bound compression summary input --- agent/context_compressor.py | 38 +++++++++++++++++++++++++- tests/agent/test_context_compressor.py | 27 ++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2bb98aee77e..00ad0a40318 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2440,6 +2440,7 @@ class ContextCompressor(ContextEngine): _CONTENT_TAIL = 1500 # chars kept from the end _TOOL_ARGS_MAX = 1500 # tool call argument chars _TOOL_ARGS_HEAD = 1200 # kept from the start of tool args + _SUMMARY_INPUT_MAX_CHARS = 160_000 # total serialized turns sent to aux summarizer def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str: """Serialize conversation turns into labeled text for the summarizer. @@ -2735,6 +2736,39 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]" return summary + @classmethod + def _bound_summary_input(cls, content: str) -> str: + """Cap total summarizer input while preserving beginning and recent tail. + + Per-message truncation alone is not enough for very long sessions: a + compression window with hundreds of messages can still produce a huge + single prompt that slow auxiliary backends time out on. Keep both edges + because the beginning often has task setup and the tail has the most + recent state; explicitly mark the omitted middle so the summarizer knows + context was intentionally compressed before it saw the prompt. + """ + if len(content) <= cls._SUMMARY_INPUT_MAX_CHARS: + return content + + marker_template = ( + "\n\n...[summary input truncated: omitted " + "{omitted:,} chars from the middle to keep compression prompt bounded]...\n\n" + ) + # Estimate once, then rebuild with the exact omitted span after the + # head/tail split is known. The second marker can differ by a few chars + # if the comma-formatted number changes width, so recompute once. + marker = marker_template.format(omitted=len(content)) + remaining = max(cls._SUMMARY_INPUT_MAX_CHARS - len(marker), 0) + head_chars = int(remaining * 0.45) + tail_chars = remaining - head_chars + omitted = max(len(content) - head_chars - tail_chars, 0) + marker = marker_template.format(omitted=omitted) + remaining = max(cls._SUMMARY_INPUT_MAX_CHARS - len(marker), 0) + head_chars = int(remaining * 0.45) + tail_chars = remaining - head_chars + tail = content[-tail_chars:].lstrip() if tail_chars else "" + return content[:head_chars].rstrip() + marker + tail + def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: """Switch from a separate ``summary_model`` back to the main model. @@ -2807,7 +2841,9 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb self._previous_summary = _redact_compaction_text(self._previous_summary) summary_budget = self._compute_summary_budget(turns_to_summarize) - content_to_summarize = self._serialize_for_summary(turns_to_summarize) + content_to_summarize = self._bound_summary_input( + self._serialize_for_summary(turns_to_summarize) + ) _sanitized_memory_context = sanitize_memory_context(memory_context) _serialized_memory_context = json.dumps( _sanitized_memory_context, diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index dfe7e699379..5f8b21f8051 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -3973,3 +3973,30 @@ class TestDoubleCompactionSummaryRole: "summary of earlier turns" in (m.get("content") or "") for m in result ) + + +class TestSummaryPromptBounding: + def test_oversized_summary_prompt_is_bounded_and_preserves_edges(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "bounded summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=272000): + c = ContextCompressor(model="test", quiet_mode=True) + + messages = [ + {"role": "user", "content": f"turn-{i}-" + ("x" * 6000)} + for i in range(80) + ] + messages[0]["content"] = "FIRST_SENTINEL " + messages[0]["content"] + messages[-1]["content"] = "LAST_SENTINEL " + messages[-1]["content"] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + summary = c._generate_summary(messages) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert summary.startswith(SUMMARY_PREFIX) + assert len(prompt) < 180_000 + assert "summary input truncated" in prompt + assert "FIRST_SENTINEL" in prompt + assert "LAST_SENTINEL" in prompt From b7a05b6b6f509d14f708a2fe7b7c1d3559396ef6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:54:22 -0700 Subject: [PATCH 022/552] fix: re-anchor summary-input bound to current main + bound iterative path Follow-ups on top of the cherry-picked #27748 mechanism: - move the cap constant to module level with full rationale comment (class attribute aliases it so subclasses/tests can override) - bound the iterative-update path too: the PREVIOUS SUMMARY block is passed through _bound_summary_input so a pathological rehydrated handoff cannot blow up the prompt (previous summary + new turns each capped) - extra regression tests: byte-identical small-input passthrough (identity), direct bound+marker unit check, bound-after-per-message- truncation shape (hundreds of under-_CONTENT_MAX turns), iterative path bounded, marker vs classify_summary_content non-collision - contributor email mapping for @robgfl45 --- agent/context_compressor.py | 32 +++++++- .../cluster2@Cluster2s-Mac-Studio.local | 1 + tests/agent/test_context_compressor.py | 76 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 contributors/emails/cluster2@Cluster2s-Mac-Studio.local diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 00ad0a40318..2baa2731c62 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -303,6 +303,20 @@ _SUMMARY_RATIO = 0.20 # itself a context-pressure source and slows every compaction. _SUMMARY_TOKENS_CEILING = 10_000 +# Aggregate cap on the serialized turn block fed to the summarizer prompt +# (chars). Per-message truncation (_CONTENT_MAX / _TOOL_ARGS_MAX) alone is +# not enough: a compression window with hundreds of already-truncated turns +# can still produce a multi-hundred-KB prompt that blows past slow auxiliary +# backends' context limits or timeouts (Codex Responses fallback paths +# especially). 160K chars ≈ 40K tokens — comfortably inside every supported +# aux model's window while leaving room for the template + previous summary. +# Applied AFTER per-message truncation, with head+tail retention and an +# explicit omitted-middle marker (see _bound_summary_input). This is a +# prompt-side bound only — NEVER add a max_tokens wire cap on the summary +# call (see the no-wire-cap contract test in +# test_compression_small_ctx_threshold_floor.py). +_SUMMARY_INPUT_MAX_CHARS = 160_000 + # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -2440,7 +2454,10 @@ class ContextCompressor(ContextEngine): _CONTENT_TAIL = 1500 # chars kept from the end _TOOL_ARGS_MAX = 1500 # tool call argument chars _TOOL_ARGS_HEAD = 1200 # kept from the start of tool args - _SUMMARY_INPUT_MAX_CHARS = 160_000 # total serialized turns sent to aux summarizer + # Aggregate cap over the whole serialized block, applied AFTER the + # per-message limits above. Alias of the module-level constant (which + # carries the full rationale) so subclasses/tests can override per-class. + _SUMMARY_INPUT_MAX_CHARS = _SUMMARY_INPUT_MAX_CHARS def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str: """Serialize conversation turns into labeled text for the summarizer. @@ -3040,13 +3057,22 @@ Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command out Write only the summary body. Do not include any preamble or prefix.""" if self._previous_summary: - # Iterative update: preserve existing info, add new progress + # Iterative update: preserve existing info, add new progress. + # Bound the previous-summary block with the same aggregate cap as + # the serialized new turns: a normal summary is far below the cap + # (the output side is held to a ~10K-token ceiling), but a + # pathological handoff rehydrated from a persisted session can be + # arbitrarily large — the iterative prompt (previous summary + + # new turns) must stay bounded too. + _bounded_previous_summary = self._bound_summary_input( + self._previous_summary + ) prompt = f"""{_summarizer_preamble} You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated. PREVIOUS SUMMARY: -{self._previous_summary} +{_bounded_previous_summary} NEW TURNS TO INCORPORATE: {content_to_summarize}{_memory_section} diff --git a/contributors/emails/cluster2@Cluster2s-Mac-Studio.local b/contributors/emails/cluster2@Cluster2s-Mac-Studio.local new file mode 100644 index 00000000000..0a829ee7efa --- /dev/null +++ b/contributors/emails/cluster2@Cluster2s-Mac-Studio.local @@ -0,0 +1 @@ +robgfl45 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 5f8b21f8051..926678108fe 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -4000,3 +4000,79 @@ class TestSummaryPromptBounding: assert "summary input truncated" in prompt assert "FIRST_SENTINEL" in prompt assert "LAST_SENTINEL" in prompt + + def test_small_input_returned_byte_identical(self): + """Inputs at or under the cap must pass through completely untouched.""" + small = "hello world\n\n[USER]: do the thing" + assert ContextCompressor._bound_summary_input(small) is small + exactly_at_cap = "a" * ContextCompressor._SUMMARY_INPUT_MAX_CHARS + assert ContextCompressor._bound_summary_input(exactly_at_cap) is exactly_at_cap + + def test_bound_respected_on_oversized_input_with_marker(self): + """Direct unit check: output length ≤ cap, marker present, edges kept.""" + cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS + content = "HEAD_EDGE " + ("m" * (cap * 3)) + " TAIL_EDGE" + bounded = ContextCompressor._bound_summary_input(content) + assert len(bounded) <= cap + assert "summary input truncated" in bounded + assert bounded.startswith("HEAD_EDGE") + assert bounded.endswith("TAIL_EDGE") + + def test_bound_applies_after_per_message_truncation(self): + """The aggregate cap catches what per-message truncation alone misses: + hundreds of turns, each individually under _CONTENT_MAX, still sum to + an unbounded serialized block without _bound_summary_input.""" + with patch("agent.context_compressor.get_model_context_length", return_value=272000): + c = ContextCompressor(model="test", quiet_mode=True) + # Each message body is < _CONTENT_MAX so per-message truncation is a + # no-op — only the aggregate bound can cap the total. + messages = [ + {"role": "user", "content": "y" * (c._CONTENT_MAX - 100)} + for _ in range(60) + ] + serialized = c._serialize_for_summary(messages) + assert len(serialized) > c._SUMMARY_INPUT_MAX_CHARS # unbounded without the cap + bounded = c._bound_summary_input(serialized) + assert len(bounded) <= c._SUMMARY_INPUT_MAX_CHARS + assert "summary input truncated" in bounded + + def test_iterative_update_path_is_bounded(self): + """The iterative prompt (previous summary + new turns) must be bounded + too — a pathological rehydrated handoff must not blow up the prompt.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "updated summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=272000): + c = ContextCompressor(model="test", quiet_mode=True) + cap = c._SUMMARY_INPUT_MAX_CHARS + c._previous_summary = "PREV_HEAD " + ("p" * (cap * 2)) + " PREV_TAIL" + + messages = [ + {"role": "user", "content": f"turn-{i}-" + ("x" * 6000)} + for i in range(80) + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + summary = c._generate_summary(messages) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert summary.startswith(SUMMARY_PREFIX) + # previous summary block + new-turns block each capped, plus the + # fixed template: well under 3x the cap (unbounded would be ~800K). + assert len(prompt) < 2 * cap + 30_000 + assert "PREV_HEAD" in prompt + assert "PREV_TAIL" in prompt + assert "summary input truncated" in prompt + + def test_marker_does_not_collide_with_summary_classifier(self): + """The omitted-middle marker must never make bounded content classify + as a compaction handoff (SUMMARY_PREFIX / merged-handoff patterns).""" + cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS + bounded = ContextCompressor._bound_summary_input("z" * (cap * 2)) + assert "summary input truncated" in bounded + assert ContextCompressor.classify_summary_content(bounded) is None + # Marker alone (worst case: lands at the start of a message) is not a + # handoff prefix either. + marker_only = bounded[bounded.index("\n\n...[summary input truncated"):] + assert ContextCompressor.classify_summary_content(marker_only.lstrip()) is None From 8d72845399a84f4b1660142c3d5047d49d3baec6 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Mon, 20 Jul 2026 17:35:55 +0530 Subject: [PATCH 023/552] fix(cli): resolve moa: model in non-interactive mode hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not supported' (HTTP 401/400): the raw model string was passed straight to the real provider. The MoA virtual provider only got wired up through the interactive /moa command and the model picker, never through the -Q one-shot startup path. resolve_runtime_provider already handles requested_provider == 'moa', and agent_init builds the MoAClient off provider == 'moa' (surface-agnostic). The only gap was mapping the moa: model string to that provider. Add _normalize_moa_model() and apply it in HermesCLI.__init__ before provider resolution: a moa: model sets requested_provider='moa' and model=, so the existing MoA path runs in non-interactive mode too. The moa: prefix wins over an explicit --provider (previously --provider deepseek -m moa:strategy silently dropped MoA). Fixes #56828 --- cli.py | 31 +++++++++++++++++++++- tests/cli/test_moa_command.py | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 1502521a213..7d86deb1d7f 100644 --- a/cli.py +++ b/cli.py @@ -3854,6 +3854,28 @@ def save_config_value(key_path: str, value: any) -> bool: # HermesCLI Class # ============================================================================ + +def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Map a ``moa:`` model string to ``(provider, preset)``. + + Returns ``("moa", "")`` when *model* selects the MoA virtual + provider, otherwise ``(None, model)`` unchanged. This gives non-interactive + ``hermes chat -Q -m moa:`` the same routing the interactive + ``/moa`` command and the model picker already use: ``resolve_runtime_provider`` + handles ``requested_provider == "moa"`` and ``agent_init`` builds the + MoAClient off ``provider == "moa"``. Without this the raw ``moa:`` + string is sent to the real provider and rejected with a 401/400 "model not + supported" (#56828). + """ + if isinstance(model, str): + stripped = model.strip() + if stripped.lower().startswith("moa:"): + preset = stripped.split(":", 1)[1].strip() + if preset: + return "moa", preset + return None, model + + class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ Interactive CLI for the Hermes Agent. @@ -3984,6 +4006,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") _DEFAULT_CONFIG_MODEL = "" self.model = model or _config_model or _DEFAULT_CONFIG_MODEL + # A ``moa:`` model string selects the MoA virtual provider in + # one shot (parity with interactive ``/moa`` and the model picker). Do + # this before provider resolution so ``-Q -m moa:`` routes + # through MoA instead of hitting the real provider with an unknown + # model (#56828). A ``moa:`` prefix wins over an explicit ``--provider``. + _moa_provider_override, self.model = _normalize_moa_model(self.model) # Read max_tokens from config (env var override: HERMES_MAX_TOKENS) _env_mt = os.environ.get("HERMES_MAX_TOKENS") if _env_mt: @@ -4019,7 +4047,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Provider selection is resolved lazily at use-time via _ensure_runtime_credentials(). self.requested_provider = ( - provider + _moa_provider_override + or provider or CLI_CONFIG["model"].get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" diff --git a/tests/cli/test_moa_command.py b/tests/cli/test_moa_command.py index c526a0f37af..7a60dd226bc 100644 --- a/tests/cli/test_moa_command.py +++ b/tests/cli/test_moa_command.py @@ -80,3 +80,52 @@ def test_decode_legacy_encoded_moa_turn_still_works(): prompt, cfg = decode_moa_turn(encoded) assert prompt == "hello" assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}] + + +class TestNormalizeMoaModel: + """#56828: `-Q -m moa:` must route through the MoA virtual provider. + + ``_normalize_moa_model`` maps the model string to (provider, preset); the + __init__ wiring then forces ``requested_provider="moa"`` so the existing + resolve_runtime_provider / agent_init MoA path runs in non-interactive mode. + """ + + def test_moa_prefix_maps_to_provider_and_preset(self): + from cli import _normalize_moa_model + assert _normalize_moa_model("moa:strategy") == ("moa", "strategy") + + def test_moa_prefix_is_case_insensitive_and_trims(self): + from cli import _normalize_moa_model + assert _normalize_moa_model(" MOA:code-review ") == ("moa", "code-review") + + def test_bare_moa_without_preset_is_not_treated_as_virtual(self): + from cli import _normalize_moa_model + # No preset after the colon → leave untouched (no provider override). + assert _normalize_moa_model("moa:") == (None, "moa:") + + def test_non_moa_model_unchanged(self): + from cli import _normalize_moa_model + assert _normalize_moa_model("anthropic/claude-opus-4.8") == (None, "anthropic/claude-opus-4.8") + + def test_none_model_unchanged(self): + from cli import _normalize_moa_model + assert _normalize_moa_model(None) == (None, None) + + def test_colon_model_that_is_not_moa_unchanged(self): + from cli import _normalize_moa_model + # A provider:model form for a real provider must not be hijacked. + assert _normalize_moa_model("openrouter:deepseek/deepseek-v4") == ( + None, + "openrouter:deepseek/deepseek-v4", + ) + + def test_override_wins_over_explicit_provider(self): + # __init__ resolves requested_provider as + # ``_moa_provider_override or provider or ...``, so a moa: prefix must + # take precedence over an explicit --provider (the #56828 deepseek case + # where MoA was silently ignored). + from cli import _normalize_moa_model + override, model = _normalize_moa_model("moa:strategy") + requested_provider = override or "deepseek" or "auto" + assert requested_provider == "moa" + assert model == "strategy" From d661886c90a7f6dcb0e452b6535326aa8c68a918 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Mon, 20 Jul 2026 17:35:55 +0530 Subject: [PATCH 024/552] test(cli): assert HermesCLI.__init__ wires moa: to the moa provider The existing tests cover _normalize_moa_model() in isolation and a local precedence expression, but not the __init__ wiring itself. Add two init-level regression tests: constructing HermesCLI(model='moa:strategy') strips the prefix to model='strategy' and forces requested_provider='moa', and the moa: prefix wins over an explicit --provider. Both fail if the override is dropped from the requested_provider resolution. Refs #56828 --- tests/cli/test_cli_provider_resolution.py | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index d1500723f87..2a04e2b8d25 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -230,6 +230,47 @@ def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch): assert shell.requested_provider == "custom" +def test_cli_init_wires_moa_preset_model_to_moa_provider(monkeypatch): + # #56828: constructing the CLI with `-m moa:` (the -Q one-shot + # path) must strip the prefix off self.model AND force + # requested_provider="moa", so the existing resolve_runtime_provider / + # agent_init MoA route runs non-interactively. The unit tests cover + # _normalize_moa_model() in isolation; this asserts the __init__ wiring + # the sweeper flagged as untested. + cli = _import_cli() + + # Neutralize any config/env provider so a failure here can only come from + # the moa override, not an ambient default. + config_copy = dict(cli.CLI_CONFIG) + model_copy = dict(config_copy.get("model", {})) + model_copy["provider"] = None + config_copy["model"] = model_copy + monkeypatch.setattr(cli, "CLI_CONFIG", config_copy) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + + shell = cli.HermesCLI(model="moa:strategy", compact=True, max_turns=1) + + assert shell.requested_provider == "moa" + assert shell.model == "strategy" + + +def test_cli_init_moa_prefix_overrides_explicit_provider(monkeypatch): + # The #56828 regression case: `--provider deepseek -m moa:strategy` + # silently dropped MoA because the explicit provider won. __init__ resolves + # requested_provider as `_moa_provider_override or provider or ...`, so the + # moa: prefix must win over the explicit --provider. + cli = _import_cli() + + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + + shell = cli.HermesCLI( + model="moa:strategy", provider="deepseek", compact=True, max_turns=1 + ) + + assert shell.requested_provider == "moa" + assert shell.model == "strategy" + + def test_codex_provider_replaces_incompatible_default_model(monkeypatch): """When provider resolves to openai-codex and no model was explicitly chosen, the global config default (e.g. anthropic/claude-opus-4.6) must From c1f5f0f9115ef779bf08cd2de70326a5ce4877cf Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 5 Jul 2026 17:49:04 +0800 Subject: [PATCH 025/552] fix(doctor): recognise 'moa' as a valid internal provider MoA (Mixture of Agents) is a legitimate internal provider used by Diagnosis presets and multi-model aggregation. When a MoA preset sets model.provider to 'moa', hermes doctor incorrectly reports it as 'unrecognised' and suggests changing it, which would break the MoA setup. Add 'moa' to the known_providers set alongside 'openrouter', 'custom', and 'auto' so doctor recognises it as valid. Fixes #58759 --- hermes_cli/doctor.py | 2 +- tests/hermes_cli/test_doctor.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index e995178d119..7d585176efd 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -872,7 +872,7 @@ def run_doctor(args): PROVIDER_REGISTRY, resolve_provider as _resolve_auth_provider, ) - known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"} + known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto", "moa"} except Exception: _resolve_auth_provider = None pass diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 8e7575b86b2..2fa798a0815 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -517,6 +517,7 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon ("kilocode", "anthropic/claude-sonnet-4.6"), ("kimi-coding", "kimi-k2"), ("nvidia", "qwen/qwen3.5-122b-a10b"), + ("moa", "anthropic/claude-sonnet-4.6"), ], ) def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( From 3638abfbf9c311d78814cd45206eeef729e1f80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=96=87?= Date: Mon, 6 Jul 2026 16:45:00 +0800 Subject: [PATCH 026/552] fix(moa): parse JSON string reference_models in _normalize_preset When reference_models is stored as a JSON string (e.g. from hermes moa configure or hand-edited config.yaml), _normalize_preset silently falls back to hardcoded defaults because the string fails both isinstance(x, list) and isinstance(x, dict) checks. Add json.loads() parsing before the type checks so both formats work. --- hermes_cli/moa_config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index bf40976dc2a..9353587fe72 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -207,6 +207,12 @@ def _normalize_preset(raw: Any) -> dict[str, Any]: raw = {} raw_refs = raw.get("reference_models") + # reference_models may be a JSON string (hand-edited config.yaml) or a list. + if isinstance(raw_refs, str): + try: + raw_refs = json.loads(raw_refs) + except (json.JSONDecodeError, ValueError): + raw_refs = [] if not isinstance(raw_refs, list): # A hand-edited scalar / single mapping (or a bad type) must degrade to # defaults instead of crashing the iteration, mirroring the tolerance From 85b2d52b71fb6b30880b4e2228bbb8876ac2ce99 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:01:03 -0700 Subject: [PATCH 027/552] test(moa): regression tests for JSON-string reference_models parsing (follow-up to #59497) --- tests/hermes_cli/test_moa_config.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 88c7896f155..408ed320858 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -100,6 +100,31 @@ def test_normalize_moa_config_wraps_bare_dict_reference_models(): assert cfg["presets"]["p"]["reference_models"] == [{"provider": "openai", "model": "gpt-4o"}] +def test_normalize_moa_config_parses_json_string_reference_models(): + """reference_models stored as a JSON string (hand-edited config.yaml or a + stringified GUI save) must round-trip to the parsed model list instead of + being discarded for defaults.""" + import json + + models = [ + {"provider": "openai", "model": "gpt-4o"}, + {"provider": "anthropic", "model": "claude-sonnet-4"}, + ] + cfg = normalize_moa_config( + {"presets": {"p": {"reference_models": json.dumps(models)}}} + ) + assert cfg["presets"]["p"]["reference_models"] == models + + +def test_normalize_moa_config_malformed_json_string_falls_back_to_defaults(): + """A malformed JSON string reference_models must degrade to the default + reference models without raising.""" + cfg = normalize_moa_config( + {"presets": {"p": {"reference_models": "[{'provider': broken"}}} + ) + assert cfg["presets"]["p"]["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS + + def test_normalize_moa_config_preserves_slot_reasoning_effort(): cfg = normalize_moa_config( { From d4c6ae7b1154b41212d41c2342749ea1b831448b Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 5 Jul 2026 19:22:40 +0800 Subject: [PATCH 028/552] fix(moa): preserve save_traces/trace_dir on GUI config save MoaConfigPayload does not declare save_traces or trace_dir, so set_moa_models() overwrites cfg["moa"] with a dict that lacks these hand-edited keys. Use dict.update() to merge instead of replace. Fixes #58819 --- hermes_cli/web_server.py | 5 +- ...est_moa_set_models_preserves_extra_keys.py | 146 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5d45e731f26..d34c6ca467c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6825,7 +6825,10 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None): ) normalized = normalize_moa_config(raw) - cfg["moa"] = normalized + # Merge instead of overwrite so that hand-edited keys not declared + # in MoaConfigPayload (e.g. save_traces, trace_dir) survive a GUI + # save. See issue #58819. + cfg.setdefault("moa", {}).update(normalized) save_config(cfg) return {"ok": True, **normalized} except HTTPException: diff --git a/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py b/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py new file mode 100644 index 00000000000..0b245ce65e4 --- /dev/null +++ b/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py @@ -0,0 +1,146 @@ +"""Regression tests for ``set_moa_models`` preserving undeclared config keys. + +Issue #58819: ``MoaConfigPayload`` does not declare ``save_traces`` or +``trace_dir``, so a GUI save via ``PUT /api/model/moa`` silently drops +these hand-edited keys from ``config.yaml``. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from hermes_cli.web_server import MoaConfigPayload, MoaModelSlot, MoaPresetPayload, set_moa_models + + +def _base_payload(**overrides) -> MoaConfigPayload: + """Return a minimal valid MoaConfigPayload.""" + defaults = dict( + default_preset="default", + active_preset="", + presets={ + "default": MoaPresetPayload( + reference_models=[ + MoaModelSlot(provider="openai-codex", model="gpt-5.5"), + ], + aggregator=MoaModelSlot(provider="openrouter", model="anthropic/claude-opus-4.8"), + max_tokens=4096, + enabled=True, + ), + }, + ) + defaults.update(overrides) + return MoaConfigPayload(**defaults) + + +class TestSetMoaModelsPreservesUndeclaredKeys: + """save_traces / trace_dir must survive a GUI save.""" + + def test_save_traces_preserved(self, tmp_path): + """Hand-edited ``moa.save_traces: true`` must not be dropped.""" + existing_cfg = { + "moa": { + "save_traces": True, + "trace_dir": "/custom/traces", + "default_preset": "default", + "presets": { + "default": { + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + "max_tokens": 4096, + "enabled": True, + }, + }, + }, + } + + saved_cfg = {} + + def fake_load_config(): + return dict(existing_cfg) # shallow copy + + def fake_save_config(cfg): + saved_cfg.update(cfg) + + payload = _base_payload() + + with ( + patch("hermes_cli.web_server.load_config", side_effect=fake_load_config), + patch("hermes_cli.web_server.save_config", side_effect=fake_save_config), + patch("hermes_cli.web_server._profile_scope"), + ): + set_moa_models(payload) + + moa = saved_cfg["moa"] + assert moa.get("save_traces") is True, ( + "save_traces was dropped by set_moa_models" + ) + assert moa.get("trace_dir") == "/custom/traces", ( + "trace_dir was dropped by set_moa_models" + ) + + def test_trace_dir_empty_string_preserved(self, tmp_path): + """Even an empty-string ``trace_dir`` must survive.""" + existing_cfg = { + "moa": { + "save_traces": True, + "trace_dir": "", + "default_preset": "default", + "presets": { + "default": { + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + "max_tokens": 4096, + "enabled": True, + }, + }, + }, + } + + saved_cfg = {} + + def fake_load_config(): + return dict(existing_cfg) + + def fake_save_config(cfg): + saved_cfg.update(cfg) + + payload = _base_payload() + + with ( + patch("hermes_cli.web_server.load_config", side_effect=fake_load_config), + patch("hermes_cli.web_server.save_config", side_effect=fake_save_config), + patch("hermes_cli.web_server._profile_scope"), + ): + set_moa_models(payload) + + moa = saved_cfg["moa"] + assert moa.get("save_traces") is True + assert moa.get("trace_dir") == "" + + def test_no_existing_moa_key_still_works(self, tmp_path): + """When ``moa`` key is absent from config, the endpoint must not crash.""" + existing_cfg: dict = {} + + saved_cfg = {} + + def fake_load_config(): + return dict(existing_cfg) + + def fake_save_config(cfg): + saved_cfg.update(cfg) + + payload = _base_payload() + + with ( + patch("hermes_cli.web_server.load_config", side_effect=fake_load_config), + patch("hermes_cli.web_server.save_config", side_effect=fake_save_config), + patch("hermes_cli.web_server._profile_scope"), + ): + result = set_moa_models(payload) + + assert result["ok"] is True + assert "default_preset" in saved_cfg["moa"] From a7d78ad685edd444c71e089c98169586f6304986 Mon Sep 17 00:00:00 2001 From: iniak Date: Mon, 13 Jul 2026 01:46:38 +0800 Subject: [PATCH 029/552] fix: filter invalid MoA slot providers --- hermes_cli/moa_cmd.py | 13 +++++++++++-- tests/hermes_cli/test_moa_config.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/hermes_cli/moa_cmd.py b/hermes_cli/moa_cmd.py index 417ce23a11c..078b14af4cd 100644 --- a/hermes_cli/moa_cmd.py +++ b/hermes_cli/moa_cmd.py @@ -29,7 +29,10 @@ def _prompt_choice(title: str, rows: list[str], default: int = 0) -> int: def _model_options() -> list[dict[str, Any]]: payload = build_models_payload( load_picker_context(), - include_unconfigured=True, + # Slot pickers must only offer providers the user can actually call. + # Including setup-only rows makes an unconfigured canonical provider + # (usually OpenRouter, due to catalog ordering) become the default. + include_unconfigured=False, picker_hints=True, canonical_order=True, pricing=True, @@ -37,7 +40,13 @@ def _model_options() -> list[dict[str, Any]]: max_models=200, ) providers = payload.get("providers") or [] - return [p for p in providers if p.get("slug") and p.get("models")] + return [ + p + for p in providers + if p.get("slug") + and str(p.get("slug")).strip().lower() != "moa" + and p.get("models") + ] def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]: diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 408ed320858..2b4ae54c627 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -14,6 +14,27 @@ from hermes_cli.moa_config import ( ) +def test_moa_slot_picker_excludes_unconfigured_providers(monkeypatch): + from hermes_cli import moa_cmd + + captured = {} + monkeypatch.setattr(moa_cmd, "load_picker_context", lambda: object()) + + def fake_build(_context, **kwargs): + captured.update(kwargs) + return { + "providers": [ + {"slug": "moa", "models": ["default"]}, + {"slug": "opencode-go", "models": ["deepseek-v4-pro"]}, + ] + } + + monkeypatch.setattr(moa_cmd, "build_models_payload", fake_build) + + assert [row["slug"] for row in moa_cmd._model_options()] == ["opencode-go"] + assert captured["include_unconfigured"] is False + + def test_normalize_moa_config_uses_default_named_preset(): cfg = normalize_moa_config({}) From 3ea35d671106e586b51aa17ad0f4d162467b92a0 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:52:26 +0300 Subject: [PATCH 030/552] fix(vertex,moa): register vertex in PROVIDER_REGISTRY and HERMES_OVERLAYS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vertex AI provider (added same-day, commit c73e74386) was never added to either of the two provider registries that agent/auxiliary_client.py and the MoA slot-resolution chain depend on, breaking Vertex outside the main conversation loop: 1. hermes_cli/auth.py::PROVIDER_REGISTRY had no "vertex" entry. The plugin-auto-extend loop that normally fills gaps explicitly skips non-api_key auth types (`if _pp.auth_type != "api_key": continue`), and Vertex was never hand-declared like "bedrock" is. Because resolve_provider_client() in agent/auxiliary_client.py gates everything on `pconfig = PROVIDER_REGISTRY.get(provider)` and returns (None, None) immediately when pconfig is None, its `elif pconfig.auth_type == "vertex"` branch was permanently dead code — every auxiliary Vertex call (vision, title generation, reflection, context compression, MoA reference/ aggregator slots) failed outright, not just a MoA-specific edge case. 2. hermes_cli/providers.py::HERMES_OVERLAYS also had no "vertex" entry, so hermes_cli.providers.get_provider("vertex") returned None. This backs _preserve_provider_with_base_url() in agent/auxiliary_client.py, which a MoA slot's resolved (base_url, api_key) pair needs to keep its "vertex" identity instead of silently collapsing to "custom" — losing the identity _refresh_provider_credentials() needs to re-mint an expired OAuth2 token (~1h lifetime) on a 401, and permanently breaking every subsequent call in that MoA preset for the rest of the session. Fix mirrors the existing "bedrock"/aws_sdk entries in both registries exactly, plus adds a "vertex" branch to _refresh_provider_credentials() (it had branches for openai-codex/nous/anthropic/xai-oauth but not vertex, so a 401 fell through to `return False` without evicting the stale cached client). - hermes_cli/auth.py: hand-declared vertex ProviderConfig(auth_type="vertex") in PROVIDER_REGISTRY, matching bedrock's shape. - hermes_cli/providers.py: vertex HermesOverlay(auth_type="vertex") in HERMES_OVERLAYS + "Google Vertex AI" label override. - agent/auxiliary_client.py: vertex branch in _refresh_provider_credentials that re-mints the token via get_vertex_config() and evicts the stale cached client. - 8 new regression tests across tests/hermes_cli/test_vertex_provider.py and tests/agent/test_auxiliary_client.py: registry membership, end-to-end resolve_provider_client("vertex", ...) building a working client (proving the previously-dead branch is now reachable), and the 401-refresh/cache- eviction path. --- agent/auxiliary_client.py | 18 +++++++ hermes_cli/auth.py | 11 +++++ hermes_cli/providers.py | 14 ++++++ tests/agent/test_auxiliary_client.py | 63 ++++++++++++++++++++++++ tests/hermes_cli/test_vertex_provider.py | 28 +++++++++++ 5 files changed, 134 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index cd18779e8ea..f475369d1a1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3819,6 +3819,24 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + if normalized == "vertex": + # Mirrors run_agent.py's _try_refresh_vertex_client_credentials + # for the main conversation loop. Without this branch, an + # auxiliary Vertex client (vision, title generation, reflection, + # context compression, ...) that 401s on its ~1h token expiry + # falls through to the final `return False` below: the stale + # client is never evicted from _client_cache (whose cache key + # ignores the rotating bearer token), so every subsequent + # auxiliary Vertex call keeps 401ing until process restart. + from agent.vertex_adapter import get_vertex_config + + token, base_url = get_vertex_config() + if not isinstance(token, str) or not token.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + _evict_cached_clients(normalized) + return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 011cf817aeb..43d8ef596f0 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -434,6 +434,17 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=(), base_url_env_var="BEDROCK_BASE_URL", ), + "vertex": ProviderConfig( + id="vertex", + name="Google Vertex AI", + auth_type="vertex", + # No static inference_base_url: Vertex's endpoint is computed per + # request from project_id + region (agent/vertex_adapter.py's + # build_vertex_base_url), not a fixed host like the other entries. + inference_base_url="", + api_key_env_vars=(), # OAuth2 (service-account JSON / ADC), not a key + base_url_env_var="", + ), "azure-foundry": ProviderConfig( id="azure-foundry", name="Azure Foundry", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 2303e214115..6bfbf01feb0 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -222,6 +222,19 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { transport="bedrock_converse", auth_type="aws_sdk", ), + # Vertex authenticates via OAuth2 (service-account JSON / ADC), not a + # static API key or models.dev entry — resolved specially by + # agent/vertex_adapter.py, like bedrock's aws_sdk. Without an overlay + # entry get_provider("vertex") returns None, which makes + # _preserve_provider_with_base_url() in agent/auxiliary_client.py treat + # a Vertex MoA slot's resolved (base_url, api_key) pair as an unknown + # custom endpoint instead of "vertex" — losing the provider identity + # that _refresh_provider_credentials() needs to re-mint an expired + # OAuth2 token on a 401. + "vertex": HermesOverlay( + transport="openai_chat", + auth_type="vertex", + ), } @@ -390,6 +403,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "lmstudio": "LM Studio", "local": "Local endpoint", "bedrock": "AWS Bedrock", + "vertex": "Google Vertex AI", "ollama-cloud": "Ollama Cloud", "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)", } diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 86b5ae4adc5..921f84ae99d 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -4347,6 +4347,69 @@ class TestAuxiliaryAuthRefreshRetry: mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999) stale_client.close.assert_called_once() + def test_refresh_provider_credentials_remints_vertex_token_and_evicts_cache(self): + """Vertex tokens live ~1h; on a long-running gateway the cached + auxiliary client's bearer token expires mid-session and 401s. + _refresh_provider_credentials("vertex") must re-mint the token via + the adapter (which refreshes in place when near expiry) and evict + the stale cached client so the next call rebuilds with a fresh one — + previously there was no "vertex" branch here at all, so this fell + through to the final `return False` and the stale client (and its + dead token) stayed cached until process restart.""" + stale_client = MagicMock() + cache_key = ("vertex", False, None, None, None) + + with ( + patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "google/gemini-3-flash-preview", None)}), + patch( + "agent.vertex_adapter.get_vertex_config", + return_value=("ya29.FRESH", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"), + ) as mock_get_config, + ): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("vertex") is True + + mock_get_config.assert_called_once() + stale_client.close.assert_called_once() + + def test_refresh_provider_credentials_vertex_returns_false_when_unminted(self): + """No usable token/base_url (e.g. ADC and the service-account file + both failed) — refresh must report failure, not silently evict and + pretend the client is fixed.""" + with patch("agent.vertex_adapter.get_vertex_config", return_value=(None, None)): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("vertex") is False + + def test_resolve_provider_client_vertex_builds_client_from_minted_token(self): + """End-to-end: resolve_provider_client("vertex", ...) must reach the + auth_type == "vertex" branch and build a working client, not die at + the PROVIDER_REGISTRY lookup (a plain HERMES_OVERLAYS-only fix would + leave this branch dead code — PROVIDER_REGISTRY is what + resolve_provider_client actually gates on).""" + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch( + "agent.vertex_adapter.get_vertex_config", + return_value=("ya29.FRESH", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"), + ), + ): + client, model = resolve_provider_client("vertex", "google/gemini-3-flash-preview") + + assert client is not None + assert model == "google/gemini-3-flash-preview" + assert str(client.base_url).rstrip("/") == ( + "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi" + ) + + def test_resolve_provider_client_vertex_none_when_no_credentials(self): + with patch("agent.vertex_adapter.has_vertex_credentials", return_value=False): + client, model = resolve_provider_client("vertex", "google/gemini-3-flash-preview") + + assert client is None + assert model is None + @pytest.mark.asyncio async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): stale_client = MagicMock() diff --git a/tests/hermes_cli/test_vertex_provider.py b/tests/hermes_cli/test_vertex_provider.py index af67aacac29..d97b5340902 100644 --- a/tests/hermes_cli/test_vertex_provider.py +++ b/tests/hermes_cli/test_vertex_provider.py @@ -98,3 +98,31 @@ def test_vertex_extra_body_empty_without_reasoning(): p = get_provider_profile("vertex") assert p.build_extra_body(model="google/gemini-3-flash-preview") == {} + + +def test_vertex_registered_in_provider_registry(): + """PROVIDER_REGISTRY (hermes_cli.auth) is what agent/auxiliary_client.py's + resolve_provider_client() looks up before dispatching on auth_type. Without + an entry here, the ``elif pconfig.auth_type == "vertex":`` branch there is + unreachable dead code — every auxiliary Vertex call (vision, title + generation, MoA reference/aggregator slots, ...) fails at the + ``pconfig is None`` guard before ever reaching it.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + cfg = PROVIDER_REGISTRY.get("vertex") + assert cfg is not None + assert cfg.auth_type == "vertex" + + +def test_vertex_registered_in_hermes_overlays(): + """hermes_cli.providers.get_provider("vertex") backs + _preserve_provider_with_base_url() in agent/auxiliary_client.py, which + decides whether a MoA slot's resolved Vertex (base_url, api_key) pair + keeps its "vertex" provider identity or silently collapses to "custom" — + losing the identity _refresh_provider_credentials() needs to re-mint an + expired OAuth2 token on a 401.""" + from hermes_cli.providers import get_provider + + resolved = get_provider("vertex") + assert resolved is not None + assert resolved.auth_type == "vertex" From df051c17cc916f741803948dcd6c64705b821b3f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:07:00 -0700 Subject: [PATCH 031/552] =?UTF-8?q?fix(vertex):=20surface=20vertex=20in=20?= =?UTF-8?q?the=20/model=20picker=20=E2=80=94=20credential=20gate=20+=20cur?= =?UTF-8?q?ated=20model=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community verification of #56688 (zmack12344321) found two follow-up gaps that kept Vertex invisible in the /model menu even after registry registration: 1. hermes_cli/model_switch.py: list_authenticated_providers() had a credential gate hard-coded to API keys (with an aws_sdk special case only) — add a vertex branch using has_vertex_credentials(), mirroring the aws_sdk shape. 2. hermes_cli/models.py: Vertex's OpenAI-compatible endpoint has no /models listing route, so without a curated _PROVIDER_MODELS entry the picker only ever showed the current model — add a Gemini curated list. Follow-up to #56688. --- hermes_cli/model_switch.py | 10 ++++ hermes_cli/models.py | 12 +++++ tests/hermes_cli/test_vertex_model_picker.py | 49 ++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 tests/hermes_cli/test_vertex_model_picker.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index fa83973622a..4205bdd302e 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2024,6 +2024,16 @@ def list_authenticated_providers( has_creds = False if overlay.auth_type == "aws_sdk": has_creds = _has_aws_sdk_creds_for_listing(hermes_slug) + elif overlay.auth_type == "vertex": + # Vertex authenticates via OAuth2 (service-account JSON / ADC), + # not an API key — mirror the aws_sdk gate above, otherwise the + # provider is silently hidden from the /model picker even when + # fully configured. + try: + from agent.vertex_adapter import has_vertex_credentials + has_creds = has_vertex_credentials() + except Exception as exc: + logger.debug("Vertex credential check failed: %s", exc) elif overlay.extra_env_vars: has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars) # Also check api_key_env_vars from PROVIDER_REGISTRY for api_key auth_type diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 2eb1b14cc2a..bea34c44486 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -560,6 +560,18 @@ _PROVIDER_MODELS: dict[str, list[str]] = { # Azure Foundry: user-provided endpoint and model. # Empty list because models depend on the endpoint configuration. "azure-foundry": [], + # Google Vertex AI — static curated list. Vertex's OpenAI-compatible + # endpoint has no /models listing route, so without this entry the + # /model picker only ever shows the currently-configured model. + # Model IDs use the "google/" publisher prefix Vertex's openapi + # endpoint expects (see hermes_cli/model_setup_flows.py). + "vertex": [ + "google/gemini-3.1-pro-preview", + "google/gemini-3-pro-preview", + "google/gemini-3.5-flash", + "google/gemini-3-flash-preview", + "google/gemini-3.1-flash-lite-preview", + ], "novita": [ "moonshotai/kimi-k2.5", "minimax/minimax-m2.7", diff --git a/tests/hermes_cli/test_vertex_model_picker.py b/tests/hermes_cli/test_vertex_model_picker.py new file mode 100644 index 00000000000..ccf17e6b4ed --- /dev/null +++ b/tests/hermes_cli/test_vertex_model_picker.py @@ -0,0 +1,49 @@ +"""Vertex visibility in the /model picker (follow-up to PR #56688). + +Community verification of the vertex-registration fix found two remaining +gaps that kept the provider invisible/unusable in the /model menu: + +1. ``list_authenticated_providers`` had no credential gate for the + ``vertex`` auth_type (only ``aws_sdk`` was special-cased), so the + provider was silently hidden even when fully configured. +2. Vertex's OpenAI-compatible endpoint has no ``/models`` listing route, + so without a curated ``_PROVIDER_MODELS["vertex"]`` entry the picker + only ever showed the currently-configured model. + +No network calls. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from hermes_cli.model_switch import list_authenticated_providers +from hermes_cli.models import _PROVIDER_MODELS + + +def test_vertex_has_curated_model_list(): + """Vertex has no /models route — the picker needs a static curated list.""" + models = _PROVIDER_MODELS.get("vertex") + assert models, "_PROVIDER_MODELS must have a non-empty 'vertex' entry" + # Vertex's openapi endpoint expects the google/ publisher prefix. + assert all(m.startswith("google/") for m in models) + + +def test_vertex_appears_when_credentials_configured(): + """has_vertex_credentials() == True must surface vertex in the picker.""" + with patch("agent.vertex_adapter.has_vertex_credentials", return_value=True): + providers = list_authenticated_providers(current_provider="openrouter", max_models=50) + + vertex = next((p for p in providers if p["slug"] == "vertex"), None) + assert vertex is not None, "vertex should appear when credentials are configured" + assert vertex["models"], "vertex row must carry the curated model list" + assert "google/gemini-3-pro-preview" in vertex["models"] + + +def test_vertex_hidden_without_credentials(): + """No service-account path / project override → vertex stays hidden.""" + with patch("agent.vertex_adapter.has_vertex_credentials", return_value=False): + providers = list_authenticated_providers(current_provider="openrouter", max_models=50) + + vertex = next((p for p in providers if p["slug"] == "vertex"), None) + assert vertex is None, "vertex should not appear without credentials" From 9b868f6f677f5153b4bf348bd760b273270967fa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:07:06 -0700 Subject: [PATCH 032/552] chore: contributor email mappings for wen0531 and iniak --- contributors/emails/iniak@iniakdeMac-mini.local | 1 + contributors/emails/wen0531@gmail.com | 1 + 2 files changed, 2 insertions(+) create mode 100644 contributors/emails/iniak@iniakdeMac-mini.local create mode 100644 contributors/emails/wen0531@gmail.com diff --git a/contributors/emails/iniak@iniakdeMac-mini.local b/contributors/emails/iniak@iniakdeMac-mini.local new file mode 100644 index 00000000000..56e9c8b5d1a --- /dev/null +++ b/contributors/emails/iniak@iniakdeMac-mini.local @@ -0,0 +1 @@ +iniak diff --git a/contributors/emails/wen0531@gmail.com b/contributors/emails/wen0531@gmail.com new file mode 100644 index 00000000000..2ba4c0fd0c0 --- /dev/null +++ b/contributors/emails/wen0531@gmail.com @@ -0,0 +1 @@ +wen0531 From 5faef80a43ccd17f619eb603ae7542d5c3cafc68 Mon Sep 17 00:00:00 2001 From: Jonathan Boisson Date: Sun, 7 Jun 2026 13:42:49 +0000 Subject: [PATCH 033/552] fix: ghost skill P0/P1 mitigation - [SKILL_PRUNED] marker + safety rule --- agent/context_compressor.py | 8 +++++++- agent/prompt_builder.py | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2baa2731c62..1406aa2197b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -891,7 +891,13 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten code_preview += "..." return f"[execute_code] `{code_preview}` ({line_count} lines output)" - if tool_name in {"skill_view", "skills_list", "skill_manage"}: + if tool_name == "skill_view": + name = args.get("name", "?") + if content_len > 5000: + return f"[skill_view] name={name} ({content_len:,} chars) [SKILL_PRUNED: content lost in compression; reload with skill_view before relying on it]" + return f"[skill_view] name={name} ({content_len:,} chars)" + + if tool_name in {"skills_list", "skill_manage"}: name = args.get("name", "?") return f"[{tool_name}] name={name} ({content_len:,} chars)" diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index fc94ca2a6b2..a7340f17279 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -192,7 +192,12 @@ SKILLS_GUIDANCE = ( "skill with skill_manage so you can reuse it next time.\n" "When using a skill and finding it outdated, incomplete, or wrong, " "patch it immediately with skill_manage(action='patch') — don't wait to be asked. " - "Skills that aren't maintained become liabilities." + "Skills that aren't maintained become liabilities.\n" + "\n" + "## Skill Safety Rule\n" + "1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n" + "2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n" + "3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding." ) KANBAN_GUIDANCE = ( From 6816f2f02c12e787d6d5f0c7ebcb1dda962157a7 Mon Sep 17 00:00:00 2001 From: Jonathan Boisson Date: Thu, 23 Jul 2026 12:24:59 -0700 Subject: [PATCH 034/552] fix(P1+P2): marker names skill_view(name='X') + DEDUP rule for repeated [SKILL_PRUNED] markers Surgical reapply of the marker-alignment and dedup-guidance halves of PR #44166 commits 52341f6ca3 / 3d8a31432d / ae07412e4b onto current main: - the [SKILL_PRUNED: ...] marker embeds the exact reload call skill_view(name='') so the model can act without guessing - SKILLS_GUIDANCE Skill Safety Rule gains rule 4 (DEDUP): after one reload, remaining markers for the same skill are historical artifacts Fixes #32106 (part). --- agent/context_compressor.py | 2 +- agent/prompt_builder.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 1406aa2197b..9c362e0d340 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -894,7 +894,7 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten if tool_name == "skill_view": name = args.get("name", "?") if content_len > 5000: - return f"[skill_view] name={name} ({content_len:,} chars) [SKILL_PRUNED: content lost in compression; reload with skill_view before relying on it]" + return f"[skill_view] name={name} ({content_len:,} chars) [SKILL_PRUNED: content lost in compression; reload with skill_view(name='{name}')]" return f"[skill_view] name={name} ({content_len:,} chars)" if tool_name in {"skills_list", "skill_manage"}: diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index a7340f17279..845e4260ddb 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -197,7 +197,8 @@ SKILLS_GUIDANCE = ( "## Skill Safety Rule\n" "1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n" "2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n" - "3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding." + "3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding.\n" + "4. **DEDUP** — After reloading a pruned skill, **ignore any remaining `[SKILL_PRUNED]` markers for that same skill** — they are historical artifacts from previous compactions and do not need further action." ) KANBAN_GUIDANCE = ( From 44c67fca91252d0290feb1e47888825b4cbb9cba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:38:52 -0700 Subject: [PATCH 035/552] =?UTF-8?q?fix(compression):=20ghost-skill=20defen?= =?UTF-8?q?se=20=E2=80=94=20canonical=20marker=20constant,=20protected-ski?= =?UTF-8?q?ll=20prune=20guard,=20deterministic=20marker=20survival?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvage rework of PR #44166 (@dolphin-creator) onto current main: - ONE canonical prune marker: _skill_pruned_marker(name) builds '[SKILL_PRUNED: ... reload with skill_view(name='X')]'; both emit sites and the survival presence check use the same string, fixing the original PR's defect where the emitted marker was '[SKILL_PRUNED:' but the presence check looked for '[SKILL_PRUNED]' (re-injection duplicated markers that had survived). - Phase-1 prune (_prune_old_tool_results) now threads a protected-skill set: skills whose skill_view call is within the last 10 messages, in the protected tail, or named in a tail user message keep their full bodies. Pass-4 pressure demotion deliberately overrides the guard so the #61932 dead-end shape cannot return. - P2 deterministic marker survival: skill names are extracted from the summarizer INPUT (and the previous summary) before the aux LLM call and any dropped canonical markers are re-injected afterward under a '## Pruned Skills' section — routed through _redact_compaction_text, appended to the summary body only (never in front of SUMMARY_PREFIX or scaffolding start-of-content markers; classify_summary_content is unaffected). Same treatment on the static fallback path, re-applied after its size cap since truncation cuts exactly where markers land. - Summarizer prompt gains a '## Pruned Skills' copy-verbatim section. Fixes #32106. --- agent/context_compressor.py | 248 ++++++++++++++++++++++++++++++++++-- 1 file changed, 240 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 9c362e0d340..7a7b69fe16d 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -320,6 +320,172 @@ _SUMMARY_INPUT_MAX_CHARS = 160_000 # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" +# Ghost-skill defense (#32106): when compaction reduces an old ``skill_view`` +# result to a 1-line metadata summary, the model still believes the skill is +# loaded even though its instructions are gone. The marker below is the ONE +# canonical prune signal — ``_skill_pruned_marker()`` builds it and every +# presence check matches against the same string, so the emit side and the +# check side can never drift apart (the original PR #44166 emitted +# ``[SKILL_PRUNED:`` but presence-checked ``[SKILL_PRUNED]``, making +# re-injection fire even when the marker had survived). +SKILL_PRUNED_MARKER_PREFIX = "[SKILL_PRUNED:" +# Cap for the deterministic marker re-injection list — keeps a very long +# session from growing an unbounded "## Pruned Skills" block in every +# iterative summary update. Newest-referenced skills win. +_MAX_PRUNED_SKILL_MARKERS = 20 + + +def _skill_pruned_marker(skill_name: str) -> str: + """Return the canonical prune marker for *skill_name*. + + Used verbatim by BOTH the emit sites (tool-result summarization, + summary re-injection) and the survival check in + ``_reinject_pruned_skill_markers`` — one string, no drift. + """ + return ( + f"{SKILL_PRUNED_MARKER_PREFIX} content lost in compression; " + f"reload with skill_view(name='{skill_name}')]" + ) + + +# Matches the canonical marker and captures the skill name. Anchored on the +# shared prefix constant so a wording change to the marker body updates the +# emit helper and this extractor together. +_SKILL_PRUNED_MARKER_RE = re.compile( + re.escape(SKILL_PRUNED_MARKER_PREFIX) + + r"[^\]]*?reload with skill_view\(name='([^']+)'\)" +) + + +def _extract_pruned_skill_names(text: str) -> list[str]: + """Return skill names referenced by prune markers in *text*, in order.""" + names: list[str] = [] + for match in _SKILL_PRUNED_MARKER_RE.finditer(text or ""): + name = match.group(1) + if name not in names: + names.append(name) + return names + + +_PRUNED_SKILLS_SECTION_HEADING = "## Pruned Skills" + + +def _reinject_pruned_skill_markers(summary: str, skill_names: list[str]) -> str: + """Deterministically restore prune markers the summarizer dropped. + + ``skill_names`` was extracted from the summarizer INPUT before the LLM + call. For every skill whose canonical marker (``_skill_pruned_marker``) + is absent from the model's output, append it under a ``## Pruned + Skills`` section. Presence is checked against the SAME canonical string + the emit sites produce — a paraphrased or renamed marker counts as + dropped and is restored (the original PR checked the literal + ``[SKILL_PRUNED]``, which never matches the emitted ``[SKILL_PRUNED:`` + form, so it duplicated markers that HAD survived). + + The appended block is plain body text: it never carries a handoff + prefix, the merged-summary delimiter, or a start-of-content scaffolding + marker, so ``classify_summary_content`` / todo-snapshot flag handling + are unaffected. The block is routed through ``_redact_compaction_text`` + like every other compaction-boundary text. + """ + if not skill_names: + return summary + missing = [ + name for name in skill_names + if _skill_pruned_marker(name) not in summary + ] + if not missing: + return summary + lines = [_skill_pruned_marker(name) for name in missing] + block = ( + "\n\n" + _PRUNED_SKILLS_SECTION_HEADING + "\n" + + "\n".join(lines) + + "\n(The listed skills' instructions were pruned during context " + "compression. Reload with the skill_view call in each marker before " + "relying on that skill; one reload per skill is enough — ignore any " + "older markers for the same skill.)" + ) + return summary + _redact_compaction_text(block) + + +# A skill_view call within this many trailing messages counts as "just +# loaded": its full instruction body must survive the Phase-1 prune even when +# the token-budget boundary would otherwise demote it (#32106). Distinct from +# the protected-tail boundary, which is token-based and can land immediately +# after a bulky just-loaded skill body. +_SKILL_PRUNE_RECENT_WINDOW = 10 + + +def _skill_view_call_sites( + messages: List[Dict[str, Any]], +) -> list[tuple[int, str]]: + """Yield ``(message_index, skill_name)`` for every skill_view tool call.""" + sites: list[tuple[int, str]] = [] + for i, msg in enumerate(messages): + if msg.get("role") != "assistant": + continue + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + fn = tc.get("function", {}) + name = fn.get("name", "") if isinstance(fn, dict) else "" + args_str = fn.get("arguments", "") if isinstance(fn, dict) else "" + else: + fn = getattr(tc, "function", None) + name = getattr(fn, "name", "") if fn else "" + args_str = getattr(fn, "arguments", "") if fn else "" + if name != "skill_view" or not isinstance(args_str, str) or not args_str: + continue + try: + args = json.loads(args_str) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(args, dict): + skill = args.get("name", "") + if isinstance(skill, str) and skill: + sites.append((i, skill)) + return sites + + +def _collect_protected_skill_names( + messages: List[Dict[str, Any]], prune_boundary: int, +) -> set[str]: + """Skill names whose skill_view bodies must survive Phase-1 demotion. + + A skill is protected (lower-cased set) when any of these hold: + + - its most recent ``skill_view`` call sits within the last + ``_SKILL_PRUNE_RECENT_WINDOW`` messages (just loaded / just reloaded); + - its most recent ``skill_view`` call sits inside the protected tail + (at or after *prune_boundary*); + - its name is mentioned in a user message inside the protected tail + (the user is actively steering work that depends on it). + + Protection applies to the ordinary Phase-1/2 prune only. The Pass-4 + pressure demotion deliberately ignores it: when the protected region + itself exceeds the soft budget, exempting skill bodies would recreate + the #61932 dead-end shape. + """ + total = len(messages) + if not total: + return set() + recent_start = max(0, total - _SKILL_PRUNE_RECENT_WINDOW) + tail_start = max(0, prune_boundary) + tail_user_texts: list[str] = [] + for msg in messages[tail_start:]: + if msg.get("role") != "user": + continue + content = msg.get("content") + if isinstance(content, str) and content: + tail_user_texts.append(content.lower()) + protected: set[str] = set() + for idx, skill in _skill_view_call_sites(messages): + key = skill.lower() + if idx >= recent_start or idx >= tail_start: + protected.add(key) + elif any(key in text for text in tail_user_texts): + protected.add(key) + return protected + # Chars per token rough estimate _CHARS_PER_TOKEN = 4 # Flat token cost per attached image part. Real cost varies by provider and @@ -894,7 +1060,13 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten if tool_name == "skill_view": name = args.get("name", "?") if content_len > 5000: - return f"[skill_view] name={name} ({content_len:,} chars) [SKILL_PRUNED: content lost in compression; reload with skill_view(name='{name}')]" + # Ghost-skill defense (#32106): a metadata-only summary makes the + # model believe the skill is still loaded. The canonical marker + # tells it the instructions are gone AND how to get them back. + return ( + f"[skill_view] name={name} ({content_len:,} chars) " + + _skill_pruned_marker(str(name)) + ) return f"[skill_view] name={name} ({content_len:,} chars)" if tool_name in {"skills_list", "skill_manage"}: @@ -2217,7 +2389,14 @@ class ContextCompressor(ContextEngine): else: content_hashes[h] = (i, msg.get("tool_call_id", "?")) - def _demote_tool_result_at(idx: int) -> bool: + # Ghost-skill defense (#32106): skills just loaded (or actively + # referenced in the protected tail) keep their full skill_view + # bodies through the ordinary prune passes. Without this, a skill + # loaded moments before a compaction can be demoted to metadata + # while the model still believes its instructions are in context. + protected_skills = _collect_protected_skill_names(result, prune_boundary) + + def _demote_tool_result_at(idx: int, *, spare_protected_skills: bool = True) -> bool: """Replace a bulky tool result at ``idx`` with a 1-line summary. Returns True when the message was modified. @@ -2256,6 +2435,16 @@ class ContextCompressor(ContextEngine): return False call_id = msg.get("tool_call_id", "") tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", "")) + if spare_protected_skills and tool_name == "skill_view" and protected_skills: + # Just-loaded / actively-referenced skills survive verbatim + # (#32106). Pass-4 pressure demotion overrides this. + try: + _args = json.loads(tool_args) if tool_args else {} + except (json.JSONDecodeError, TypeError): + _args = {} + _skill = _args.get("name", "") if isinstance(_args, dict) else "" + if isinstance(_skill, str) and _skill.lower() in protected_skills: + return False summary = _summarize_tool_result(tool_name, tool_args, content) result[idx] = {**msg, "content": summary} pruned += 1 @@ -2320,7 +2509,10 @@ class ContextCompressor(ContextEngine): if demote_end > prune_boundary and _protected_region_tokens() > soft_ceiling: pressure_hits = 0 for i in range(max(0, prune_boundary), demote_end): - if _demote_tool_result_at(i): + # Pressure passes override the just-loaded-skill guard: + # when the protected region itself blows the soft budget, + # sparing skill bodies would recreate the #61932 dead-end. + if _demote_tool_result_at(i, spare_protected_skills=False): pressure_hits += 1 if _truncate_tool_call_args_at(i): pressure_hits += 1 @@ -2340,7 +2532,7 @@ class ContextCompressor(ContextEngine): if last_tool_idx is not None and i == last_tool_idx: continue if result[i].get("role") == "tool": - if _demote_tool_result_at(i): + if _demote_tool_result_at(i, spare_protected_skills=False): pressure_hits += 1 elif result[i].get("role") == "assistant": if _truncate_tool_call_args_at(i): @@ -2354,7 +2546,9 @@ class ContextCompressor(ContextEngine): and last_tool_idx >= prune_boundary and _protected_region_tokens() > soft_ceiling ): - if _demote_tool_result_at(last_tool_idx): + if _demote_tool_result_at( + last_tool_idx, spare_protected_skills=False + ): pressure_hits += 1 if pressure_hits and not self.quiet_mode: logger.info( @@ -2754,9 +2948,25 @@ Continue from the most recent unfulfilled user ask and protected tail messages. ## Critical Context Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" + # Ghost-skill defense (#32106): the fallback's per-turn truncation + # (``_FALLBACK_TURN_MAX_CHARS``) routinely cuts [SKILL_PRUNED: ...] + # markers out of the compacted turns. Re-derive them from the raw + # turn contents and re-inject deterministically, exactly like the + # LLM-summary path. + _pruned_names: list[str] = [] + for _turn in turns_to_summarize: + for _name in _extract_pruned_skill_names( + _content_text_for_contains(_turn.get("content")) + ): + if _name not in _pruned_names: + _pruned_names.append(_name) + del _pruned_names[_MAX_PRUNED_SKILL_MARKERS:] summary = self._with_summary_prefix(_redact_compaction_text(body.strip())) if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS: summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]" + # Re-inject AFTER the size cap: the markers live at the end of the + # body, exactly where the truncation above cuts. + summary = _reinject_pruned_skill_markers(summary, _pruned_names) return summary @classmethod @@ -2864,9 +3074,22 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb self._previous_summary = _redact_compaction_text(self._previous_summary) summary_budget = self._compute_summary_budget(turns_to_summarize) - content_to_summarize = self._bound_summary_input( - self._serialize_for_summary(turns_to_summarize) - ) + content_to_summarize = self._serialize_for_summary(turns_to_summarize) + # P2 ghost-skill defense (#32106): [SKILL_PRUNED: ...] markers entering + # the summarizer are prompt INPUT only — LLMs routinely paraphrase them + # into vague prose ("some skills were loaded"), which erases the reload + # instruction. Extract the referenced skill names deterministically + # BEFORE the call; ``_reinject_pruned_skill_markers`` restores any + # marker the model dropped AFTER the call. Markers already carried by + # the previous summary must survive iterative rewrites the same way. + # Extraction runs on the UNBOUNDED serialization so a marker that + # falls into the input bound's omitted middle is still re-injected. + _pruned_skill_names = _extract_pruned_skill_names(content_to_summarize) + for _name in _extract_pruned_skill_names(self._previous_summary or ""): + if _name not in _pruned_skill_names: + _pruned_skill_names.append(_name) + del _pruned_skill_names[_MAX_PRUNED_SKILL_MARKERS:] + content_to_summarize = self._bound_summary_input(content_to_summarize) _sanitized_memory_context = sanitize_memory_context(memory_context) _serialized_memory_context = json.dumps( _sanitized_memory_context, @@ -3058,6 +3281,12 @@ Be specific with file paths, commands, line numbers, and results.] ## Critical Context [Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] +{_PRUNED_SKILLS_SECTION_HEADING} +[If any [SKILL_PRUNED: ...reload with skill_view(...)] markers appear in the input, +repeat each one verbatim here — copy the exact text, do NOT paraphrase, summarize, +or describe them. These markers tell the agent which skills must be reloaded before +use. If none appear, omit this section entirely.] + Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. {_temporal_anchoring_rule} Write only the summary body. Do not include any preamble or prefix.""" @@ -3209,6 +3438,9 @@ This compaction should PRIORITISE preserving all information related to the focu # Redact the summary output as well — the summarizer LLM may # ignore prompt instructions and echo back secrets verbatim. summary = _redact_compaction_text(content.strip()) + # P2 ghost-skill defense (#32106): deterministically restore any + # [SKILL_PRUNED: ...] marker the summarizer paraphrased away. + summary = _reinject_pruned_skill_markers(summary, _pruned_skill_names) summary = self._ground_historical_task_snapshot(summary, turns_to_summarize) self._validate_summary_user_provenance(summary, has_user_turn) # Store for iterative updates on next compaction From 28f73d32e97d897cb24b1c0ec6daeb7d7a167d9d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:44:22 -0700 Subject: [PATCH 036/552] =?UTF-8?q?test(compression):=20ghost-skill=20defe?= =?UTF-8?q?nse=20suite=20=E2=80=94=20marker=20round-trip,=20protected=20pr?= =?UTF-8?q?une,=20real-compress=20survival?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21 tests pinning the salvaged #44166 behavior: - marker emit + extractor round trip (patterns adapted from PR #32375 by @LeonSGP43, with credit) - no-duplicate re-injection when the canonical marker survived (the original PR's presence-check defect) - Phase-1 protection for just-loaded / user-referenced skills, and the Pass-4 pressure override that keeps #61932 fixed - deterministic marker survival through a REAL compress() with a mocked aux LLM: drop → re-injected, keep → not duplicated, static-fallback path, iterative re-compression via rehydrated handoff - markers never classify as handoff content (classify_summary_content / _strip_context_summary_handoff_message untouched) - SKILLS_GUIDANCE Skill Safety Rule renders with real newlines --- contributors/emails/lesbetes28@gmail.com | 1 + tests/agent/test_ghost_skill_pruning.py | 364 +++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 contributors/emails/lesbetes28@gmail.com create mode 100644 tests/agent/test_ghost_skill_pruning.py diff --git a/contributors/emails/lesbetes28@gmail.com b/contributors/emails/lesbetes28@gmail.com new file mode 100644 index 00000000000..3790e398235 --- /dev/null +++ b/contributors/emails/lesbetes28@gmail.com @@ -0,0 +1 @@ +dolphin-creator diff --git a/tests/agent/test_ghost_skill_pruning.py b/tests/agent/test_ghost_skill_pruning.py new file mode 100644 index 00000000000..d99a700a591 --- /dev/null +++ b/tests/agent/test_ghost_skill_pruning.py @@ -0,0 +1,364 @@ +"""Ghost-skill defense tests (#32106, salvage of PR #44166). + +When compaction reduces an old ``skill_view`` result to a metadata-only +summary, the model still believes the skill is loaded even though its +instructions are gone. The defense has three layers: + +- P0/P1: the pruned tool-result summary carries a canonical + ``[SKILL_PRUNED: ...]`` marker with the exact reload call, and the + system prompt (SKILLS_GUIDANCE) tells the model how to react to it. +- Phase-1 protection: a skill loaded just before compaction (or actively + referenced in the protected tail) keeps its full body through the + ordinary prune passes. +- P2: markers entering the summarizer are extracted BEFORE the aux LLM + call and deterministically re-injected if the model paraphrased them + away — including on the static fallback path. + +Test patterns for the marker emit checks adapted from PR #32375 +(@LeonSGP43) with credit. +""" + +from unittest.mock import MagicMock, patch + +from agent.context_compressor import ( + SKILL_PRUNED_MARKER_PREFIX, + SUMMARY_PREFIX, + ContextCompressor, + _collect_protected_skill_names, + _extract_pruned_skill_names, + _MAX_PRUNED_SKILL_MARKERS, + _reinject_pruned_skill_markers, + _skill_pruned_marker, + _summarize_tool_result, +) + + +def _make_compressor(**overrides): + kwargs = dict( + model="test/model", + quiet_mode=True, + protect_first_n=1, + protect_last_n=2, + ) + kwargs.update(overrides) + with patch( + "agent.context_compressor.get_model_context_length", return_value=100000 + ): + return ContextCompressor(**kwargs) + + +def _skill_view_pair(call_id, skill_name, size=6000): + return [ + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": "skill_view", + "arguments": f'{{"name":"{skill_name}"}}', + }, + }], + }, + { + "role": "tool", + "tool_call_id": call_id, + "content": f"# {skill_name} instructions\n" + "x" * size, + }, + ] + + +class TestSkillPrunedMarkerEmit: + """Marker emit — patterns adapted from PR #32375 (@LeonSGP43).""" + + def test_skill_view_summary_marks_pruned_content(self): + summary = _summarize_tool_result( + "skill_view", '{"name":"docker-management"}', "x" * 6000 + ) + assert summary.startswith("[skill_view] name=docker-management (6,000 chars)") + assert _skill_pruned_marker("docker-management") in summary + assert "reload with skill_view(name='docker-management')" in summary + + def test_small_skill_view_summary_not_marked(self): + summary = _summarize_tool_result( + "skill_view", '{"name":"docker-management"}', "x" * 1234 + ) + assert summary == "[skill_view] name=docker-management (1,234 chars)" + assert SKILL_PRUNED_MARKER_PREFIX not in summary + + def test_other_skill_tool_summaries_remain_metadata_only(self): + summary = _summarize_tool_result( + "skills_list", '{"name":"docker-management"}', "x" * 6000 + ) + assert summary == "[skills_list] name=docker-management (6,000 chars)" + assert SKILL_PRUNED_MARKER_PREFIX not in summary + + def test_marker_extractor_round_trips_the_emitted_marker(self): + """Emit and check sides share one canonical string. + + The original PR #44166 emitted ``[SKILL_PRUNED:`` but presence- + checked ``[SKILL_PRUNED]`` — re-injection fired even when the + marker had survived. Pin the round trip. + """ + summary = _summarize_tool_result("skill_view", '{"name":"pdf"}', "x" * 6000) + assert _extract_pruned_skill_names(summary) == ["pdf"] + assert _skill_pruned_marker("pdf") in summary + + +class TestReinjectPrunedSkillMarkers: + def test_no_duplicate_when_canonical_marker_survived(self): + marker = _skill_pruned_marker("pdf") + out = _reinject_pruned_skill_markers("summary body\n" + marker, ["pdf"]) + assert out.count(SKILL_PRUNED_MARKER_PREFIX) == 1 + + def test_reinjects_when_marker_paraphrased_away(self): + out = _reinject_pruned_skill_markers( + "The pdf skill was loaded earlier but its content was summarized.", + ["pdf"], + ) + assert _skill_pruned_marker("pdf") in out + assert "## Pruned Skills" in out + + def test_partial_survival_reinjects_only_missing(self): + marker_a = _skill_pruned_marker("alpha") + out = _reinject_pruned_skill_markers("body\n" + marker_a, ["alpha", "beta"]) + assert out.count(_skill_pruned_marker("alpha")) == 1 + assert out.count(_skill_pruned_marker("beta")) == 1 + + def test_empty_name_list_is_a_no_op(self): + assert _reinject_pruned_skill_markers("body", []) == "body" + + def test_marker_block_is_not_classified_as_handoff_content(self): + """Markers must never turn a row into summary/handoff content.""" + marker = _skill_pruned_marker("pdf") + assert ContextCompressor.classify_summary_content(marker) is None + row = "[skill_view] name=pdf (6,000 chars) " + marker + assert ContextCompressor.classify_summary_content(row) is None + # And the strip helper leaves marker-bearing rows untouched. + c = ContextCompressor.__new__(ContextCompressor) + msg = {"role": "user", "content": row} + assert c._strip_context_summary_handoff_message(msg) == msg + + def test_reinjected_summary_still_classifies_standalone(self): + body = _reinject_pruned_skill_markers("## Goal\nwork\n", ["pdf"]) + full = SUMMARY_PREFIX + "\n\n" + body + assert ContextCompressor.classify_summary_content(full) == "standalone" + + +class TestProtectedSkillPrune: + """Phase-1 prune must not demote a just-loaded skill (#32106).""" + + def _filler(self, n, start=0): + out = [] + for i in range(n): + role = "user" if (start + i) % 2 == 0 else "assistant" + out.append({"role": role, "content": f"filler {start + i} " + "y" * 400}) + return out + + def test_old_single_use_skill_is_pruned_with_marker(self): + c = _make_compressor() + msgs = _skill_view_pair("call_s", "old-skill") + self._filler(14) + result, pruned = c._prune_old_tool_results(msgs, protect_tail_count=4) + assert pruned >= 1 + skill_row = result[1] + assert _skill_pruned_marker("old-skill") in skill_row["content"] + + def test_recently_loaded_skill_survives_prune(self): + c = _make_compressor() + # skill loaded within the last 10 messages, but OUTSIDE the + # protected tail count — without the guard it would be demoted. + msgs = ( + self._filler(10) + + _skill_view_pair("call_s", "fresh-skill") + + self._filler(6, start=10) + ) + result, _ = c._prune_old_tool_results(msgs, protect_tail_count=4) + skill_row = result[11] + assert skill_row["content"].startswith("# fresh-skill instructions") + assert SKILL_PRUNED_MARKER_PREFIX not in skill_row["content"] + + def test_skill_named_in_tail_user_message_survives_prune(self): + c = _make_compressor() + msgs = ( + _skill_view_pair("call_s", "steered-skill") + + self._filler(14) + + [{"role": "user", "content": "keep following the steered-skill steps"}] + ) + result, _ = c._prune_old_tool_results(msgs, protect_tail_count=4) + skill_row = result[1] + assert skill_row["content"].startswith("# steered-skill instructions") + + def test_pressure_demotion_overrides_skill_protection(self): + """Pass-4 must still demote protected skill bodies (#61932 guard).""" + c = _make_compressor() + msgs = ( + self._filler(2) + + _skill_view_pair("call_s", "fresh-skill", size=60000) + + [{"role": "user", "content": "active ask"}] + ) + # Tiny token budget → protected region exceeds the soft ceiling and + # the pressure pass must reclaim the skill body despite protection. + result, pruned = c._prune_old_tool_results( + msgs, protect_tail_count=4, protect_tail_tokens=100 + ) + skill_row = result[3] + assert pruned >= 1 + assert _skill_pruned_marker("fresh-skill") in skill_row["content"] + + +class TestMarkerSurvivesRealCompress: + """P2 layer: markers survive a real compress() with a mocked aux LLM.""" + + def _mock_response(self, text): + response = MagicMock() + response.choices = [MagicMock()] + response.choices[0].message.content = text + return response + + def _messages_with_pruned_skill_in_middle(self): + """Transcript whose compressed middle carries a prune marker row.""" + pruned_row_content = ( + "[skill_view] name=pdf (48,201 chars) " + _skill_pruned_marker("pdf") + ) + msgs = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Build the PDF report"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_pdf", + "type": "function", + "function": { + "name": "skill_view", + "arguments": '{"name":"pdf"}', + }, + }], + }, + {"role": "tool", "tool_call_id": "call_pdf", "content": pruned_row_content}, + {"role": "assistant", "content": "Loaded the skill, working."}, + {"role": "user", "content": "continue"}, + {"role": "assistant", "content": "more work " + "z" * 500}, + {"role": "user", "content": "latest ask"}, + {"role": "assistant", "content": "ack"}, + ] + return msgs + + def _summary_text_of(self, result): + for msg in result: + if ContextCompressor.classify_summary_content(msg.get("content")): + return msg["content"] + raise AssertionError(f"no summary message found in {result!r}") + + def test_marker_reinjected_when_summarizer_drops_it(self): + c = _make_compressor(protect_first_n=1, protect_last_n=2) + msgs = self._messages_with_pruned_skill_in_middle() + drop_response = self._mock_response( + "## Goal\nBuild the PDF report.\n\n## Completed Actions\n" + "1. Loaded some skills and worked on the report." + ) + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=7), + patch( + "agent.context_compressor.call_llm", return_value=drop_response + ) as mock_call, + ): + result = c.compress(msgs, force=True) + assert mock_call.called + summary_text = self._summary_text_of(result) + assert _skill_pruned_marker("pdf") in summary_text + # Stored iterative-update state carries the marker too. + assert _skill_pruned_marker("pdf") in c._previous_summary + + def test_marker_not_duplicated_when_summarizer_preserves_it(self): + c = _make_compressor(protect_first_n=1, protect_last_n=2) + msgs = self._messages_with_pruned_skill_in_middle() + keep_response = self._mock_response( + "## Goal\nBuild the PDF report.\n\n## Pruned Skills\n" + + _skill_pruned_marker("pdf") + ) + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=7), + patch("agent.context_compressor.call_llm", return_value=keep_response), + ): + result = c.compress(msgs, force=True) + summary_text = self._summary_text_of(result) + assert summary_text.count(_skill_pruned_marker("pdf")) == 1 + + def test_marker_survives_static_fallback_summary(self): + c = _make_compressor(protect_first_n=1, protect_last_n=2) + msgs = self._messages_with_pruned_skill_in_middle() + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=7), + patch( + "agent.context_compressor.call_llm", + side_effect=RuntimeError("no provider"), + ), + ): + result = c.compress(msgs, force=True) + summary_text = self._summary_text_of(result) + assert _skill_pruned_marker("pdf") in summary_text + assert c._last_summary_fallback_used is True + + def test_marker_survives_iterative_recompression(self): + """Markers in a rehydrated handoff summary survive iterative rewrites. + + On re-compression the previous handoff (carrying the marker) is + rehydrated into ``_previous_summary``; even when the summarizer's + iterative update drops the marker, re-injection restores it. + """ + c = _make_compressor(protect_first_n=1, protect_last_n=2) + prior_handoff = ( + SUMMARY_PREFIX + + "\n\n## Goal\nOld work.\n\n## Pruned Skills\n" + + _skill_pruned_marker("pdf") + ) + msgs = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": prior_handoff}, + ] + [ + {"role": "assistant" if i % 2 == 0 else "user", "content": f"turn {i} " + "q" * 300} + for i in range(8) + ] + drop_response = self._mock_response("## Goal\nNext task in flight.") + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=8), + patch("agent.context_compressor.call_llm", return_value=drop_response), + ): + result = c.compress(msgs, force=True) + summary_text = self._summary_text_of(result) + assert _skill_pruned_marker("pdf") in summary_text + + +class TestReinjectionBoundsAndRedaction: + def test_marker_list_is_capped(self): + names = [f"skill-{i}" for i in range(_MAX_PRUNED_SKILL_MARKERS + 15)] + out = _reinject_pruned_skill_markers("body", names) + assert out.count(SKILL_PRUNED_MARKER_PREFIX) == len(names) + # The cap is applied at the collection sites in _generate_summary / + # _build_static_fallback_summary; the helper itself is mechanical. + + def test_reinjection_block_is_redacted(self, monkeypatch): + import agent.redact as redact_mod + + # force=True redaction must win even when redaction is disabled. + monkeypatch.setattr(redact_mod, "_REDACT_ENABLED", False, raising=False) + secret = "ghp_" + "a1B2" * 6 + out = _reinject_pruned_skill_markers("body", [f"x {secret}"]) + assert secret not in out + + +class TestSkillsGuidanceSafetyRule: + def test_safety_rule_present_with_real_newlines(self): + from agent.prompt_builder import SKILLS_GUIDANCE + + assert "## Skill Safety Rule" in SKILLS_GUIDANCE + assert "[SKILL_PRUNED]" in SKILLS_GUIDANCE + assert "skill_view(name='...')" in SKILLS_GUIDANCE + # The rule list must use REAL newlines — the original PR hunk risked + # literal backslash-n escape text rendering into the system prompt. + assert "\\n" not in SKILLS_GUIDANCE + assert SKILLS_GUIDANCE.count("\n") >= 6 + for rule in ("UNAVAILABLE", "RELOAD", "WAIT", "DEDUP"): + assert rule in SKILLS_GUIDANCE From 69365109b3a134620424b25a67fedb1d0cbaaaef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:48:49 -0700 Subject: [PATCH 037/552] fix(compression): mark raw skill_view bodies summarized away, not only pre-pruned rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _collect_ghosted_skill_names() covers both ghost-skill shapes in the compressed middle window: rows already demoted to a [SKILL_PRUNED: ...] marker AND raw skill_view bodies (> _SKILL_VIEW_PRUNE_MIN_CHARS) that survived Phase-1 inside an earlier protected tail and then aged into the compression window — the summarizer paraphrases those instructions away too. Shared threshold constant between the emit site and the scan. Pinned by a live-probe-shaped test (real compress(), mocked aux LLM). --- agent/context_compressor.py | 81 +++++++++++++++++++------ tests/agent/test_ghost_skill_pruning.py | 24 ++++++++ 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7a7b69fe16d..1eb10489eb3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -329,6 +329,10 @@ _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" # ``[SKILL_PRUNED:`` but presence-checked ``[SKILL_PRUNED]``, making # re-injection fire even when the marker had survived). SKILL_PRUNED_MARKER_PREFIX = "[SKILL_PRUNED:" +# skill_view results at or below this size stay verbatim in pruned +# summaries — small skills are cheap to keep and their loss is unlikely to +# ghost the model. Shared by the emit site and the summarizer-input scan. +_SKILL_VIEW_PRUNE_MIN_CHARS = 5000 # Cap for the deterministic marker re-injection list — keeps a very long # session from growing an unbounded "## Pruned Skills" block in every # iterative summary update. Newest-referenced skills win. @@ -367,6 +371,51 @@ def _extract_pruned_skill_names(text: str) -> list[str]: return names +def _collect_ghosted_skill_names(turns: List[Dict[str, Any]]) -> list[str]: + """Skill names whose instructions are about to be lost in compaction. + + Covers BOTH shapes a compacted middle window can carry: + + - a ``skill_view`` result already demoted by Phase-1 pruning — the + canonical ``[SKILL_PRUNED: ...]`` marker is in the row content; + - a RAW ``skill_view`` body that was never demoted (it sat inside the + protected tail of an earlier prune, then aged into the compression + window). The summarizer will paraphrase the instructions away, which + is exactly the ghost-skill failure — so it needs a marker too. + """ + names: list[str] = [] + + def _add(name: str) -> None: + if name and name not in names: + names.append(name) + + call_id_to_skill: dict[str, str] = {} + for idx, skill in _skill_view_call_sites(turns): + msg = turns[idx] + for tc in msg.get("tool_calls") or []: + tc_fn = tc.get("function", {}) if isinstance(tc, dict) else getattr(tc, "function", None) + tc_name = tc_fn.get("name", "") if isinstance(tc_fn, dict) else getattr(tc_fn, "name", "") + if tc_name != "skill_view": + continue + cid = tc.get("id", "") if isinstance(tc, dict) else (getattr(tc, "id", "") or "") + if cid: + call_id_to_skill[cid] = skill + for msg in turns: + content = msg.get("content") + text = content if isinstance(content, str) else _content_text_for_contains(content) + for name in _extract_pruned_skill_names(text): + _add(name) + if ( + msg.get("role") == "tool" + and isinstance(content, str) + and len(content) > _SKILL_VIEW_PRUNE_MIN_CHARS + ): + skill = call_id_to_skill.get(str(msg.get("tool_call_id") or "")) + if skill: + _add(skill) + return names + + _PRUNED_SKILLS_SECTION_HEADING = "## Pruned Skills" @@ -1059,7 +1108,7 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten if tool_name == "skill_view": name = args.get("name", "?") - if content_len > 5000: + if content_len > _SKILL_VIEW_PRUNE_MIN_CHARS: # Ghost-skill defense (#32106): a metadata-only summary makes the # model believe the skill is still loaded. The canonical marker # tells it the instructions are gone AND how to get them back. @@ -2950,16 +2999,10 @@ Continue from the most recent unfulfilled user ask and protected tail messages. Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" # Ghost-skill defense (#32106): the fallback's per-turn truncation # (``_FALLBACK_TURN_MAX_CHARS``) routinely cuts [SKILL_PRUNED: ...] - # markers out of the compacted turns. Re-derive them from the raw - # turn contents and re-inject deterministically, exactly like the - # LLM-summary path. - _pruned_names: list[str] = [] - for _turn in turns_to_summarize: - for _name in _extract_pruned_skill_names( - _content_text_for_contains(_turn.get("content")) - ): - if _name not in _pruned_names: - _pruned_names.append(_name) + # markers out of the compacted turns. Re-derive the ghosted skills + # from the raw turn contents and re-inject deterministically, + # exactly like the LLM-summary path. + _pruned_names = _collect_ghosted_skill_names(turns_to_summarize) del _pruned_names[_MAX_PRUNED_SKILL_MARKERS:] summary = self._with_summary_prefix(_redact_compaction_text(body.strip())) if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS: @@ -3078,13 +3121,15 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb # P2 ghost-skill defense (#32106): [SKILL_PRUNED: ...] markers entering # the summarizer are prompt INPUT only — LLMs routinely paraphrase them # into vague prose ("some skills were loaded"), which erases the reload - # instruction. Extract the referenced skill names deterministically - # BEFORE the call; ``_reinject_pruned_skill_markers`` restores any - # marker the model dropped AFTER the call. Markers already carried by - # the previous summary must survive iterative rewrites the same way. - # Extraction runs on the UNBOUNDED serialization so a marker that - # falls into the input bound's omitted middle is still re-injected. - _pruned_skill_names = _extract_pruned_skill_names(content_to_summarize) + # instruction. Collect the ghosted skills deterministically BEFORE the + # call (both already-pruned marker rows AND raw skill_view bodies whose + # instructions are about to be summarized away); + # ``_reinject_pruned_skill_markers`` restores any marker the model + # dropped AFTER the call. Markers already carried by the previous + # summary must survive iterative rewrites the same way. Collection + # walks the turn LIST, so the serialized input bound below cannot + # hide a marker in its omitted middle. + _pruned_skill_names = _collect_ghosted_skill_names(turns_to_summarize) for _name in _extract_pruned_skill_names(self._previous_summary or ""): if _name not in _pruned_skill_names: _pruned_skill_names.append(_name) diff --git a/tests/agent/test_ghost_skill_pruning.py b/tests/agent/test_ghost_skill_pruning.py index d99a700a591..a0594d84f52 100644 --- a/tests/agent/test_ghost_skill_pruning.py +++ b/tests/agent/test_ghost_skill_pruning.py @@ -301,6 +301,30 @@ class TestMarkerSurvivesRealCompress: assert _skill_pruned_marker("pdf") in summary_text assert c._last_summary_fallback_used is True + def test_raw_skill_body_in_compressed_middle_gets_marker(self): + """A never-demoted skill_view body summarized away still ghosts. + + The skill body can survive Phase-1 (protected tail of an earlier + prune) and then age into the compression window as RAW content. + The summarizer paraphrases it away — the P2 layer must emit the + marker for it as well, not only for already-pruned rows. + """ + c = _make_compressor(protect_first_n=1, protect_last_n=2) + skill_body = "# pdf skill\n" + ("Detailed instructions line.\n" * 400) + msgs = self._messages_with_pruned_skill_in_middle() + msgs[3] = {"role": "tool", "tool_call_id": "call_pdf", "content": skill_body} + drop_response = self._mock_response( + "## Goal\nBuild the PDF report.\n\n## Completed Actions\n" + "1. Loaded some skills and worked on the report." + ) + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=7), + patch("agent.context_compressor.call_llm", return_value=drop_response), + ): + result = c.compress(msgs, force=True) + summary_text = self._summary_text_of(result) + assert _skill_pruned_marker("pdf") in summary_text + def test_marker_survives_iterative_recompression(self): """Markers in a rehydrated handoff summary survive iterative rewrites. From a9c868225e32c1e67dd7fef8aa0305c775eff373 Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 13 Jul 2026 22:20:47 +0800 Subject: [PATCH 038/552] feat(compress): preserve recent N user messages during context compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _ensure_last_n_user_messages_in_tail to guarantee the last N user messages survive compression in the uncompressed tail, with surrounding assistant/tool context preserved. - Add min_tail_user_messages parameter (default 3) to ContextCompressor - New _ensure_last_n_user_messages_in_tail method generalizes single-user protection - Skip context-summary handoff banners when counting user messages - User messages are clean boundaries — skip _align_boundary_backward - Wire through cli.py, agent_init.py, and gateway cache busting keys Config: compression: min_tail_user_messages: 3 Co-Authored-By: Claude --- agent/agent_init.py | 2 + agent/context_compressor.py | 67 ++++++++ cli.py | 1 + gateway/run.py | 1 + tests/agent/test_context_compressor.py | 223 +++++++++++++++++++++++++ 5 files changed, 294 insertions(+) diff --git a/agent/agent_init.py b/agent/agent_init.py index e239c48cfbd..6271453cd1e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1810,6 +1810,7 @@ def init_agent( compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) + compression_min_tail_users = int(_compression_cfg.get("min_tail_user_messages", 3)) # Cap on compression retry rounds before a turn gives up with "max # compression attempts reached" (compression.max_attempts). Hardcoding 3 # strands sessions that legitimately need more rounds — e.g. a restart @@ -2348,6 +2349,7 @@ def init_agent( proactive_prune_tokens=compression_proactive_prune_tokens, proactive_prune_min_result_chars=compression_proactive_prune_min_chars, proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim, + min_tail_user_messages=compression_min_tail_users, ) _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) if callable(_bind_session_state): diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 1eb10489eb3..8166398175e 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1873,6 +1873,7 @@ class ContextCompressor(ContextEngine): proactive_prune_tokens: int = 0, proactive_prune_min_result_chars: int = 8000, proactive_prune_min_reclaim_tokens: int = 4096, + min_tail_user_messages: int = 1, ): self.model = model self.base_url = base_url @@ -1927,6 +1928,7 @@ class ContextCompressor(ContextEngine): self.proactive_prune_min_reclaim_tokens = max( 0, int(proactive_prune_min_reclaim_tokens or 0) ) + self.min_tail_user_messages = min_tail_user_messages self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) self.quiet_mode = quiet_mode # Output-token reservation: the provider carves max_tokens out of the @@ -4546,6 +4548,60 @@ This compaction should PRIORITISE preserving all information related to the focu return max(pair_end, head_end + 1) return adjusted + def _ensure_last_n_user_messages_in_tail( + self, + messages: List[Dict[str, Any]], + cut_idx: int, + head_end: int, + n: int, + ) -> int: + """Guarantee the last N user messages are in the protected tail. + + Generalizes ``_ensure_last_user_message_in_tail`` to preserve an + arbitrary number of recent user messages. This prevents the token- + budget-based tail cut from consuming recent conversation turns + when large tool outputs fill the budget (COMPRESS-01). + + When *n* <= 1, delegates directly to the existing single-message + method for byte-identical regression safety (COMPRESS-08). + + If the conversation has fewer than *n* user messages, the earliest + available user message is used without error (COMPRESS-07). + + A user message is already a clean boundary — there is no + tool_call/result group that spans across it, so + ``_align_boundary_backward`` is intentionally NOT called. + Calling it can pull the cut past the user message into the + preceding assistant(tool_calls)→tool group and split it (#22566). + """ + if n <= 1: + return self._ensure_last_user_message_in_tail(messages, cut_idx, head_end) + + # Collect real user message indices walking backward from end. + # Skip context-summary handoff banners — they are internal + # continuity state, not real user turns. + user_indices = [] + for i in range(len(messages) - 1, head_end - 1, -1): + msg = messages[i] + if msg.get("role") == "user" and not self._is_context_summary_content( + msg.get("content") + ): + user_indices.append(i) + + if len(user_indices) == 0: + return cut_idx + + if len(user_indices) < n: + target_idx = user_indices[-1] + else: + target_idx = user_indices[n - 1] + + if target_idx >= cut_idx: + return cut_idx + + cut_idx = target_idx + return max(cut_idx, head_end + 1) + def _find_turn_pair_end( self, messages: List[Dict[str, Any]], @@ -4675,6 +4731,17 @@ This compaction should PRIORITISE preserving all information related to the focu # monotonic — the tail can only grow, never shrink. cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end) + # Extend to the last N actionable user messages when configured + # (compression.min_tail_user_messages). This prevents the + # token-budget tail from consuming recent turns when large tool + # outputs fill the budget. The anchor only walks ``cut_idx`` + # backward (monotonic — the tail can only grow, never shrink), and + # a user message is a clean boundary, so the forward re-alignment + # below remains a no-op for the anchored index. + cut_idx = self._ensure_last_n_user_messages_in_tail( + messages, cut_idx, head_end, self.min_tail_user_messages, + ) + # The floor guarantees forward progress — compression must always claim # at least one message or the caller's compress_start >= compress_end # guard turns the pass into a no-op that re-runs forever (the same loop diff --git a/cli.py b/cli.py index 7d86deb1d7f..d0515cb1072 100644 --- a/cli.py +++ b/cli.py @@ -468,6 +468,7 @@ def load_cli_config() -> Dict[str, Any]: "compression": { "enabled": True, # Auto-compress when approaching context limit "threshold": 0.50, # Compress at 50% of model's context limit + "min_tail_user_messages": 3, # Min recent user messages to preserve in tail after compression }, "agent": { "max_turns": 90, # Default max tool-calling iterations (shared with subagents) diff --git a/gateway/run.py b/gateway/run.py index 98f6b86da78..1a3be8a897a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18125,6 +18125,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ("compression", "proactive_prune_tokens"), ("compression", "proactive_prune_min_result_chars"), ("compression", "proactive_prune_min_reclaim_tokens"), + ("compression", "min_tail_user_messages"), ("agent", "disabled_toolsets"), ("memory", "provider"), ("checkpoints", "enabled"), diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 926678108fe..26446e266ab 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -4076,3 +4076,226 @@ class TestSummaryPromptBounding: # handoff prefix either. marker_only = bounded[bounded.index("\n\n...[summary input truncated"):] assert ContextCompressor.classify_summary_content(marker_only.lstrip()) is None + + +class TestMinTailUserMessages: + """COMPRESS-01,02,07,08: N-user-message tail protection. + + Tests the ``_ensure_last_n_user_messages_in_tail`` method and its + integration through ``_find_tail_cut_by_tokens``. + """ + + def test_n3_preserves_last_3_user_messages(self): + """COMPRESS-01: _find_tail_cut_by_tokens with min_tail_user_messages=3 + guarantees the last 3 user-role messages are in the tail.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + ) + c.tail_token_budget = 200 + messages = [ + {"role": "user", "content": "head msg 1"}, + {"role": "assistant", "content": "head reply 1"}, + {"role": "user", "content": "middle 1"}, + {"role": "assistant", "content": "middle 1 reply"}, + {"role": "user", "content": "middle 2"}, + {"role": "assistant", "content": "middle 2 reply"}, + {"role": "user", "content": "recent 3"}, + {"role": "assistant", "content": "recent 3 reply"}, + {"role": "user", "content": "recent 2"}, + {"role": "assistant", "content": "recent 2 reply"}, + {"role": "user", "content": "recent 1"}, + {"role": "assistant", "content": "recent 1 reply"}, + ] + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail_messages = messages[cut:] + tail_user_contents = [m["content"] for m in tail_messages if m["role"] == "user"] + assert len(tail_user_contents) >= 3, ( + f"Expected >= 3 user messages in tail, got {len(tail_user_contents)}" + ) + assert "recent 1" in tail_user_contents + assert "recent 2" in tail_user_contents + assert "recent 3" in tail_user_contents + assert cut >= head_end + 1 + + def test_n3_tool_group_integrity(self): + """COMPRESS-02: When the 3rd-to-last user message is preceded by + assistant(tool_calls) + tool results, the boundary is set at the + user message (a clean boundary). The preceding tool group stays + together — it is either entirely in the compressed region or + entirely in the tail, never split across the boundary.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + ) + c.tail_token_budget = 200 + messages = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": None, + "tool_calls": [{"function": {"name": "read_file", "arguments": "{}"}}]}, + {"role": "tool", "content": "result content", + "tool_call_id": "call_1"}, + {"role": "user", "content": "user 3rd last"}, + {"role": "assistant", "content": "reply 3rd last"}, + {"role": "user", "content": "user 2nd last"}, + {"role": "assistant", "content": "reply 2nd last"}, + {"role": "user", "content": "user last"}, + {"role": "assistant", "content": "reply last"}, + ] + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + compressed = messages[:cut] + tool_positions = [ + i for i, m in enumerate(compressed) + if m.get("role") in ("assistant", "tool") + and (m.get("tool_calls") or m.get("tool_call_id")) + ] + if len(tool_positions) >= 2: + assert tool_positions[-1] - tool_positions[0] == len(tool_positions) - 1, ( + "Tool group must stay contiguous across the boundary" + ) + tail_messages = messages[cut:] + tail_users = [m["content"] for m in tail_messages if m["role"] == "user"] + assert "user 3rd last" in tail_users + assert "user 2nd last" in tail_users + assert "user last" in tail_users + assert cut >= head_end + 1 + + def test_n1_regression_safety(self): + """COMPRESS-08: N=1 produces identical tail positioning to the existing + _ensure_last_user_message_in_tail method.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=2, + quiet_mode=True, + ) + c.min_tail_user_messages = 1 + messages = [ + {"role": "user", "content": "head 1"}, + {"role": "assistant", "content": "head reply 1"}, + {"role": "user", "content": "middle user"}, + {"role": "assistant", "content": "middle reply"}, + {"role": "user", "content": "last user"}, + {"role": "assistant", "content": "last reply"}, + ] + head_end = c.protect_first_n + cut1 = c._find_tail_cut_by_tokens(messages, head_end) + tail1 = messages[cut1:] + # Verify the last user message is in the tail + tail_users = [m["content"] for m in tail1 if m["role"] == "user"] + assert "last user" in tail_users + assert cut1 >= head_end + 1 + + def test_fewer_than_n_user_messages(self): + """COMPRESS-07: When the conversation has fewer than N user messages, + the earliest available user message is used without error.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + ) + messages = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply 1"}, + {"role": "user", "content": "second"}, + {"role": "assistant", "content": "reply 2"}, + ] + # Only 2 user messages, but N=5 — should use earliest found + head_end = c.protect_first_n + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=3, head_end=head_end, n=5 + ) + # Should not crash, boundary should be before the first user message + # (index 0) or at most cut_idx + assert result <= 3 + + def test_nth_user_already_in_tail_no_reposition(self): + """When the Nth user message is already in the tail, cut_idx is unchanged.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + ) + messages = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply 1"}, + {"role": "user", "content": "second"}, + {"role": "assistant", "content": "reply 2"}, + {"role": "user", "content": "third"}, + {"role": "assistant", "content": "reply 3"}, + ] + head_end = c.protect_first_n + # cut_idx at 2 means all users from index 2 onward are in tail + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=2, head_end=head_end, n=3 + ) + assert result == 2 # unchanged + + def test_n5_preserves_last_5_user_messages(self): + """COMPRESS-06: min_tail_user_messages=5 protects last 5 user messages.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + ) + c.min_tail_user_messages = 5 + c.tail_token_budget = 500 + # protect_first_n=1 → head_end=1, so index 0 is head. + # u1..u5 must all be at indices >= head_end+1 (=2) to survive the clamp. + messages = [ + {"role": "user", "content": "head 1"}, # 0 (head) + {"role": "assistant", "content": "head reply"}, # 1 (head_end boundary) + {"role": "user", "content": "u1"}, # 2 + {"role": "assistant", "content": "a1"}, # 3 + {"role": "user", "content": "u2"}, # 4 + {"role": "assistant", "content": "a2"}, # 5 + {"role": "user", "content": "u3"}, # 6 + {"role": "assistant", "content": "a3"}, # 7 + {"role": "user", "content": "u4"}, # 8 + {"role": "assistant", "content": "a4"}, # 9 + {"role": "user", "content": "u5"}, # 10 + {"role": "assistant", "content": "a5"}, # 11 + ] + head_end = c.protect_first_n # = 1 + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail_users = [m["content"] for m in messages[cut:] if m["role"] == "user"] + assert len(tail_users) >= 5, f"Expected >=5 users in tail, got {len(tail_users)}" + for u in ("u1", "u2", "u3", "u4", "u5"): + assert u in tail_users + assert cut >= head_end + 1 + + def test_no_user_messages_beyond_head(self): + """When there are no user messages beyond head_end, cut_idx is unchanged.""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=5, + quiet_mode=True, + ) + messages = [ + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "reply 1"}, + {"role": "user", "content": "msg 2"}, + {"role": "assistant", "content": "reply 2"}, + ] + head_end = c.protect_first_n # = 5 > len(messages) + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=2, head_end=head_end, n=3 + ) + assert result == 2 # unchanged From d43cc2ca80c9f6e332eccd67dc8724224f64ee3d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:55:20 -0700 Subject: [PATCH 039/552] fix(compress): gate N-user tail guarantee to actionable turns, behavior-preserving default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up fixes on top of the salvaged #22566 mechanism: - N-collector now counts only REAL actionable user turns via _is_actionable_user_turn + _is_synthetic_compression_user_turn — the same filter pair _find_last_user_message_idx uses post-#69291. The contributor's bare role=='user' + _is_context_summary_content check let blank platform echoes and continuation/todo rows consume N slots, silently degrading the guarantee. - Default flipped 3 -> 1 (behavior-preserving): a default of 3 was measured to change the tail cut on transcripts whose budget covers only the last turn. min_tail_user_messages=1 delegates to the existing single-user anchor; N>1 is opt-in, and the call site is gated so the default path is byte-identical to main. - Hardened config parse in agent_init (bool rejected, fractional floats rejected, floor 1) matching the max_attempts parser shape. - Wired the recurring external-PR config gaps: hermes_cli/config.py DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py). - Regression tests: blank echoes / synthetic rows don't count toward N; tool-call/result pairs never split by the N-boundary (no-orphan both directions); N-guarantee wins over tail_token_budget and the _MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default parity pin; DEFAULT_CONFIG pin. --- agent/agent_init.py | 23 +- agent/context_compressor.py | 40 ++-- cli-config.yaml.example | 9 + cli.py | 2 +- contributors/emails/jerry@hermes.local | 1 + hermes_cli/config.py | 6 + tests/agent/test_context_compressor.py | 214 ++++++++++++++++++ .../context-compression-and-caching.md | 2 + 8 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 contributors/emails/jerry@hermes.local diff --git a/agent/agent_init.py b/agent/agent_init.py index 6271453cd1e..26a4c787617 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1810,7 +1810,28 @@ def init_agent( compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) - compression_min_tail_users = int(_compression_cfg.get("min_tail_user_messages", 3)) + # Minimum REAL (actionable) user messages guaranteed to survive in the + # uncompressed tail (compression.min_tail_user_messages). Default 1 + # preserves current behavior exactly — the existing single-user tail + # anchor. Values > 1 extend the guarantee to the last N actionable + # user turns. Booleans rejected (bool subclasses int), non-int-like + # values fall back to 1, floor at 1. + _raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1) + if isinstance(_raw_min_tail_users, bool): + compression_min_tail_users = 1 + elif isinstance(_raw_min_tail_users, int): + compression_min_tail_users = _raw_min_tail_users + elif isinstance(_raw_min_tail_users, float): + compression_min_tail_users = ( + int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1 + ) + else: + try: + compression_min_tail_users = int(str(_raw_min_tail_users).strip()) + except (TypeError, ValueError): + compression_min_tail_users = 1 + if compression_min_tail_users < 1: + compression_min_tail_users = 1 # Cap on compression retry rounds before a turn gives up with "max # compression attempts reached" (compression.max_attempts). Hardcoding 3 # strands sessions that legitimately need more rounds — e.g. a restart diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8166398175e..249678f5745 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4555,18 +4555,25 @@ This compaction should PRIORITISE preserving all information related to the focu head_end: int, n: int, ) -> int: - """Guarantee the last N user messages are in the protected tail. + """Guarantee the last N actionable user messages are in the protected tail. Generalizes ``_ensure_last_user_message_in_tail`` to preserve an arbitrary number of recent user messages. This prevents the token- budget-based tail cut from consuming recent conversation turns - when large tool outputs fill the budget (COMPRESS-01). + when large tool outputs fill the budget. When *n* <= 1, delegates directly to the existing single-message - method for byte-identical regression safety (COMPRESS-08). + method for byte-identical regression safety. If the conversation has fewer than *n* user messages, the earliest - available user message is used without error (COMPRESS-07). + available user message is used without error. + + Only REAL actionable user turns count toward N — the collector uses + the same ``_is_actionable_user_turn`` / + ``_is_synthetic_compression_user_turn`` pair as + ``_find_last_user_message_idx``, so blank platform echoes, compaction + handoffs, continuation markers, and todo-snapshot rows never consume + a slot (#69291 bug class). A user message is already a clean boundary — there is no tool_call/result group that spans across it, so @@ -4578,13 +4585,15 @@ This compaction should PRIORITISE preserving all information related to the focu return self._ensure_last_user_message_in_tail(messages, cut_idx, head_end) # Collect real user message indices walking backward from end. - # Skip context-summary handoff banners — they are internal - # continuity state, not real user turns. + # Mirror _find_last_user_message_idx's filters: compaction handoffs, + # blank platform echoes, and synthetic continuation/todo rows are + # continuity artifacts, not real user turns. user_indices = [] for i in range(len(messages) - 1, head_end - 1, -1): msg = messages[i] - if msg.get("role") == "user" and not self._is_context_summary_content( - msg.get("content") + if ( + self._is_actionable_user_turn(msg) + and not self._is_synthetic_compression_user_turn(msg) ): user_indices.append(i) @@ -4732,15 +4741,20 @@ This compaction should PRIORITISE preserving all information related to the focu cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end) # Extend to the last N actionable user messages when configured - # (compression.min_tail_user_messages). This prevents the + # (compression.min_tail_user_messages > 1). This prevents the # token-budget tail from consuming recent turns when large tool # outputs fill the budget. The anchor only walks ``cut_idx`` # backward (monotonic — the tail can only grow, never shrink), and # a user message is a clean boundary, so the forward re-alignment - # below remains a no-op for the anchored index. - cut_idx = self._ensure_last_n_user_messages_in_tail( - messages, cut_idx, head_end, self.min_tail_user_messages, - ) + # below remains a no-op for the anchored index. Gated at the call + # site so the default (1) path is byte-identical to the historical + # single-anchor pipeline — the single-user anchor already ran above, + # and re-invoking it here could re-trigger the causal-coupling + # forward push (#22523) after the assistant anchor adjusted the cut. + if self.min_tail_user_messages > 1: + cut_idx = self._ensure_last_n_user_messages_in_tail( + messages, cut_idx, head_end, self.min_tail_user_messages, + ) # The floor guarantees forward progress — compression must always claim # at least one message or the caller's compress_start >= compress_end diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 847bc98693f..4374d788522 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -448,6 +448,15 @@ compression: # compression of older turns. protect_last_n: 20 + # Minimum number of REAL (actionable) user messages guaranteed to survive in + # the uncompressed tail (default: 1 = the existing single last-user anchor, + # behavior-preserving). Raise to e.g. 3 to keep the last 3 real user turns + # verbatim even when bulky tool outputs fill the tail token budget — blank + # platform echoes, compaction handoffs, and synthetic continuation rows never + # count toward N. The tail can exceed the token budget when this pulls the + # cut back; the guarantee wins over the budget by design. + min_tail_user_messages: 1 + # Compression retry rounds before a turn gives up with "max compression # attempts reached" (default: 3, same as the previous hardcoded value). # Raise (e.g. 6) for tool-schema-heavy sessions where 3 rounds cannot bring diff --git a/cli.py b/cli.py index d0515cb1072..9d3b6fc4cf5 100644 --- a/cli.py +++ b/cli.py @@ -468,7 +468,7 @@ def load_cli_config() -> Dict[str, Any]: "compression": { "enabled": True, # Auto-compress when approaching context limit "threshold": 0.50, # Compress at 50% of model's context limit - "min_tail_user_messages": 3, # Min recent user messages to preserve in tail after compression + "min_tail_user_messages": 1, # Real user messages guaranteed in the tail (1 = existing single anchor) }, "agent": { "max_turns": 90, # Default max tool-calling iterations (shared with subagents) diff --git a/contributors/emails/jerry@hermes.local b/contributors/emails/jerry@hermes.local new file mode 100644 index 00000000000..fdb939b79e8 --- /dev/null +++ b/contributors/emails/jerry@hermes.local @@ -0,0 +1 @@ +zhangyang-crazy-one diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 640c184f0cc..765891e2db3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1409,6 +1409,12 @@ DEFAULT_CONFIG = { # the model's context length at apply-time. "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed + "min_tail_user_messages": 1, # REAL (actionable) user messages guaranteed to + # survive in the uncompressed tail. 1 = existing + # single last-user anchor (default, behavior- + # preserving); raise to e.g. 3 to keep the last + # 3 real user turns verbatim when bulky tool + # outputs fill the tail token budget. "max_attempts": 3, # compression retry rounds before a turn gives up # with "max compression attempts reached". Raise # (e.g. 6) for tool-schema-heavy sessions where 3 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 26446e266ab..6364441f2a2 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -4094,6 +4094,7 @@ class TestMinTailUserMessages: threshold_percent=0.50, protect_first_n=2, quiet_mode=True, + min_tail_user_messages=3, ) c.tail_token_budget = 200 messages = [ @@ -4134,6 +4135,7 @@ class TestMinTailUserMessages: threshold_percent=0.50, protect_first_n=1, quiet_mode=True, + min_tail_user_messages=3, ) c.tail_token_budget = 200 messages = [ @@ -4299,3 +4301,215 @@ class TestMinTailUserMessages: messages, cut_idx=2, head_end=head_end, n=3 ) assert result == 2 # unchanged + + def test_default_is_behavior_preserving(self): + """Default min_tail_user_messages=1 leaves the tail cut byte-identical + to the historical single-anchor pipeline. + + A default of 3 was measured to CHANGE the cut on transcripts whose + tail budget covers only the last turn, so the default is gated to 1 + (= the existing single last-user anchor) and N>1 is opt-in. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c_default = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + ) + c_explicit = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + min_tail_user_messages=1, + ) + assert c_default.min_tail_user_messages == 1 + c_default.tail_token_budget = 200 + c_explicit.tail_token_budget = 200 + messages = [ + {"role": "user", "content": "head msg"}, + {"role": "assistant", "content": "head reply"}, + ] + for i in range(3): + messages.append({"role": "user", "content": f"user {i}"}) + messages.append({"role": "assistant", "content": "X" * 4000}) + messages.append({"role": "user", "content": "final user"}) + messages.append({"role": "assistant", "content": "final reply"}) + head_end = c_default.protect_first_n + assert ( + c_default._find_tail_cut_by_tokens(messages, head_end) + == c_explicit._find_tail_cut_by_tokens(messages, head_end) + ) + + def test_blank_echo_does_not_count_toward_n(self): + """A blank platform echo (empty user row) must not consume one of the + N slots — otherwise the guarantee silently degrades to N-1 real turns + (the #69291 bug class the single anchor already fixed).""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + messages = [ + {"role": "user", "content": "head"}, # 0 (head) + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real oldest"}, # 2 <- 3rd real user + {"role": "assistant", "content": "reply oldest"}, # 3 + {"role": "user", "content": "real middle"}, # 4 + {"role": "assistant", "content": "reply middle"}, # 5 + {"role": "user", "content": ""}, # 6 blank echo + {"role": "assistant", "content": "reply to echo"}, # 7 + {"role": "user", "content": " "}, # 8 whitespace echo + {"role": "assistant", "content": "another reply"}, # 9 + {"role": "user", "content": "real latest"}, # 10 + {"role": "assistant", "content": "final reply"}, # 11 + ] + head_end = c.protect_first_n # = 1 + # cut_idx=10 → only "real latest" in tail; N=3 must walk back to + # index 2 ("real oldest"), NOT stop at a blank echo (6/8). + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=10, head_end=head_end, n=3 + ) + assert result == 2, ( + f"3rd real user is at index 2, got cut {result} — blank echoes " + "must not count toward N" + ) + tail_users = [ + m["content"] for m in messages[result:] + if m["role"] == "user" and m["content"].strip() + ] + assert {"real oldest", "real middle", "real latest"} <= set(tail_users) + + def test_synthetic_compression_rows_do_not_count_toward_n(self): + """Compaction handoff banners and continuation markers carry + role="user" after SessionDB projection but are continuity artifacts — + they must not consume N slots.""" + from agent.context_compressor import ( + COMPRESSION_CONTINUATION_USER_CONTENT, + ) + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=2, + ) + messages = [ + {"role": "user", "content": "head"}, # 0 (head) + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real second"}, # 2 + {"role": "assistant", "content": "reply second"}, # 3 + {"role": "user", "content": SUMMARY_PREFIX + " old summary"}, # 4 handoff + {"role": "assistant", "content": "ack"}, # 5 + {"role": "user", "content": COMPRESSION_CONTINUATION_USER_CONTENT}, # 6 marker + {"role": "assistant", "content": "ack 2"}, # 7 + {"role": "user", "content": "real latest"}, # 8 + {"role": "assistant", "content": "final reply"}, # 9 + ] + head_end = c.protect_first_n + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=8, head_end=head_end, n=2 + ) + assert result == 2, ( + f"2nd real user is at index 2, got cut {result} — synthetic " + "compression rows must not count toward N" + ) + + def test_n_boundary_never_orphans_tool_results(self): + """Integration: with N=3 the full tail-cut pipeline must never place + a tool result in the tail whose parent assistant(tool_calls) was + summarized away, or vice versa (no-orphan in BOTH directions).""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + c.tail_token_budget = 100 + messages = [ + {"role": "user", "content": "head"}, # 0 + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real 3"}, # 2 + {"role": "assistant", "content": None, + "tool_calls": [{"id": "call_a", "function": {"name": "t", "arguments": "{}"}}]}, # 3 + {"role": "tool", "content": "R" * 2000, "tool_call_id": "call_a"}, # 4 + {"role": "assistant", "content": "reply 3"}, # 5 + {"role": "user", "content": "real 2"}, # 6 + {"role": "assistant", "content": None, + "tool_calls": [{"id": "call_b", "function": {"name": "t", "arguments": "{}"}}]}, # 7 + {"role": "tool", "content": "S" * 2000, "tool_call_id": "call_b"}, # 8 + {"role": "assistant", "content": "reply 2"}, # 9 + {"role": "user", "content": "real 1"}, # 10 + {"role": "assistant", "content": "reply 1"}, # 11 + ] + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail = messages[cut:] + tail_call_ids = { + tc.get("id") + for m in tail if m.get("role") == "assistant" + for tc in (m.get("tool_calls") or []) + } + tail_result_ids = { + m.get("tool_call_id") for m in tail if m.get("role") == "tool" + } + assert tail_call_ids == tail_result_ids, ( + f"tool pair split across N-boundary: calls={tail_call_ids} " + f"results={tail_result_ids}" + ) + tail_users = [m["content"] for m in tail if m["role"] == "user"] + assert {"real 1", "real 2", "real 3"} <= set(tail_users) + + def test_n_guarantee_wins_over_tail_token_budget_and_floor(self): + """Interaction contract: the N-user guarantee WINS over both + tail_token_budget and _MAX_TAIL_MESSAGE_FLOOR. + + The budget walk (and its bounded message floor) computes the initial + cut; the N-anchor then only ever pulls the cut BACKWARD (tail can + grow, never shrink), exactly like the existing single-user and + assistant anchors. So a tiny budget cannot roll real users 2..N into + the summary, and the floor remains a lower bound, not a cap, on the + anchored tail. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + # Budget covers roughly one bulky turn — without the N-anchor the + # cut lands after users 2..3. + c.tail_token_budget = 150 + messages = [{"role": "user", "content": "head"}, + {"role": "assistant", "content": "head reply"}] + for i in (3, 2, 1): + messages.append({"role": "user", "content": f"real {i}"}) + messages.append({"role": "assistant", "content": "B" * 6000}) + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail = messages[cut:] + tail_users = [m["content"] for m in tail if m["role"] == "user"] + assert {"real 1", "real 2", "real 3"} <= set(tail_users), ( + f"N-guarantee must win over the token budget; tail users: {tail_users}" + ) + # The anchored tail legitimately exceeds the budget (and the 8-message + # floor is a minimum, not a cap): the guarantee is the stronger + # invariant by design. + from agent.context_compressor import _estimate_msg_budget_tokens + accumulated = sum(_estimate_msg_budget_tokens(m) for m in tail) + assert accumulated > c.tail_token_budget + + def test_default_config_ships_behavior_preserving_value(self): + """DEFAULT_CONFIG ships min_tail_user_messages=1 so an unset key is + exactly the pre-feature single-anchor behavior.""" + from hermes_cli.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["compression"]["min_tail_user_messages"] == 1 diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index e3eb2c4d8f2..c27f371c396 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -87,6 +87,7 @@ compression: # "claude-sonnet": 0.35 # overrides" below. target_ratio: 0.20 # How much of threshold to keep as tail (default: 0.20) protect_last_n: 20 # Minimum protected tail messages (default: 20) + min_tail_user_messages: 1 # Real user messages guaranteed in the tail (default: 1) codex_gpt55_autoraise: true # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true) codex_gpt55_autoraise_notice: true # Show the one-time autoraise notice (default: true) codex_app_server_auto: native # native|hermes|off for Codex app-server thread compaction @@ -107,6 +108,7 @@ auxiliary: | `model_thresholds` | `{}` | map | Per-model overrides of `threshold`. Keys are substring-matched against the model name (longest match wins). The small-context floor still applies on top (see below) | | `target_ratio` | `0.20` | 0.10-0.80 | Controls tail protection token budget: `threshold_tokens × target_ratio` | | `protect_last_n` | `20` | ≥1 | Minimum number of recent messages always preserved | +| `min_tail_user_messages` | `1` | ≥1 | Minimum number of REAL (actionable) user messages guaranteed to survive in the uncompressed tail. `1` = the existing single last-user anchor (behavior-preserving default). Raise to e.g. `3` to keep the last 3 real user turns verbatim even when bulky tool outputs fill the tail token budget. Blank platform echoes, compaction handoffs, and synthetic continuation rows never count toward N. The guarantee wins over the tail token budget — the tail may exceed the budget when the anchor pulls the cut back | | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | | `idle_compact_after_seconds` | `0` | ≥0 seconds | Opt-in: compact up front when a session resumes after this many seconds idle (0 = disabled). Skips when context ≤ threshold × target_ratio; honors cooldown/anti-thrash/lock guards | | `codex_gpt55_autoraise` | `true` | bool | Raise the trigger to 85% for gpt-5.5 on the ChatGPT Codex OAuth route (see below). Set `false` to keep the global `threshold` | From d0d116be2e038a57cf5ec34979c767f1f8d1fbc4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:10:02 -0700 Subject: [PATCH 040/552] fix: getattr-guard min_tail_user_messages for __new__ test doubles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare ContextCompressor.__new__ doubles (test_compress_focus, cross_session_guard, image_tokens, pre_compress_memory_context) skip __init__ and lack the attribute — the documented compression-path test-double pitfall. Guard with getattr default 1 + int type pin (bool excluded). --- agent/context_compressor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 249678f5745..65684ced454 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4751,9 +4751,13 @@ This compaction should PRIORITISE preserving all information related to the focu # single-anchor pipeline — the single-user anchor already ran above, # and re-invoking it here could re-trigger the causal-coupling # forward push (#22523) after the assistant anchor adjusted the cut. - if self.min_tail_user_messages > 1: + # getattr-guarded: bare ``ContextCompressor.__new__`` test doubles + # (and plugin engines) skip __init__, so the attribute may be absent + # (see the compression-path test-double pitfall). + _min_tail_users = getattr(self, "min_tail_user_messages", 1) + if isinstance(_min_tail_users, int) and not isinstance(_min_tail_users, bool) and _min_tail_users > 1: cut_idx = self._ensure_last_n_user_messages_in_tail( - messages, cut_idx, head_end, self.min_tail_user_messages, + messages, cut_idx, head_end, _min_tail_users, ) # The floor guarantees forward progress — compression must always claim From f65d105cbb533a241f152165e4ea6b85ed8d3264 Mon Sep 17 00:00:00 2001 From: AlexFucuson9 Date: Fri, 17 Jul 2026 16:53:08 +0700 Subject: [PATCH 041/552] fix(agent): preserve Gemini thought_signature in MoA aggregator mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MoA mode is active with a Gemini model as the aggregator, agent.model holds the virtual preset name (e.g. "closed"), not the actual aggregator model name. The _sanitize_tool_calls_for_strict_api call uses agent.model to decide whether to keep extra_content (thought_signature) on tool_calls — since "closed" doesn't contain "gemini", the thought_signature is stripped and the Gemini aggregator rejects the next request with HTTP 400 (INVALID_ARGUMENT): "Function call is missing a thought_signature in functionCall parts." Fix: resolve the actual aggregator model name from moa_config (conversation_loop) or last_aggregator_slot (chat_completion_helpers) and pass it to _sanitize_tool_calls_for_strict_api so the _model_consumes_thought_signature check sees the real Gemini model. Closes #65092 --- agent/chat_completion_helpers.py | 12 +++++++++++- agent/conversation_loop.py | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 8dc764c0753..34098cdc5e2 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1946,7 +1946,17 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]: api_msg.pop(internal_key, None) if _needs_sanitize: - agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model) + # In MoA mode, agent.model is the virtual preset name, + # not the actual aggregator model. Resolve the real + # aggregator model so Gemini preserves thought_signature. + _sanitize_model = agent.model + if agent.provider == "moa": + _moa_client = getattr(agent, "client", None) + if _moa_client is not None: + _agg_slot = getattr(_moa_client, "last_aggregator_slot", None) + if _agg_slot and _agg_slot.get("model"): + _sanitize_model = _agg_slot["model"] + agent._sanitize_tool_calls_for_strict_api(api_msg, model=_sanitize_model) api_messages.append(api_msg) effective_system = agent._cached_system_prompt or "" diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 62b675aa33b..0a7c36ddbd0 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1103,7 +1103,16 @@ def run_conversation( # Uses new dicts so the internal messages list retains the fields # for Codex Responses compatibility. if agent._should_sanitize_tool_calls(): - agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model) + # In MoA mode, agent.model is the virtual preset name + # (e.g. "closed"), not the actual aggregator model. Use + # the resolved aggregator model so Gemini aggregators + # correctly preserve thought_signature (extra_content). + _sanitize_model = agent.model + if agent.provider == "moa" and moa_config: + _agg = moa_config.get("aggregator") or {} + if _agg.get("model"): + _sanitize_model = _agg["model"] + agent._sanitize_tool_calls_for_strict_api(api_msg, model=_sanitize_model) # Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context # The signature field helps maintain reasoning continuity api_messages.append(api_msg) From 8d119832b4f1f02ade9f72484ff48639d436af50 Mon Sep 17 00:00:00 2001 From: Idris Almalki Date: Sat, 25 Apr 2026 17:19:22 +0300 Subject: [PATCH 042/552] fix(gemini): emit thoughtSignature sentinel for cross-provider tool_calls in native adapter When Hermes fails over from a non-Gemini provider (xAI, Anthropic, etc.) to Gemini mid-conversation, the existing assistant tool_calls in history carry no Gemini ``extra_content.google.thought_signature`` (the originating provider never emits one). The native adapter's ``_translate_tool_call_to_gemini`` omitted ``thoughtSignature`` entirely in that case, so Gemini 3 thinking models rejected every replayed turn with:: HTTP 400 INVALID_ARGUMENT Function call is missing a thought_signature in functionCall parts. Additional data, function call default_api:, position N. The Cloud Code Assist sibling adapter already handles this exact case by emitting a sentinel ``"skip_thought_signature_validator"`` (see ``agent/gemini_cloudcode_adapter.py:106``, originally added in #11270 and documented as matching ``opencode-gemini-auth``'s approach). This change mirrors that fallback in the native adapter so the two paths behave identically when replaying cross-provider history. Verified live against ``generativelanguage.googleapis.com/v1beta`` with ``gemini-3-pro-preview``: synthetic 2-turn conversation with no real ``thoughtSignature`` returns 400 without the sentinel and 200 with it. Test added: ``test_build_native_request_emits_sentinel_for_cross_provider_tool_call``. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/gemini_native_adapter.py | 8 +++-- tests/agent/test_gemini_native_adapter.py | 37 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 1c25f1e6cf0..b4f6e6386e7 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -270,8 +270,12 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]: } } thought_signature = _tool_call_extra_signature(tool_call) - if thought_signature: - part["thoughtSignature"] = thought_signature + # Fallback sentinel for cross-provider tool_calls (e.g. fallback from + # xAI/Anthropic to Gemini, where the original tool_call carries no + # Gemini thoughtSignature). Mirrors gemini_cloudcode_adapter.py:106. + # Without this, Gemini 3 thinking models reject replayed history with + # 400 INVALID_ARGUMENT on the missing thoughtSignature. + part["thoughtSignature"] = thought_signature or "skip_thought_signature_validator" return part diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index e797d6b2ae7..e7493fe4918 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -52,6 +52,43 @@ def test_build_native_request_preserves_thought_signature_on_tool_replay(): assert parts[0]["thoughtSignature"] == "sig-123" +def test_build_native_request_emits_sentinel_for_cross_provider_tool_call(): + """Cross-provider tool_calls (xAI/Anthropic -> Gemini fallback) carry no + Gemini thoughtSignature. Without a sentinel, Gemini 3 thinking models + reject the request with 400 INVALID_ARGUMENT. The native adapter must + emit the same ``skip_thought_signature_validator`` sentinel that the + Cloud Code Assist adapter already uses for the same scenario. + """ + from agent.gemini_native_adapter import build_gemini_request + + request = build_gemini_request( + messages=[ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + # No extra_content — this tool_call originated from a + # non-Gemini provider during fallback. + } + ], + }, + ], + tools=[], + tool_choice=None, + ) + + parts = request["contents"][0]["parts"] + assert parts[0]["functionCall"]["name"] == "get_weather" + assert parts[0]["thoughtSignature"] == "skip_thought_signature_validator" + + def test_build_native_request_uses_original_function_name_for_tool_result(): from agent.gemini_native_adapter import build_gemini_request From 8d14e19f9aba3754409d0c461c2bbeac33b45a9a Mon Sep 17 00:00:00 2001 From: Dineth Hettiarachchi <123150002+deaneeth@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:02:25 +0530 Subject: [PATCH 043/552] fix(agent): close MoA stream on interrupt --- agent/chat_completion_helpers.py | 62 ++++++++++++++++++++++++++++-- tests/run_agent/test_streaming.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 34098cdc5e2..37113b61acf 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2473,7 +2473,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= request_client_holder = {"client": None, "diag": None, "owner_tid": None} # Transport kind of the registered request client — see the non-streaming # variant. Routes _close_request_client_once to anthropic vs openai abort/ - # close helpers (#67142). + # close helpers (#67142). ``kind="stream"`` registers a per-request + # *stream handle* instead of a client — used under the MoA facade, whose + # singleton client has no per-request sockets to abort + # (_abort_request_openai_client is a no-op on it), so interrupts must + # close the stream object itself (#57354). request_client_kind = {"value": "openai"} request_client_lock = threading.Lock() # Request-local cancellation flag — see interruptible_api_call for the full @@ -2493,6 +2497,44 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= request_client_holder["owner_tid"] = threading.get_ident() return client + def _stream_close_callable(stream): + close = getattr(stream, "close", None) + if callable(close): + return close + response = getattr(stream, "response", None) + close = getattr(response, "close", None) + if callable(close): + return close + return None + + def _set_request_stream_handle(stream): + # Register the per-request *stream* under kind="stream" so an + # interrupt closes the stream handle itself. Under the MoA facade the + # registered "client" is the shared facade singleton whose + # per-request abort helpers are no-ops, leaving the underlying HTTP + # stream open until the provider drained it (#57354). + if _stream_close_callable(stream) is None: + return stream + with request_client_lock: + request_client_holder["client"] = stream + request_client_kind["value"] = "stream" + request_client_holder["owner_tid"] = threading.get_ident() + return stream + + def _close_request_stream_handle(stream, reason: str) -> None: + close = _stream_close_callable(stream) + if close is None: + return + try: + close() + logger.info("Streaming response handle closed (%s)", reason) + except Exception as exc: + logger.debug( + "Streaming response handle close failed (%s): %s", + reason, + exc, + ) + def _close_request_client_once(reason: str) -> None: # See #29507 explanation in the non-streaming variant above. A # stranger thread (the interrupt-check / stale-stream detector loop) @@ -2500,9 +2542,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # so the worker thread retains ownership of the FD release. with request_client_lock: request_client = request_client_holder.get("client") + request_kind = request_client_kind.get("value", "openai") owner_tid = request_client_holder.get("owner_tid") + # A registered stream handle (kind="stream", MoA facade path) is + # safe to close from any thread — closing IS the abort — so the + # stranger-thread ownership carve-out only applies to real + # per-request clients (#57354). stranger_thread = ( - request_client is not None + request_kind != "stream" + and request_client is not None and owner_tid is not None and owner_tid != threading.get_ident() ) @@ -2511,8 +2559,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= request_client_holder["owner_tid"] = None if request_client is None: return - kind = request_client_kind.get("value", "openai") - if kind == "anthropic_messages": + if request_kind == "stream": + _close_request_stream_handle(request_client, reason) + elif request_kind == "anthropic_messages": if stranger_thread: agent._abort_request_anthropic_client(request_client, reason=reason) else: @@ -2691,6 +2740,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= _diag = agent._stream_diag_init() request_client_holder["diag"] = _diag stream = request_client.chat.completions.create(**stream_kwargs) + if agent.provider == "moa": + # The MoA facade is a shared singleton — abort/close of the + # registered client is a no-op, so register the stream handle + # itself for interrupt teardown (#57354). + stream = _set_request_stream_handle(stream) # Claim the delta sink for THIS attempt (#65991). If a prior attempt's # stream is somehow still alive (a stale-stream reconnect whose socket # abort raced), this claim supersedes it so its late chunks are fenced diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 55a0b9c008e..c73f441141e 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -3,6 +3,7 @@ Tests the unified streaming API call, delta callbacks, tool-call suppression, provider fallback, and CLI streaming display. """ +import threading from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -718,6 +719,68 @@ class TestStreamingFallback: assert agent._disable_streaming is True assert deltas == [] + @patch("run_agent.AIAgent._abort_request_openai_client") + @patch("run_agent.AIAgent._close_request_openai_client") + @patch("run_agent.AIAgent._create_request_openai_client") + def test_moa_interrupt_closes_stream_handle( + self, mock_create, mock_close_openai, mock_abort_openai + ): + """MoA interrupts must close the per-request stream, not the facade client.""" + from run_agent import AIAgent + + class _BlockingClosableStream: + def __init__(self): + self.entered = threading.Event() + self.closed = threading.Event() + self.close_calls = 0 + + def __iter__(self): + return self + + def __next__(self): + self.entered.set() + if not self.closed.wait(timeout=5): + raise TimeoutError("MoA test stream was not closed") + raise RuntimeError("stream closed") + + def close(self): + self.close_calls += 1 + self.closed.set() + + stream = _BlockingClosableStream() + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = stream + mock_create.return_value = mock_client + + agent = AIAgent( + model="default", + provider="moa", + api_key="test-key", + base_url="moa://local", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "chat_completions" + agent._interrupt_requested = False + agent.client = mock_client + + def _request_interrupt(): + assert stream.entered.wait(timeout=2) + agent._interrupt_requested = True + + interrupter = threading.Thread(target=_request_interrupt, daemon=True) + interrupter.start() + + with pytest.raises(InterruptedError): + agent._interruptible_streaming_api_call({"model": "default", "messages": []}) + + assert stream.closed.wait(timeout=2) + assert stream.close_calls == 1 + mock_create.assert_called_once() + mock_close_openai.assert_not_called() + mock_abort_openai.assert_not_called() + @patch("run_agent.AIAgent._create_request_openai_client") @patch("run_agent.AIAgent._close_request_openai_client") def test_stream_error_propagates_original(self, mock_close, mock_create): From 55011878472f00ee804f2a954cf0a1587d9b5295 Mon Sep 17 00:00:00 2001 From: kosta Date: Sat, 27 Jun 2026 15:49:58 -0400 Subject: [PATCH 044/552] fix(moa): restore virtual runtime after fallback --- agent/agent_runtime_helpers.py | 10 ++++- tests/run_agent/test_moa_loop_mode.py | 60 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index a418088916a..bbaa688df7e 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1388,7 +1388,15 @@ def restore_primary_runtime(agent) -> bool: ) # ── Rebuild client for the primary provider ── - if agent.api_mode == "anthropic_messages": + if agent.provider == "moa": + # MoA is a virtual chat-completions provider. It never has real + # OpenAI client kwargs; restoring it after a fallback must recreate + # the facade, not call OpenAI() with an empty api_key. + from agent.moa_loop import MoAClient + + agent.client = MoAClient(agent.model or "default") + agent._anthropic_client = None + elif agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 50e3a008724..2a0b3df8709 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -81,6 +81,66 @@ def test_moa_runtime_provider_uses_virtual_endpoint(): assert runtime["api_key"] == "moa-virtual-provider" +def test_moa_primary_restore_rebuilds_virtual_facade(monkeypatch, tmp_path): + """MoA sessions must restore from fallback without constructing OpenAI(). + + Regression for a long-lived MoA session that failed over to a real provider: + the next turn restored provider/model to MoA but tried to rebuild the shared + client from MoA's empty client_kwargs, raising "api_key client option must be + set" and then "Failed to recreate closed OpenAI client". + """ + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + """ +moa: + default_preset: review + presets: + review: + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: openrouter + model: anthropic/claude-opus-4.8 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + agent = AIAgent( + api_key="moa-virtual-provider", + base_url="moa://local", + model="review", + provider="moa", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=["file"], + max_iterations=1, + ) + primary_client = agent.client + + def fail_openai_rebuild(*_args, **_kwargs): + raise AssertionError("MoA restore must not build a real OpenAI client") + + monkeypatch.setattr(agent, "_create_openai_client", fail_openai_rebuild) + setattr(agent, "_fallback_activated", True) + setattr(agent, "provider", "zai") + setattr(agent, "model", "glm-5.2") + agent.base_url = "https://api.z.ai/api/coding/paas/v4" + agent.api_key = "fallback-key" + setattr(agent, "_client_kwargs", {"api_key": "fallback-key", "base_url": agent.base_url}) + agent.client = SimpleNamespace(close=lambda: None, _client=SimpleNamespace(is_closed=True)) + + assert agent._restore_primary_runtime() is True + assert getattr(agent, "provider") == "moa" + assert getattr(agent, "model") == "review" + assert agent.client is not primary_client + assert hasattr(agent.client.chat, "completions") + assert getattr(agent, "_fallback_activated") is False + + def test_moa_does_not_cap_output_tokens(monkeypatch, tmp_path): """MoA must not inject an output cap on reference or aggregator calls. From 0749cac7a13740eaf0173faf722a10221d025b81 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:08:37 -0700 Subject: [PATCH 045/552] fix(moa): share facade factory so restore/recover keep reference relay (#53802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the salvaged core of #53802: a naive MoAClient(preset) rebuild restores a working facade but silently drops the reference_callback relay wired in agent_init, so moa.reference / moa.aggregating display events stop reaching every frontend for the rest of the session. Introduce agent.moa_loop.build_moa_facade(agent, preset) as the single construction point for the MoA facade and use it at: - initial client construction (agent_init.py) - turn-start fallback restore (restore_primary_runtime) - transient transport recovery (try_recover_primary_transport — previously fell through to _create_openai_client with MoA's empty client_kwargs and died with 'api_key client option must be set') - mid-session model switches (switch_model) The relay reads agent.tool_progress_callback at emit time, so callbacks attached after construction are picked up automatically. Adds test_moa_restored_facade_still_emits_reference_events covering event delivery through a restored facade. --- agent/agent_init.py | 51 ++++-------------- agent/agent_runtime_helpers.py | 21 ++++++-- agent/moa_loop.py | 55 ++++++++++++++++++++ tests/run_agent/test_moa_loop_mode.py | 74 +++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 45 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 26a4c787617..92be00c7bf4 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1025,49 +1025,20 @@ def init_agent( elif isinstance(effective_key, str) and len(effective_key) > 12: print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") elif agent.provider == "moa": - from agent.moa_loop import MoAClient + from agent.moa_loop import build_moa_facade agent.api_mode = "chat_completions" - # Route reference-model outputs to the agent's tool_progress_callback so + # build_moa_facade wires the reference relay that routes + # reference-model outputs to the agent's tool_progress_callback so # every surface that already consumes it (CLI spinner/scrollback, TUI, - # desktop, gateway) can show each reference's answer as a labelled block - # before the aggregator acts. The facade emits "moa.reference" and - # "moa.aggregating" events; we forward them through the same callback - # the tool lifecycle uses. Best-effort and cache-safe — these are - # display-only events, they never touch the message history. - def _moa_reference_relay(event: str, **kwargs: Any) -> None: - cb = getattr(agent, "tool_progress_callback", None) - if cb is None: - return - try: - if event == "moa.reference": - label = str(kwargs.get("label") or "") - text = str(kwargs.get("text") or "") - idx = kwargs.get("index") - count = kwargs.get("count") - cb( - "moa.reference", - label, - text, - None, - moa_index=idx, - moa_count=count, - ) - elif event == "moa.aggregating": - cb( - "moa.aggregating", - str(kwargs.get("aggregator") or ""), - None, - None, - moa_ref_count=kwargs.get("ref_count"), - ) - except Exception: - pass - - agent.client = MoAClient( - agent.model or "default", - reference_callback=_moa_reference_relay, - ) + # desktop, gateway) can show each reference's answer as a labelled + # block before the aggregator acts. The facade emits "moa.reference" + # and "moa.aggregating" events, forwarded through the same callback + # the tool lifecycle uses. Best-effort and cache-safe — display-only + # events, they never touch the message history. The factory is shared + # with the fallback-restore/recovery paths so a restored facade keeps + # emitting these events (#53802). + agent.client = build_moa_facade(agent, agent.model) agent._client_kwargs = {} agent.api_key = api_key or "moa-virtual-provider" agent.base_url = "moa://local" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index bbaa688df7e..f9ff6b112ef 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1225,6 +1225,14 @@ def try_recover_primary_transport( ) agent._is_anthropic_oauth = rt["is_anthropic_oauth"] agent.client = None + elif (agent.provider or "").strip().lower() == "moa": + # MoA is a virtual provider with empty client_kwargs — rebuilding + # via _create_openai_client would raise "api_key client option + # must be set". Recreate the facade through the shared factory so + # the reference_callback relay survives recovery (#53802). + from agent.moa_loop import build_moa_facade + + agent.client = build_moa_facade(agent, agent.model) else: agent.client = agent._create_openai_client( dict(rt["client_kwargs"]), @@ -1391,10 +1399,13 @@ def restore_primary_runtime(agent) -> bool: if agent.provider == "moa": # MoA is a virtual chat-completions provider. It never has real # OpenAI client kwargs; restoring it after a fallback must recreate - # the facade, not call OpenAI() with an empty api_key. - from agent.moa_loop import MoAClient + # the facade, not call OpenAI() with an empty api_key. Use the + # shared factory so the restored facade keeps the reference_callback + # relay wired at init — a bare MoAClient() would silently stop + # emitting moa.reference/moa.aggregating display events (#53802). + from agent.moa_loop import build_moa_facade - agent.client = MoAClient(agent.model or "default") + agent.client = build_moa_facade(agent, agent.model) agent._anthropic_client = None elif agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client @@ -2132,7 +2143,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Build new client ── if (new_provider or "").strip().lower() == "moa": - from agent.moa_loop import MoAClient + from agent.moa_loop import build_moa_facade # The MoA virtual provider speaks only chat.completions via the # MoAClient facade — the aggregator's real transport @@ -2149,7 +2160,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo agent.api_key = api_key or "moa-virtual-provider" agent.base_url = "moa://local" agent._client_kwargs = {} - agent.client = MoAClient(agent.model or "default") + agent.client = build_moa_facade(agent, agent.model) elif api_mode == "anthropic_messages": from agent.anthropic_adapter import ( build_anthropic_client, diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 3d9373b823c..b742c743ce0 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -1268,3 +1268,58 @@ class MoAClient: return self.chat.completions.consume_and_save_trace( session_id, aggregator_output_fallback=aggregator_output_fallback ) + + +def build_moa_facade(agent, preset_name: Any = None) -> MoAClient: + """Build the MoA facade client for ``agent``, wiring the reference relay. + + Single construction point for ``MoAClient`` wherever the agent's shared + client is (re)built: initial setup (``agent_init``), turn-start fallback + restore (``restore_primary_runtime``), transient transport recovery + (``try_recover_primary_transport``), and mid-session model switches + (``switch_model``). + + Constructing a bare ``MoAClient(preset)`` at any of those sites silently + drops the ``reference_callback`` relay that ``agent_init`` wires to + ``agent.tool_progress_callback`` — after a fallback+restore cycle the + facade would still work, but every frontend (CLI spinner, TUI, desktop, + gateway) would stop receiving ``moa.reference`` / ``moa.aggregating`` + display events for the rest of the session (#53802). + + The relay reads ``agent.tool_progress_callback`` at *emit* time, so a + callback attached after client construction is picked up automatically. + Best-effort and display-only — it never raises into the model call. + """ + def _moa_reference_relay(event: str, **kwargs: Any) -> None: + cb = getattr(agent, "tool_progress_callback", None) + if cb is None: + return + try: + if event == "moa.reference": + label = str(kwargs.get("label") or "") + text = str(kwargs.get("text") or "") + idx = kwargs.get("index") + count = kwargs.get("count") + cb( + "moa.reference", + label, + text, + None, + moa_index=idx, + moa_count=count, + ) + elif event == "moa.aggregating": + cb( + "moa.aggregating", + str(kwargs.get("aggregator") or ""), + None, + None, + moa_ref_count=kwargs.get("ref_count"), + ) + except Exception: + pass + + return MoAClient( + str(preset_name or getattr(agent, "model", None) or "default"), + reference_callback=_moa_reference_relay, + ) diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 2a0b3df8709..c41dca2511d 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -141,6 +141,80 @@ moa: assert getattr(agent, "_fallback_activated") is False +def test_moa_restored_facade_still_emits_reference_events(monkeypatch, tmp_path): + """A restored MoA facade must keep the reference_callback relay wired. + + Regression for the naive-rebuild flaw in the original #53802 approach: + ``MoAClient(preset)`` without ``reference_callback`` restores a *working* + facade that silently stops emitting ``moa.reference``/``moa.aggregating`` + display events for the rest of the session. The shared ``build_moa_facade`` + factory rewires the relay to ``agent.tool_progress_callback`` on restore. + """ + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + """ +moa: + default_preset: review + presets: + review: + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: openrouter + model: anthropic/claude-opus-4.8 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + agent = AIAgent( + api_key="moa-virtual-provider", + base_url="moa://local", + model="review", + provider="moa", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=["file"], + max_iterations=1, + ) + + # Simulate a fallback to a real provider, then restore. + setattr(agent, "_fallback_activated", True) + setattr(agent, "provider", "zai") + setattr(agent, "model", "glm-5.2") + agent.base_url = "https://api.z.ai/api/coding/paas/v4" + agent.api_key = "fallback-key" + setattr(agent, "_client_kwargs", {"api_key": "fallback-key", "base_url": agent.base_url}) + agent.client = SimpleNamespace(close=lambda: None, _client=SimpleNamespace(is_closed=True)) + assert agent._restore_primary_runtime() is True + + # The relay reads tool_progress_callback at emit time — attach a recorder + # and fire the facade's internal _emit exactly as the fan-out does. + events = [] + + def record_progress(event, *args, **kwargs): + events.append((event, args, kwargs)) + + agent.tool_progress_callback = record_progress + completions = agent.client.chat.completions + assert completions.reference_callback is not None, ( + "restored MoA facade lost its reference_callback relay" + ) + completions._emit( + "moa.reference", index=0, count=1, label="openai-codex/gpt-5.5", text="advice" + ) + completions._emit("moa.aggregating", aggregator="openrouter", ref_count=1) + + assert [e[0] for e in events] == ["moa.reference", "moa.aggregating"] + ref_event = events[0] + assert ref_event[1][0] == "openai-codex/gpt-5.5" + assert ref_event[1][1] == "advice" + assert ref_event[2] == {"moa_index": 0, "moa_count": 1} + + def test_moa_does_not_cap_output_tokens(monkeypatch, tmp_path): """MoA must not inject an output cap on reference or aggregator calls. From 4c66307c360838756115fa0442df93fc417cb975 Mon Sep 17 00:00:00 2001 From: dyreckt <196375055+dyreckt@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:40:44 -0400 Subject: [PATCH 046/552] fix(moa): pass Copilot initiator header to advisors --- agent/auxiliary_client.py | 19 +++++++ agent/moa_loop.py | 20 ++++++++ tests/run_agent/test_moa_loop_mode.py | 74 +++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f475369d1a1..dc5c2648f7a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3648,6 +3648,7 @@ def _retry_same_provider_sync( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], + extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3685,6 +3686,11 @@ def _retry_same_provider_sync( base_url=retry_base or resolved_base_url, task=task, ) + # Preserve per-request attribution headers (e.g. Copilot's + # ``x-initiator: user``) across the rebuilt-client retry — dropping them + # here would let a recovery retry silently lose capability gating (#60293). + if extra_headers: + retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( @@ -3708,6 +3714,7 @@ async def _retry_same_provider_async( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], + extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3745,6 +3752,10 @@ async def _retry_same_provider_async( base_url=retry_base or resolved_base_url, task=task, ) + # Preserve per-request attribution headers across the rebuilt-client + # retry — see the sync variant above (#60293). + if extra_headers: + retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( @@ -7108,6 +7119,7 @@ def call_llm( timeout: float = None, extra_body: dict = None, reasoning_config: Optional[dict] = None, + extra_headers: Optional[Dict[str, str]] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -7133,6 +7145,9 @@ def call_llm( extra_body: Additional request body fields. reasoning_config: Optional Hermes reasoning config for direct model calls such as MoA reference/aggregator slots. + extra_headers: Additional per-request HTTP headers. These override + client-level defaults for providers that gate capabilities on + request attribution (for example Copilot's ``x-initiator``). stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -7246,6 +7261,8 @@ def call_llm( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=_base_info or resolved_base_url, task=task) + if extra_headers: + kwargs["extra_headers"] = dict(extra_headers) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) _client_base = str(getattr(client, "base_url", "") or "") @@ -7499,6 +7516,7 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, + extra_headers=extra_headers, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -7542,6 +7560,7 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, + extra_headers=extra_headers, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it diff --git a/agent/moa_loop.py b/agent/moa_loop.py index b742c743ce0..8522750cd04 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -338,12 +338,32 @@ def _run_reference( # reference model have its own output cap independently. _slot_max_tokens: int | None = slot.get("max_tokens") _effective_max_tokens = _slot_max_tokens if _slot_max_tokens is not None else max_tokens + extra_headers = None + # Normalize provider aliases (github, github-copilot, github-models, + # ...) through the auxiliary client's canonical alias table so slot + # configs that spell Copilot differently still get the header. + from agent.auxiliary_client import _normalize_aux_provider + + if _normalize_aux_provider(str(runtime.get("provider") or "")) in ( + "copilot", + "copilot-acp", + ): + # Copilot Pro/Pro+ gates some premium chat models on request + # attribution. The main agent marks the first API request of a + # user turn as ``x-initiator: user``; MoA reference fan-out is also + # directly serving the user's current turn, not a background agent + # task, so mirror that header here. Without it, Claude/Gemini + # Copilot advisors can be rejected as unavailable to the + # ``copilot-language-server`` integrator even though standalone + # Copilot calls work. + extra_headers = {"x-initiator": "user"} response = call_llm( task="moa_reference", messages=messages, temperature=temperature, max_tokens=_effective_max_tokens, reasoning_config=_slot_reasoning_config(slot), + extra_headers=extra_headers, **runtime, ) usage = CanonicalUsage() diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index c41dca2511d..2ff06f79b29 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -395,6 +395,80 @@ def test_moa_provider_backed_slot_survives_aux_resolution(monkeypatch, provider) assert api_key == f"token-for-{provider}" +def test_moa_copilot_reference_forwards_user_initiator_header(monkeypatch): + """Copilot MoA advisors must carry the same user-turn attribution as main calls. + + Copilot Pro/Pro+ gates some premium chat models on the ``x-initiator`` + request header. MoA references are direct fan-out for the user's current + turn, so Copilot advisors need ``x-initiator: user`` rather than inheriting + the Copilot language-server default attribution. + """ + from agent import moa_loop + + calls = [] + + monkeypatch.setattr( + moa_loop, + "_slot_runtime", + lambda _slot: { + "provider": "copilot", + "model": "claude-sonnet-4.6", + "api_mode": "chat_completions", + "base_url": "https://api.githubcopilot.com", + "api_key": "copilot-token", + }, + ) + + def fake_call_llm(**kwargs): + calls.append(kwargs) + return _response("copilot advice") + + monkeypatch.setattr(moa_loop, "call_llm", fake_call_llm) + + _label, text, _acct = moa_loop._run_reference( + {"provider": "copilot", "model": "claude-sonnet-4.6"}, + [{"role": "user", "content": "solve this"}], + ) + + assert text == "copilot advice" + assert calls[0]["task"] == "moa_reference" + assert calls[0]["extra_headers"] == {"x-initiator": "user"} + + +def test_moa_non_copilot_reference_does_not_forward_initiator_header(monkeypatch): + """The Copilot attribution header must stay scoped to Copilot advisors.""" + from agent import moa_loop + + calls = [] + + monkeypatch.setattr( + moa_loop, + "_slot_runtime", + lambda _slot: { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "openrouter-token", + }, + ) + + def fake_call_llm(**kwargs): + calls.append(kwargs) + return _response("openrouter advice") + + monkeypatch.setattr(moa_loop, "call_llm", fake_call_llm) + + _label, text, _acct = moa_loop._run_reference( + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + [{"role": "user", "content": "solve this"}], + ) + + assert text == "openrouter advice" + assert calls[0]["task"] == "moa_reference" + assert calls[0]["extra_headers"] is None + + def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch): """A slot whose provider can't be resolved still attempts the call with the bare provider/model rather than aborting the whole MoA turn.""" From 16950a4568715c9439f217a67533fab7d4b8643f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:20:53 -0700 Subject: [PATCH 047/552] fix(moa): normalize Copilot aliases + carry x-initiator through retry rebuilds (#60293) Follow-ups to the salvaged core of #60293: - Gate the x-initiator header on _normalize_aux_provider() instead of a literal 'copilot' string compare, so slot configs spelled github / github-copilot / github-models / copilot-acp / mixed case all get the user-turn attribution. - Thread extra_headers through _retry_same_provider_sync/_async so the credential-refresh and pool-rotation retry rebuilds don't silently drop the header (the rebuilt kwargs previously started from scratch). - Add a transport-boundary test asserting the header reaches the SDK client's create() kwargs (no call_llm mocking), an alias-spelling matrix test, and a retry-rebuild preservation test. --- tests/run_agent/test_moa_loop_mode.py | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 2ff06f79b29..1be3b488336 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -469,6 +469,147 @@ def test_moa_non_copilot_reference_does_not_forward_initiator_header(monkeypatch assert calls[0]["extra_headers"] is None +@pytest.mark.parametrize( + "provider_spelling", + ["copilot", "github-copilot", "github", "github-models", "Copilot", "copilot-acp"], +) +def test_moa_copilot_alias_spellings_forward_initiator_header( + monkeypatch, provider_spelling +): + """Every Copilot alias spelling must trigger the x-initiator header. + + Slot configs spell the provider inconsistently (github, github-copilot, + github-models, copilot-acp, mixed case); the header gate goes through the + auxiliary client's canonical alias normalization so all of them get the + user-turn attribution, not just the literal string "copilot". + """ + from agent import moa_loop + + calls = [] + + monkeypatch.setattr( + moa_loop, + "_slot_runtime", + lambda _slot: { + "provider": provider_spelling, + "model": "claude-sonnet-4.6", + "api_mode": "chat_completions", + "base_url": "https://api.githubcopilot.com", + "api_key": "copilot-token", + }, + ) + + def fake_call_llm(**kwargs): + calls.append(kwargs) + return _response("copilot advice") + + monkeypatch.setattr(moa_loop, "call_llm", fake_call_llm) + + _label, text, _acct = moa_loop._run_reference( + {"provider": provider_spelling, "model": "claude-sonnet-4.6"}, + [{"role": "user", "content": "solve this"}], + ) + + assert text == "copilot advice" + assert calls[0]["extra_headers"] == {"x-initiator": "user"} + + +def test_call_llm_extra_headers_reach_transport_create(monkeypatch): + """extra_headers must reach the SDK client's create() kwargs. + + Transport-boundary regression for #60293: mocking call_llm proves nothing + about delivery — this asserts the header survives call_llm's request + building and lands in the kwargs handed to chat.completions.create(). + """ + from types import SimpleNamespace + + from agent import auxiliary_client as ac + + captured = {} + + class _Completions: + def create(self, **kwargs): + captured.update(kwargs) + return _response("ok") + + fake_client = SimpleNamespace( + chat=SimpleNamespace(completions=_Completions()), + base_url="https://api.githubcopilot.com", + ) + monkeypatch.setattr( + ac, + "_resolve_task_provider_model", + lambda *a, **k: ( + "copilot", + "claude-sonnet-4.6", + "https://api.githubcopilot.com", + "copilot-token", + "chat_completions", + ), + ) + monkeypatch.setattr(ac, "_get_cached_client", lambda *a, **k: (fake_client, "claude-sonnet-4.6")) + monkeypatch.setattr(ac, "_validate_llm_response", lambda resp, task, **_kw: resp) + + ac.call_llm( + provider="copilot", + model="claude-sonnet-4.6", + messages=[{"role": "user", "content": "hi"}], + extra_headers={"x-initiator": "user"}, + ) + + assert captured.get("extra_headers") == {"x-initiator": "user"} + # And it must not leak into unrelated request fields. + assert "x-initiator" not in captured.get("extra_body", {}) if captured.get("extra_body") else True + + +def test_retry_same_provider_sync_preserves_extra_headers(monkeypatch): + """The same-provider retry rebuild must carry extra_headers through. + + Regression for #60293's follow-up: a credential-refresh/pool-rotation + retry rebuilds the request kwargs from scratch — without forwarding + extra_headers, the retried Copilot advisor call silently loses its + ``x-initiator: user`` attribution and can be rejected. + """ + from types import SimpleNamespace + + from agent import auxiliary_client as ac + + captured = {} + + class _Completions: + def create(self, **kwargs): + captured.update(kwargs) + return _response("retried ok") + + fake_client = SimpleNamespace( + chat=SimpleNamespace(completions=_Completions()), + base_url="https://api.githubcopilot.com", + ) + monkeypatch.setattr(ac, "_get_cached_client", lambda *a, **k: (fake_client, "claude-sonnet-4.6")) + monkeypatch.setattr(ac, "_validate_llm_response", lambda resp, task, **_kw: resp) + + ac._retry_same_provider_sync( + task=None, + resolved_provider="copilot", + resolved_model="claude-sonnet-4.6", + resolved_base_url="https://api.githubcopilot.com", + resolved_api_key="copilot-token", + resolved_api_mode="chat_completions", + main_runtime=None, + final_model="claude-sonnet-4.6", + messages=[{"role": "user", "content": "hi"}], + temperature=None, + max_tokens=None, + tools=None, + effective_timeout=30.0, + effective_extra_body={}, + reasoning_config=None, + extra_headers={"x-initiator": "user"}, + ) + + assert captured.get("extra_headers") == {"x-initiator": "user"} + + def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch): """A slot whose provider can't be resolved still attempts the call with the bare provider/model rather than aborting the whole MoA turn.""" From 74a56b76b08bccc4b4a85076af15e2c176ab5542 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:25:27 -0700 Subject: [PATCH 048/552] test(moa): regression for aggregator-model thought_signature resolution (#66212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test_moa_gemini_aggregator_sanitize_uses_real_model: drives a full MoA tool-call turn (virtual-provider mode) with a Gemini aggregator and asserts the strict-API sanitize pass is invoked with the resolved aggregator model (gemini-3-pro-preview), never the virtual preset name once a slot is resolved — the exact path that stripped extra_content/thought_signature and made Gemini aggregators 400 (#65092). Writing the test surfaced a gap in the salvaged #66212 fix: in virtual- provider MoA mode (provider=moa, no moa_config threaded through run_conversation) the conversation-loop branch never fired because it only consulted moa_config. Extend it to fall back to the facade's last_aggregator_slot — the same source the handle_max_iterations fix uses — so both MoA entry modes resolve the real aggregator model. Also adds the contributors/emails mapping for the #15676 credit base. --- agent/conversation_loop.py | 20 +++- .../idrisalmalki@Idriss-MacBook-Air.local | 1 + tests/run_agent/test_moa_loop_mode.py | 92 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 contributors/emails/idrisalmalki@Idriss-MacBook-Air.local diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 0a7c36ddbd0..04b5cdb679e 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1108,10 +1108,22 @@ def run_conversation( # the resolved aggregator model so Gemini aggregators # correctly preserve thought_signature (extra_content). _sanitize_model = agent.model - if agent.provider == "moa" and moa_config: - _agg = moa_config.get("aggregator") or {} - if _agg.get("model"): - _sanitize_model = _agg["model"] + if agent.provider == "moa": + if moa_config: + _agg = moa_config.get("aggregator") or {} + if _agg.get("model"): + _sanitize_model = _agg["model"] + if _sanitize_model == agent.model: + # Virtual-provider mode: no moa_config is threaded + # through run_conversation — the facade resolves the + # preset internally. Ask the facade for the resolved + # aggregator slot from the previous create() instead + # (set before any history replay that could carry + # thought_signature). + _moa_client = getattr(agent, "client", None) + _agg_slot = getattr(_moa_client, "last_aggregator_slot", None) + if _agg_slot and _agg_slot.get("model"): + _sanitize_model = _agg_slot["model"] agent._sanitize_tool_calls_for_strict_api(api_msg, model=_sanitize_model) # Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context # The signature field helps maintain reasoning continuity diff --git a/contributors/emails/idrisalmalki@Idriss-MacBook-Air.local b/contributors/emails/idrisalmalki@Idriss-MacBook-Air.local new file mode 100644 index 00000000000..8ea8798405e --- /dev/null +++ b/contributors/emails/idrisalmalki@Idriss-MacBook-Air.local @@ -0,0 +1 @@ +quantumbyte1617 diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 1be3b488336..1ad39d315c0 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -610,6 +610,98 @@ def test_retry_same_provider_sync_preserves_extra_headers(monkeypatch): assert captured.get("extra_headers") == {"x-initiator": "user"} +def test_moa_gemini_aggregator_sanitize_uses_real_model(monkeypatch, tmp_path): + """MoA turns must sanitize tool_calls against the AGGREGATOR model, not the preset. + + Regression for #66212 / #65092: under MoA, ``agent.model`` holds the + virtual preset name (e.g. "review"), so passing it to + _sanitize_tool_calls_for_strict_api makes + _model_consumes_thought_signature() return False and strips + ``extra_content`` (Gemini thought_signature) from replayed tool_calls — + the Gemini aggregator then 400s with "Function call is missing a + thought_signature in functionCall parts." + """ + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + """ +moa: + default_preset: review + presets: + review: + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: gemini + model: gemini-3-pro-preview +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + sanitize_models = [] + + tool_call = SimpleNamespace( + id="call_1", + type="function", + function=SimpleNamespace(name="read_file", arguments='{"path": "x"}'), + ) + + responses = iter( + [ + _response(None, tool_calls=[tool_call]), + _response("aggregator done"), + ] + ) + + def fake_call_llm(**kwargs): + if kwargs["task"] == "moa_reference": + return _response("reference advice") + return next(responses) + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + + agent = AIAgent( + api_key="moa-virtual-provider", + base_url="moa://local", + model="review", + provider="moa", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=["file"], + max_iterations=3, + ) + + real_sanitize = type(agent)._sanitize_tool_calls_for_strict_api + + def spy_sanitize(api_msg, model=None): + sanitize_models.append(model) + return real_sanitize(api_msg, model=model) + + monkeypatch.setattr( + type(agent), "_sanitize_tool_calls_for_strict_api", staticmethod(spy_sanitize) + ) + monkeypatch.setattr( + agent, "execute_tool", lambda *_a, **_k: "file contents", raising=False + ) + + result = agent.run_conversation("read the file") + + assert result["final_response"] == "aggregator done" + # Once the history contains an assistant tool_call turn, the sanitize + # pass must be asked about the REAL aggregator model — never the virtual + # preset name (which would strip Gemini's thought_signature). The very + # first API call may still see the preset (the facade hasn't resolved a + # slot yet), but no tool_calls exist in history at that point. + assert any(m == "gemini-3-pro-preview" for m in sanitize_models), sanitize_models + first_resolved = sanitize_models.index("gemini-3-pro-preview") + assert all( + m == "gemini-3-pro-preview" for m in sanitize_models[first_resolved:] + ), sanitize_models + + def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch): """A slot whose provider can't be resolved still attempts the call with the bare provider/model rather than aborting the whole MoA turn.""" From 850f576f3d3abb7ab45ed633fc5b838d8d5c671e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:48:49 -0700 Subject: [PATCH 049/552] feat(moa): add every_n fanout cadence with cached-guidance reuse Extends the fanout enum with 'every_n:' (N >= 2): advisors run on the first iteration of each user turn and every Nth tool iteration after it; off-cadence iterations REUSE the cached guidance from the last on-cadence run via the same cache mechanism the user_turn fanout uses, so the aggregator still gets advice on every step. The cadence counter is scoped per user turn (resets on a new user message) and only advances when the advisory state actually changes, so streaming retries never consume a cadence slot. Mapping form {mode: every_n, n: N} normalizes to the canonical string. Unknown/degenerate values fall back to per_iteration. Addresses issue #63393 (advisor fan-out multiplies turn latency/cost by the tool-iteration count). Redesigned from PR #63448: the submitted shape skipped references entirely on off-cadence iterations (aggregator ran advice-less); this version keeps the last advice in play, credited for the idea and cadence framing. Config-gated, default-off (default fanout remains per_iteration). Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> --- agent/moa_loop.py | 276 +++++++++++++++++- hermes_cli/moa_config.py | 64 +++- tests/hermes_cli/test_moa_config.py | 71 +++++ tests/run_agent/test_moa_fanout_cadence.py | 222 ++++++++++++++ .../user-guide/features/mixture-of-agents.md | 60 ++++ 5 files changed, 675 insertions(+), 18 deletions(-) create mode 100644 tests/run_agent/test_moa_fanout_cadence.py diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 8522750cd04..3eceeb945ba 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -10,6 +10,7 @@ from __future__ import annotations import hashlib import logging +import re from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -19,6 +20,136 @@ from agent.transports import get_transport logger = logging.getLogger(__name__) +# --- MoA privacy filter (config: moa.privacy_filter — '' | display | full) --- +# +# Advisor (reference) outputs can echo PII from the conversation — emails, +# phone numbers, credentials pasted by the user — into surfaces the user may +# not expect: the labelled reference blocks rendered in the UI, saved MoA +# trace files, and (in `full` mode) the guidance block injected into the +# aggregator prompt (issue #59959). Secret/credential shapes (API-key +# prefixes, JWTs, private keys, DB connection strings, E.164 phone numbers) +# are handled by the repo's central redactor, ``agent.redact +# .redact_sensitive_text`` — the MoA filter never re-implements those. The +# two patterns below cover the PII classes the central redactor deliberately +# leaves alone for log/tool output (emails and formatted phone numbers). +# +# Pattern safety: advisory text is frequently code-review-shaped — line +# numbers, timestamps, git SHAs, IDs, IP addresses. A bare 10-digit match +# would mangle all of those, so the phone pattern requires clearly delimited +# formatting: a parenthesized area code and/or explicit `-`/`.` separators +# between groups ((555) 123-4567, 555-123-4567, 555.123.4567, +1 555-123-4567). +# Undelimited digit runs (5551234567), dates (2026-07-12), times (12:34:56), +# hex IDs, and dotted quads never match. International numbers in E.164 form +# (+14155551234) are already masked by the central redactor. +_MOA_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b") +_MOA_PHONE_RE = re.compile( + r"(? Any: + """Redact secrets + PII from one advisor/reference text surface. + + Centralized secret shapes first (force=True: the MoA privacy filter is + its own explicit opt-in, independent of the global log-redaction toggle; + code_file=True: advisory text is prose/code, so the ENV/JSON assignment + heuristics that mangle source snippets stay off), then the MoA-specific + email/formatted-phone patterns. Non-string inputs pass through unchanged. + """ + if not isinstance(text, str) or not text: + return text + from agent.redact import redact_sensitive_text + + text = redact_sensitive_text(text, force=True, code_file=True) + text = _MOA_EMAIL_RE.sub("[redacted email]", text) + text = _MOA_PHONE_RE.sub("[redacted phone]", text) + return text + + +def _moa_privacy_mode(moa_raw: Any) -> str: + """Resolve the normalized privacy-filter mode from a raw ``moa`` config.""" + from hermes_cli.moa_config import coerce_privacy_filter + + raw = moa_raw if isinstance(moa_raw, dict) else {} + return coerce_privacy_filter(raw.get("privacy_filter")) + + +def _redact_reference_outputs( + reference_outputs: list[tuple[str, str, Any]], +) -> list[tuple[str, str, Any]]: + """Return reference-output tuples with their advisor text redacted. + + The ``_RefAccounting`` third slot is left as-is — accounting fields carry + no advisor text; the full-output/input trace fields are redacted + separately at trace-stash time (see create()) so the LIVE cache keeps raw + accounting objects untouched. + """ + return [ + (label, _redact_reference_text(text), acct) + for label, text, acct in reference_outputs + ] + + +def _redact_trace_messages(messages: Any) -> Any: + """Redact message copies destined for trace persistence. + + Handles both string content and structured content-part lists (e.g. + cache_control-decorated text parts). Unknown shapes pass through. + """ + if not isinstance(messages, list): + return messages + out: list[Any] = [] + for m in messages: + if not isinstance(m, dict): + out.append(m) + continue + content = m.get("content") + if isinstance(content, str): + out.append({**m, "content": _redact_reference_text(content)}) + elif isinstance(content, list): + out.append( + { + **m, + "content": [ + {**p, "text": _redact_reference_text(p.get("text"))} + if isinstance(p, dict) and isinstance(p.get("text"), str) + else p + for p in content + ], + } + ) + else: + out.append(m) + return out + + +def _redact_trace_accounting(acct: Any) -> Any: + """Return a copy of a ``_RefAccounting`` with its trace text redacted. + + Traces persist the advisor's FULL input messages and output to disk, so a + privacy-filtered run must not write raw PII there. Usage/cost fields are + copied verbatim (numbers, no text). Non-accounting objects pass through. + """ + if not isinstance(acct, _RefAccounting): + return acct + return _RefAccounting( + acct.usage, + acct.cost_usd, + acct.cost_status, + acct.cost_source, + messages=_redact_trace_messages(acct.messages), + output=_redact_reference_text(acct.output), + model=acct.model, + provider=acct.provider, + temperature=acct.temperature, + ) + + + # Upper bound on concurrent reference-model calls. References are independent # advisory calls (no tools, no inter-dependence), so we fan them out the same # way delegate_task runs a batch: all in flight at once, results collected when @@ -742,6 +873,18 @@ def aggregate_moa_context( max_tokens=reference_max_tokens, ) + # 'full' privacy mode (moa.privacy_filter) also covers this one-shot /moa + # synthesis path: advisor text is redacted before it reaches the + # synthesizing aggregator. 'display' does not apply here — this path has + # no user-visible reference blocks or trace records of its own. + try: + from hermes_cli.config import load_config as _load_config + + if _moa_privacy_mode((_load_config() or {}).get("moa")) == "full": + reference_outputs = _redact_reference_outputs(reference_outputs) + except Exception: # pragma: no cover - privacy filter must never break a turn + logger.debug("MoA privacy filter check failed", exc_info=True) + joined = "\n\n".join( f"Reference {idx} — {label}:\n{text}" for idx, (label, text, _usage) in enumerate(reference_outputs, start=1) @@ -877,6 +1020,18 @@ class MoAChatCompletions: # caller to stitch in the live session_id + resolved aggregator output # and flush to the trace file (only when moa.save_traces is on). self._pending_trace: Any = None + # every_n fan-out cadence state. The iteration counter is scoped to a + # single USER TURN (not the facade lifetime): it counts create() calls + # since the last new user message and resets whenever the user-turn + # signature changes, so cadence position never leaks across turns — + # iteration 1 of every turn is always on-cadence (fresh advice for a + # fresh request). See the fanout handling in create(). + self._fanout_iteration_count = 0 + self._fanout_turn_sig: str | None = None + self._fanout_last_state_sig: str | None = None + # Normalized moa.privacy_filter mode for the current turn ('' | + # 'display' | 'full'), refreshed from config on every create(). + self._privacy_mode: str = "" def consume_reference_usage(self) -> tuple[Any, Any]: """Pop pending reference-fan-out usage + cost, resetting both to empty. @@ -992,9 +1147,17 @@ class MoAChatCompletions: extra_body: Any = agg_kwargs.get("extra_body") # Record the exact aggregator INPUT (incl. the injected reference # context) into the pending trace so a trace captures what the - # aggregator actually saw, not a reconstruction. + # aggregator actually saw, not a reconstruction. Traces are a + # persisted surface: when the privacy filter is active, the stored + # COPY is redacted ('display' mode's live aggregator input stays raw — + # only the on-disk record is filtered; 'full' mode's input is already + # redacted upstream, so this is a near no-op there). if self._pending_trace is not None: - self._pending_trace["aggregator_input_messages"] = agg_messages + self._pending_trace["aggregator_input_messages"] = ( + _redact_trace_messages([dict(m) for m in agg_messages]) + if getattr(self, "_privacy_mode", "") + else agg_messages + ) self._pending_trace["aggregator_label"] = _slot_label(aggregator) # The aggregator is the acting model. Resolve its slot to the provider's # real runtime (base_url/api_key/api_mode) and call it through the same @@ -1070,7 +1233,15 @@ class MoAChatCompletions: from hermes_cli.config import load_config from hermes_cli.moa_config import resolve_moa_preset - preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name) + _moa_raw = load_config().get("moa") or {} + preset = resolve_moa_preset(_moa_raw, self.preset_name) + # Privacy filter mode: '' (off, default) | 'display' | 'full'. See + # coerce_privacy_filter / the pattern block at the top of this module. + # Remembered on self so _call_prepared_aggregator (which may run on a + # later prepared-request call without re-reading config) redacts the + # trace's aggregator input consistently with this turn's fan-out. + privacy_mode = _moa_privacy_mode(_moa_raw) + self._privacy_mode = privacy_mode messages = list(api_kwargs.get("messages") or []) reference_models = preset.get("reference_models") or [] aggregator = preset.get("aggregator") or {} @@ -1121,9 +1292,29 @@ class MoAChatCompletions: # start, then let the acting model work). Implemented by hashing only # the prefix up to the LAST USER message so mid-turn growth doesn't # change the signature — iteration 2+ becomes a cache HIT. + # "every_n:" (N >= 2): the middle ground (issue #63393 — advisor + # fan-out multiplies latency/cost by the tool-iteration count). + # Advisors run on iteration 1 of a user turn and then every Nth tool + # iteration; the iterations in between REUSE the cached guidance from + # the last on-cadence run (same mechanism as user_turn's cache HIT — + # the aggregator still gets advice every iteration, it's just not + # refreshed against the very latest tool results). The iteration + # counter is scoped per user turn and resets on a new user message, + # so every turn starts with fresh advice. fanout_mode = str(preset.get("fanout") or "per_iteration").strip().lower() + every_n = 0 + if fanout_mode.startswith("every_n:"): + try: + every_n = int(fanout_mode.split(":", 1)[1]) + except (TypeError, ValueError): + every_n = 0 + if every_n < 2: + # Unparseable / degenerate cadence degrades to the default, + # mirroring _coerce_fanout's tolerant-read contract. + fanout_mode = "per_iteration" sig_messages = ref_messages - if fanout_mode == "user_turn": + turn_prefix = ref_messages + if fanout_mode in ("user_turn",) or every_n >= 2: # Find the last REAL user message. The advisory view appends a # synthetic user marker (_ADVISORY_INSTRUCTION) when it ends on an # assistant turn — i.e. on every tool iteration after the first — @@ -1138,19 +1329,51 @@ class MoAChatCompletions: last_user_idx = _i break if last_user_idx is not None: - sig_messages = ref_messages[: last_user_idx + 1] + turn_prefix = ref_messages[: last_user_idx + 1] + if fanout_mode == "user_turn": + sig_messages = turn_prefix + + def _hash_messages(msgs: list[dict[str, Any]]) -> str: + return hashlib.sha256( + "\u0000".join( + f"{m.get('role')}:{m.get('content')}" for m in msgs + ).encode("utf-8", "replace") + ).hexdigest() + + # every_n cadence bookkeeping: advance the per-turn iteration counter + # only when the advisory STATE actually advanced (a redundant create() + # with identical state — e.g. a streaming retry — must not consume a + # cadence slot), and reset it whenever the user-turn prefix changes. + _every_n_reuse = False + if every_n >= 2: + _turn_sig = _hash_messages(turn_prefix) + if _turn_sig != self._fanout_turn_sig: + self._fanout_turn_sig = _turn_sig + self._fanout_iteration_count = 0 + self._fanout_last_state_sig = None + _state_sig = _hash_messages(ref_messages) + if _state_sig != self._fanout_last_state_sig: + self._fanout_last_state_sig = _state_sig + self._fanout_iteration_count += 1 + # Iteration 1 is on-cadence; then every Nth iteration after it. + _on_cadence = (self._fanout_iteration_count - 1) % every_n == 0 + _every_n_reuse = not _on_cadence and bool(self._ref_cache_outputs) # Turn-scoped cache: only run + display references when the advisory # view changed (i.e. a new user turn). Within one turn the agent loop # calls create() once per tool iteration; in user_turn mode the # signature is stable across those iterations (prefix hash above), so # the fan-out runs once per user turn and iterations reuse the advice. - _sig = hashlib.sha256( - "\u0000".join( - f"{m.get('role')}:{m.get('content')}" for m in sig_messages - ).encode("utf-8", "replace") - ).hexdigest() + _sig = _hash_messages(sig_messages) _cache_key = (self.preset_name, _sig, tuple(_slot_label(s) for s in reference_models)) + if _every_n_reuse: + # Off-cadence every_n iteration: pin the key to the last + # on-cadence run so the lookup below is a HIT and its guidance is + # reused (no advisor calls, no double accounting, no re-emit) — + # exactly the user_turn cache-HIT path. When the cache is empty + # (defensive; a new turn resets the counter to on-cadence) the + # flag above stays False and the references run normally. + _cache_key = self._ref_cache_key _refs_from_cache = _cache_key == self._ref_cache_key and bool(self._ref_cache_outputs) if _refs_from_cache: @@ -1197,9 +1420,19 @@ class MoAChatCompletions: # built; the aggregator OUTPUT is stitched in by the caller # (consume_and_save_trace) once the response resolves — the caller # holds the live session_id and the resolved aggregator response. + # Traces are a persisted, user-readable surface, so ANY active + # privacy mode ('display' or 'full') redacts the advisor text and + # the full per-advisor input/output carried by _RefAccounting. + if privacy_mode: + _trace_refs = [ + (label, _redact_reference_text(text), _redact_trace_accounting(acct)) + for label, text, acct in reference_outputs + ] + else: + _trace_refs = list(reference_outputs) self._pending_trace = { "preset": self.preset_name, - "reference_outputs": list(reference_outputs), + "reference_outputs": _trace_refs, "aggregator_slot": aggregator, "aggregator_temperature": aggregator_temperature, } @@ -1209,7 +1442,10 @@ class MoAChatCompletions: # actually ran them). The user sees one labelled block per # reference (rendered like a thinking block) so the MoA process is # visible rather than a silent pause. Best-effort: never blocks the - # turn. + # turn. Reference blocks are a user-visible surface: both privacy + # modes redact them (the cache keeps the RAW text — redaction + # always happens at the consuming surface, so a mid-session mode + # change never leaks or double-redacts). _ref_count = len(reference_outputs) for _idx, (_label, _text, _usage) in enumerate(reference_outputs, start=1): self._emit( @@ -1217,7 +1453,7 @@ class MoAChatCompletions: index=_idx, count=_ref_count, label=_label, - text=_text, + text=_redact_reference_text(_text) if privacy_mode else _text, ) if _ref_count: self._emit( @@ -1229,15 +1465,25 @@ class MoAChatCompletions: guidance: str | None = None agg_messages = [dict(m) for m in messages] if reference_outputs: + # 'full' privacy mode: redact the advisor text that reaches the + # AGGREGATOR too (issue #59959's literal ask). 'display' leaves + # the aggregator input raw so synthesis quality is unaffected. + # The redaction is applied to a per-call copy — the cache always + # holds raw advisor text (see the emit comment above). + _agg_refs = ( + _redact_reference_outputs(reference_outputs) + if privacy_mode == "full" + else reference_outputs + ) joined = "\n\n".join( f"Reference {idx} — {label}:\n{text}" - for idx, (label, text, _usage) in enumerate(reference_outputs, start=1) + for idx, (label, text, _usage) in enumerate(_agg_refs, start=1) ) guidance = ( "[Mixture of Agents reference context]\n" f"Preset: {self.preset_name}\n" f"Aggregator/acting model: {_slot_label(aggregator)}\n" - f"References: {', '.join(label for label, _, _ in reference_outputs)}\n\n" + f"References: {', '.join(label for label, _, _ in _agg_refs)}\n\n" "Use the reference responses below as private context. You are the aggregator and acting model: " "answer the user directly or call tools as needed.\n\n" f"{joined}" diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index 9353587fe72..cfa6826fe1b 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -68,9 +68,59 @@ def _coerce_int_or_none(value: Any) -> int | None: def _coerce_fanout(value: Any) -> str: - """Normalize the fan-out cadence; unknown values fall back to default.""" + """Normalize the fan-out cadence; unknown values fall back to default. + + Canonical values are the strings ``per_iteration``, ``user_turn``, and + ``every_n:`` (N >= 2). The ``every_n`` cadence also accepts the mapping + form ``{mode: every_n, n: N}`` from hand-edited YAML and normalizes it to + the canonical string, so the rest of the pipeline (presets, flattened + view, runtime) only ever sees one shape. ``every_n:1`` means "run every + iteration" and collapses to ``per_iteration``; anything unparseable falls + back to ``per_iteration`` (the tolerant-read contract of this module). + """ + if isinstance(value, dict): + # Mapping form: {mode: every_n, n: 3}. Non-every_n mapping modes fall + # through to the string path below (e.g. {mode: user_turn}). + mode = str(value.get("mode") or "").strip().lower() + if mode == "every_n": + n = _coerce_int(value.get("n"), 0) + return f"every_n:{n}" if n >= 2 else "per_iteration" + value = mode mode = str(value or "").strip().lower() - return mode if mode in {"per_iteration", "user_turn"} else "per_iteration" + if mode in {"per_iteration", "user_turn"}: + return mode + if mode.startswith("every_n"): + _, sep, rest = mode.partition(":") + n = _coerce_int(rest.strip(), 0) if sep else 0 + if n >= 2: + return f"every_n:{n}" + return "per_iteration" + + +def coerce_privacy_filter(value: Any) -> str: + """Normalize ``moa.privacy_filter`` to '' (off), 'display', or 'full'. + + - ``''`` (empty string): filter off — the default. ``false``/``None``/ + unknown values land here so a hand-edited config degrades to prior + behavior (tolerant-read contract). + - ``'display'``: redact user-visible surfaces only — the reference blocks + shown in the UI and the saved MoA trace records. The aggregator still + sees raw advisor text, so answer quality is unaffected. + - ``'full'``: additionally redact the advisor text injected into the + aggregator prompt (issue #59959's literal ask). A hand-edited boolean + ``true`` maps here because the issue framed the toggle as "redact + before passing to the aggregator". + """ + if value is True: + return "full" + if value is None or value is False: + return "" + mode = str(value).strip().lower() + if mode in {"display", "full"}: + return mode + if mode in {"true", "on", "yes", "1"}: + return "full" + return "" def _clean_reasoning_effort(value: Any) -> str | None: @@ -246,7 +296,11 @@ def _normalize_preset(raw: Any) -> dict[str, Any]: # iteration, so advice tracks live task state. "user_turn" runs the # advisors ONCE per user turn (the original MoA shape): the # aggregator gets their upfront plan-level advice, then acts alone - # for the rest of the tool loop. + # for the rest of the tool loop. "every_n:" (N >= 2) is the middle + # ground: advisors run on the first iteration of each user turn and + # every Nth tool iteration after it; in-between iterations reuse the + # cached guidance from the last advisor run. Also accepts the mapping + # form {mode: every_n, n: N}, normalized to the canonical string. "fanout": _coerce_fanout(raw.get("fanout")), } @@ -296,6 +350,10 @@ def normalize_moa_config(raw: Any) -> dict[str, Any]: "reference_max_tokens": active.get("reference_max_tokens"), "fanout": active.get("fanout", "per_iteration"), "enabled": active["enabled"], + # MoA-level (not per-preset) toggles ride at the top level alongside + # save_traces. privacy_filter: '' (off, default) | 'display' | 'full' + # — see coerce_privacy_filter for the semantics of each mode. + "privacy_filter": coerce_privacy_filter(raw.get("privacy_filter")), } diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 2b4ae54c627..ee16f873050 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -583,3 +583,74 @@ def test_slot_max_tokens_absent_by_default(): ) ref = cfg["presets"]["p"]["reference_models"][0] assert "max_tokens" not in ref + + +# --- fanout cadence normalization (every_n) --- + + +def test_fanout_defaults_to_per_iteration(): + cfg = normalize_moa_config({}) + assert cfg["fanout"] == "per_iteration" + + +def test_fanout_every_n_string_form_normalized(): + cfg = normalize_moa_config({"fanout": "every_n:3"}) + assert cfg["fanout"] == "every_n:3" + assert cfg["presets"][DEFAULT_MOA_PRESET_NAME]["fanout"] == "every_n:3" + + +def test_fanout_every_n_mapping_form_normalized_to_string(): + cfg = normalize_moa_config({"fanout": {"mode": "every_n", "n": 4}}) + assert cfg["fanout"] == "every_n:4" + + +def test_fanout_every_n_degenerate_n_falls_back(): + # n=1 means "every iteration" — that IS per_iteration; n=0 / negative / + # garbage must never produce a broken cadence string. + assert normalize_moa_config({"fanout": "every_n:1"})["fanout"] == "per_iteration" + assert normalize_moa_config({"fanout": "every_n:0"})["fanout"] == "per_iteration" + assert normalize_moa_config({"fanout": "every_n:-2"})["fanout"] == "per_iteration" + assert normalize_moa_config({"fanout": "every_n:x"})["fanout"] == "per_iteration" + assert normalize_moa_config({"fanout": "every_n"})["fanout"] == "per_iteration" + assert normalize_moa_config({"fanout": {"mode": "every_n"}})["fanout"] == "per_iteration" + + +def test_fanout_every_n_round_trips_through_normalize(): + once = normalize_moa_config({"fanout": "every_n:3"}) + twice = normalize_moa_config(once) + assert twice["fanout"] == "every_n:3" + assert twice["presets"][DEFAULT_MOA_PRESET_NAME]["fanout"] == "every_n:3" + + +def test_fanout_mapping_user_turn_mode_accepted(): + cfg = normalize_moa_config({"fanout": {"mode": "user_turn"}}) + assert cfg["fanout"] == "user_turn" + + +# --- privacy_filter normalization --- + + +def test_privacy_filter_defaults_off(): + cfg = normalize_moa_config({}) + assert cfg["privacy_filter"] == "" + + +def test_privacy_filter_modes_normalized(): + from hermes_cli.moa_config import coerce_privacy_filter + + assert coerce_privacy_filter("display") == "display" + assert coerce_privacy_filter("FULL") == "full" + assert coerce_privacy_filter(True) == "full" # legacy boolean → issue #59959 ask + assert coerce_privacy_filter("true") == "full" + assert coerce_privacy_filter(False) == "" + assert coerce_privacy_filter(None) == "" + assert coerce_privacy_filter("bogus") == "" + assert coerce_privacy_filter("off") == "" + + +def test_privacy_filter_round_trips_through_normalize(): + once = normalize_moa_config({"privacy_filter": "display"}) + assert once["privacy_filter"] == "display" + assert normalize_moa_config(once)["privacy_filter"] == "display" + full = normalize_moa_config({"privacy_filter": "full"}) + assert normalize_moa_config(full)["privacy_filter"] == "full" diff --git a/tests/run_agent/test_moa_fanout_cadence.py b/tests/run_agent/test_moa_fanout_cadence.py new file mode 100644 index 00000000000..09cae419ee3 --- /dev/null +++ b/tests/run_agent/test_moa_fanout_cadence.py @@ -0,0 +1,222 @@ +"""every_n fanout cadence: advisors refresh every Nth tool iteration and +off-cadence iterations reuse the cached guidance from the last on-cadence run. + +Redesigned from PR #63448's intent (issue #63393 — advisor fan-out multiplies +turn latency/cost by the tool-iteration count). Unlike the submitted shape +(which dropped references entirely on off-cadence iterations), off-cadence +iterations here still feed the aggregator the LAST advisor guidance via the +same cache-reuse mechanism the user_turn fanout uses. +""" + +from types import SimpleNamespace + + +def _response(content="done", *, tool_calls=None): + message = SimpleNamespace(content=content, tool_calls=tool_calls or []) + choice = SimpleNamespace(message=message, finish_reason="stop") + return SimpleNamespace(choices=[choice], usage=None, model="fake-model") + + +def _cadence_config(home, fanout="every_n:3"): + home.mkdir() + (home / "config.yaml").write_text( + f""" +moa: + default_preset: review + presets: + review: + fanout: "{fanout}" + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: openrouter + model: anthropic/claude-opus-4.8 +""".strip(), + encoding="utf-8", + ) + + +def _install_fake_llm(monkeypatch, ref_runs): + def fake_call_llm(**kwargs): + if kwargs["task"] == "moa_reference": + ref_runs.append(kwargs["model"]) + return _response(f"advice #{len(ref_runs)}") + return _response("acted") + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + + +def _iteration_messages(base, iterations): + """Yield message lists simulating a growing tool loop: the base user turn, + then one new (assistant tool_call, tool result) pair per iteration.""" + msgs = list(base) + yield list(msgs) + for i in range(1, iterations): + msgs = msgs + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": f"c{i}", "function": {"name": "f", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": f"c{i}", "content": f"result {i}"}, + ] + yield list(msgs) + + +def test_every_n_cadence_runs_references_every_nth_iteration(monkeypatch, tmp_path): + """With every_n:3, references run on iterations 1 and 4 of a 6-iteration + tool loop (1 on-cadence, then every 3rd), not on all 6.""" + home = tmp_path / ".hermes" + _cadence_config(home, "every_n:3") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + events = [] + facade = MoAChatCompletions("review", reference_callback=lambda ev, **kw: events.append(ev)) + base = [{"role": "user", "content": "do the thing"}] + for msgs in _iteration_messages(base, 6): + facade.create(messages=msgs, tools=[{"type": "function"}]) + + # 1 reference model × iterations {1, 4} on-cadence = 2 advisor runs. + assert len(ref_runs) == 2 + # Display blocks only surface when references actually ran. + assert events.count("moa.reference") == 2 + assert events.count("moa.aggregating") == 2 + + +def test_every_n_off_cadence_iterations_reuse_cached_guidance(monkeypatch, tmp_path): + """Off-cadence iterations must still give the aggregator the last + on-cadence advisor guidance (cache reuse), not run advisor-less.""" + home = tmp_path / ".hermes" + _cadence_config(home, "every_n:3") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + facade = MoAChatCompletions("review") + base = [{"role": "user", "content": "task"}] + prepared = [ + facade.create(messages=msgs, tools=[], _moa_prepare_only=True) + for msgs in _iteration_messages(base, 3) + ] + + # Iteration 1 ran the references; iterations 2-3 are off-cadence. + assert len(ref_runs) == 1 + # Every iteration's aggregator request carries reference guidance... + assert all(p["guidance"] for p in prepared) + # ...and the off-cadence ones reuse iteration 1's exact advice text. + assert "advice #1" in prepared[0]["guidance"] + assert prepared[1]["guidance"] == prepared[0]["guidance"] + assert prepared[2]["guidance"] == prepared[0]["guidance"] + + +def test_every_n_off_cadence_does_not_double_charge_usage(monkeypatch, tmp_path): + """Cache-reuse iterations must not re-report advisor usage/cost.""" + home = tmp_path / ".hermes" + _cadence_config(home, "every_n:2") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + facade = MoAChatCompletions("review") + base = [{"role": "user", "content": "task"}] + it = _iteration_messages(base, 2) + + facade.create(messages=next(it), tools=[]) + usage1, _cost1 = facade.consume_reference_usage() + + facade.create(messages=next(it), tools=[]) # off-cadence: reuse + usage2, cost2 = facade.consume_reference_usage() + + assert len(ref_runs) == 1 + # The reuse iteration reports zero advisor usage and no cost. + assert usage2.input_tokens == 0 and usage2.output_tokens == 0 + assert cost2 is None + assert usage1 is not usage2 + + +def test_every_n_counter_resets_on_new_user_turn(monkeypatch, tmp_path): + """A new user message starts a new turn: iteration 1 is on-cadence again, + regardless of where the previous turn's counter stood.""" + home = tmp_path / ".hermes" + _cadence_config(home, "every_n:3") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + facade = MoAChatCompletions("review") + turn1 = [{"role": "user", "content": "turn one"}] + msgs: list = turn1 + for msgs in _iteration_messages(turn1, 2): + facade.create(messages=msgs, tools=[]) + assert len(ref_runs) == 1 # iteration 2 was off-cadence + + # New user turn appended after the tool loop → counter resets, advisors + # run immediately (fresh advice for the fresh request). + turn2 = msgs + [ + {"role": "assistant", "content": "done with turn one"}, + {"role": "user", "content": "turn two"}, + ] + facade.create(messages=turn2, tools=[]) + assert len(ref_runs) == 2 + + +def test_every_n_redundant_create_does_not_consume_cadence_slot(monkeypatch, tmp_path): + """A repeat create() with IDENTICAL state (e.g. a streaming retry) must not + advance the cadence counter — only real state changes count.""" + home = tmp_path / ".hermes" + _cadence_config(home, "every_n:2") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + facade = MoAChatCompletions("review") + base = [{"role": "user", "content": "task"}] + it = _iteration_messages(base, 3) + + first = next(it) + facade.create(messages=first, tools=[]) + facade.create(messages=first, tools=[]) # retry: same state, no slot used + assert facade._fanout_iteration_count == 1 + + facade.create(messages=next(it), tools=[]) # iteration 2: off-cadence + facade.create(messages=next(it), tools=[]) # iteration 3: on-cadence (every 2nd) + assert len(ref_runs) == 2 + + +def test_per_iteration_default_unchanged_by_cadence_state(monkeypatch, tmp_path): + """Default fanout still re-runs references on every state change.""" + home = tmp_path / ".hermes" + _cadence_config(home, "per_iteration") + monkeypatch.setenv("HERMES_HOME", str(home)) + + ref_runs = [] + _install_fake_llm(monkeypatch, ref_runs) + + from agent.moa_loop import MoAChatCompletions + + facade = MoAChatCompletions("review") + base = [{"role": "user", "content": "task"}] + for msgs in _iteration_messages(base, 3): + facade.create(messages=msgs, tools=[]) + + assert len(ref_runs) == 3 diff --git a/website/docs/user-guide/features/mixture-of-agents.md b/website/docs/user-guide/features/mixture-of-agents.md index 151a066dda4..d2d720bc391 100644 --- a/website/docs/user-guide/features/mixture-of-agents.md +++ b/website/docs/user-guide/features/mixture-of-agents.md @@ -132,6 +132,66 @@ moa: Leave it unset (or `0`/blank) to keep the prior uncapped behavior. +### Advisor cadence with `fanout` + +By default the advisors re-run on **every tool iteration** (`fanout: +per_iteration`), so their advice always tracks the latest tool results — at +the cost of multiplying advisor latency and spend by the number of tool calls +in a turn. Two alternative cadences trade freshness for speed: + +- `fanout: user_turn` — advisors run **once per user turn**; every tool + iteration after that reuses the same upfront advice (the original MoA + shape: synthesize a plan, then let the acting model work). +- `fanout: every_n:3` — the middle ground: advisors run on the **first** + iteration of each user turn and then every **3rd** tool iteration (any + `N >= 2` works). Iterations in between reuse the cached guidance from the + last advisor run, so the aggregator still gets advice on every step — it is + just refreshed every N steps instead of every step. The counter resets on + each new user message, so every turn starts with fresh advice. The mapping + form `fanout: {mode: every_n, n: 3}` is also accepted and normalized to + the string form. + +```yaml +moa: + presets: + fast: + reference_models: + - provider: openrouter + model: anthropic/claude-opus-4.8 + aggregator: + provider: openrouter + model: openai/gpt-5.5 + fanout: every_n:3 # advisors refresh every 3rd tool iteration +``` + +Unknown or malformed values fall back to `per_iteration`. + +### Privacy filter for advisor outputs + +Advisor outputs can echo sensitive data from the conversation — emails, +formatted phone numbers, API keys, JWTs — into the reference blocks shown in +the UI, saved MoA traces, and the aggregator prompt. `moa.privacy_filter` +(off by default) redacts those surfaces: + +```yaml +moa: + privacy_filter: display # or: full +``` + +- `display` — redacts **user-visible surfaces only**: the labelled reference + blocks rendered in the UI and the records written by `save_traces`. The + aggregator still receives the raw advisor text, so answer quality is + unaffected. +- `full` — additionally redacts the advisor text injected into the + aggregator prompt (and the one-shot `/moa` synthesis input). + +Credential shapes (API-key prefixes, JWTs, private keys, DB connection +strings) are masked by Hermes' central secret redactor; the MoA filter adds +email and clearly formatted phone-number redaction on top. Patterns are +deliberately conservative for code-review-style advice: bare digit runs, line +numbers, timestamps, git SHAs, and IP addresses are never touched — only +delimited phone formats like `(555) 123-4567` or `555-123-4567` match. + ### Per-slot reasoning effort Reference and aggregator slots may also set `reasoning_effort`. Use this when From f7b90e6f80dd1fe451e8fe309b7aca9650f39753 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:48:49 -0700 Subject: [PATCH 050/552] feat(moa): add privacy redaction filter with display/full modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds moa.privacy_filter ('' | display | full, default off — issue #59959): - display: redact user-visible surfaces only (reference blocks emitted to the UI + saved MoA trace records, including per-advisor full input/output and the aggregator-input copy); the aggregator sees raw advisor text so synthesis quality is unaffected. - full: additionally redact the advisor text injected into the aggregator prompt, on both the persistent facade path and the one-shot /moa synthesis path (the issue's literal ask). Legacy boolean true maps here. Secret/credential shapes (API-key prefixes, JWTs, private keys, DB connection strings) are delegated to the central redactor (agent.redact.redact_sensitive_text, force=True + code_file=True); the MoA filter adds only email and clearly delimited phone-number patterns. No bare 10-digit matching: line numbers, timestamps, epoch values, git SHAs, IPs, versions, and source-code assignments in code-review-shaped advisory text pass through byte-identical. The reference cache always holds raw text — redaction happens at each consuming surface, so a mid-session mode change never leaks or double-redacts. Reworked from PR #60463: replaced its hand-rolled pattern list (which matched bare digit runs and re-implemented key shapes) with central- redactor reuse + safe patterns, and split the single boolean into display/full modes. Credited for the feature framing. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> --- hermes_cli/config.py | 10 + tests/run_agent/test_moa_privacy_filter.py | 207 +++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/run_agent/test_moa_privacy_filter.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 765891e2db3..912472f86b6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2431,6 +2431,16 @@ DEFAULT_CONFIG = { # override the output directory. "save_traces": False, "trace_dir": "", + # Privacy redaction filter for advisor (reference) outputs. Advisors + # can echo PII from the conversation (emails, formatted phone numbers) + # and credential shapes into reference blocks, traces, and the + # aggregator prompt. Modes ('' = off, the default): + # "display" — redact user-visible surfaces only (reference blocks + # shown in the UI + saved MoA trace records); the + # aggregator still sees raw advisor text. + # "full" — additionally redact the advisor text injected into + # the aggregator prompt (issue #59959). + "privacy_filter": "", "presets": { "default": { "reference_models": [ diff --git a/tests/run_agent/test_moa_privacy_filter.py b/tests/run_agent/test_moa_privacy_filter.py new file mode 100644 index 00000000000..a3fe38c7e70 --- /dev/null +++ b/tests/run_agent/test_moa_privacy_filter.py @@ -0,0 +1,207 @@ +"""MoA privacy redaction filter (config: moa.privacy_filter — display | full). + +Reworked from PR #60463 (issue #59959). Secret/credential shapes are handled +by the central redactor (agent.redact.redact_sensitive_text); the MoA filter +adds conservative email + formatted-phone patterns and wires two modes: + + display — redact user-visible surfaces only (reference display events + + saved MoA traces); the aggregator sees raw advisor text. + full — additionally redact the advisor text injected into the + aggregator prompt (issue #59959's literal ask). + (off) — default: everything passes through raw. +""" + +from types import SimpleNamespace + +from agent.moa_loop import _redact_reference_text + + +def _response(content="done", *, tool_calls=None): + message = SimpleNamespace(content=content, tool_calls=tool_calls or []) + choice = SimpleNamespace(message=message, finish_reason="stop") + return SimpleNamespace(choices=[choice], usage=None, model="fake-model") + + +# --- pattern behavior ------------------------------------------------------- + + +def test_redacts_email_addresses(): + out = _redact_reference_text("contact jane.doe+dev@example.co.uk for access") + assert "jane.doe" not in out + assert "[redacted email]" in out + + +def test_redacts_formatted_phone_numbers(): + for phone in ["(555) 123-4567", "555-123-4567", "555.123.4567", "+1 555-123-4567"]: + out = _redact_reference_text(f"call {phone} today") + assert phone not in out, phone + assert "[redacted phone]" in out, phone + + +def test_redacts_api_keys_and_jwts_via_central_redactor(): + out = _redact_reference_text( + "key sk-proj-abc123def456ghi789jkl012 and token " + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + ) + assert "sk-proj-abc123def456ghi789jkl012" not in out + assert "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" not in out + + +def test_does_not_mangle_code_review_shaped_text(): + """Advisory text is often code-review shaped. Line numbers, timestamps, + bare digit runs, git SHAs, IPs, versions, and source-code assignments must + survive redaction byte-identical — a false positive here corrupts the + guidance the aggregator acts on.""" + text = ( + "Line 1274: off-by-one at src/moa_loop.py:1274\n" + "commit 3428e70c599cbfbe240172b4ee1118dbc18a78ca\n" + "Date: 2026-07-12 12:34:56 +0000\n" + "epoch 1234567890, id 5551234567, port 127.0.0.1:8080\n" + "version 1.2.3-4567\n" + "MAX_TOKENS=4096\n" + '"apiKey": "test-fixture"\n' + "timeout = 1234567890\n" + ) + assert _redact_reference_text(text) == text + + +def test_git_log_author_emails_are_redacted_but_line_survives(): + """Emails ARE redacted (they're the PII class the filter exists for), but + the surrounding git-log structure must stay intact and parseable.""" + out = _redact_reference_text("Author: Jane Doe fixed the bug") + assert "jane@example.com" not in out + assert out == "Author: Jane Doe <[redacted email]> fixed the bug" + + +def test_non_string_input_passes_through(): + assert _redact_reference_text(None) is None + assert _redact_reference_text(42) == 42 + assert _redact_reference_text("") == "" + + +# --- mode wiring ------------------------------------------------------------ + + +SENSITIVE_ADVICE = "email ceo@example.com, phone (555) 867-5309, proceed" + + +def _privacy_config(home, privacy_filter): + home.mkdir() + line = f" privacy_filter: {privacy_filter}\n" if privacy_filter is not None else "" + (home / "config.yaml").write_text( + f""" +moa: +{line} default_preset: review + presets: + review: + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: openrouter + model: anthropic/claude-opus-4.8 +""".strip(), + encoding="utf-8", + ) + + +def _install_fake_llm(monkeypatch): + calls = [] + + def fake_call_llm(**kwargs): + calls.append(kwargs) + if kwargs["task"] == "moa_reference": + return _response(SENSITIVE_ADVICE) + return _response("acted") + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + return calls + + +def _run_facade(monkeypatch, tmp_path, privacy_filter): + home = tmp_path / ".hermes" + _privacy_config(home, privacy_filter) + monkeypatch.setenv("HERMES_HOME", str(home)) + _install_fake_llm(monkeypatch) + + from agent.moa_loop import MoAChatCompletions + + events = [] + facade = MoAChatCompletions( + "review", reference_callback=lambda ev, **kw: events.append((ev, kw)) + ) + prepared = facade.create( + messages=[{"role": "user", "content": "review this"}], + tools=[], + _moa_prepare_only=True, + ) + ref_events = [kw for ev, kw in events if ev == "moa.reference"] + return prepared, ref_events, facade + + +def test_privacy_off_by_default_everything_raw(monkeypatch, tmp_path): + prepared, ref_events, _ = _run_facade(monkeypatch, tmp_path, None) + assert "ceo@example.com" in prepared["guidance"] + assert "ceo@example.com" in ref_events[0]["text"] + + +def test_display_mode_redacts_display_but_not_aggregator(monkeypatch, tmp_path): + prepared, ref_events, facade = _run_facade(monkeypatch, tmp_path, "display") + # User-visible reference block: redacted. + assert "ceo@example.com" not in ref_events[0]["text"] + assert "[redacted email]" in ref_events[0]["text"] + assert "(555) 867-5309" not in ref_events[0]["text"] + # Aggregator input: raw (synthesis quality unaffected). + assert "ceo@example.com" in prepared["guidance"] + # Pending trace surface: redacted (traces persist to disk). + trace_refs = facade._pending_trace["reference_outputs"] + assert all("ceo@example.com" not in text for _l, text, _a in trace_refs) + + +def test_full_mode_redacts_aggregator_input_too(monkeypatch, tmp_path): + prepared, ref_events, _ = _run_facade(monkeypatch, tmp_path, "full") + assert "ceo@example.com" not in prepared["guidance"] + assert "[redacted email]" in prepared["guidance"] + assert "(555) 867-5309" not in prepared["guidance"] + assert "ceo@example.com" not in ref_events[0]["text"] + # The redacted guidance is what reaches the aggregator request messages. + joined_content = "".join( + str(m.get("content")) for m in prepared["messages"] + ) + assert "ceo@example.com" not in joined_content + + +def test_legacy_boolean_true_maps_to_full(monkeypatch, tmp_path): + prepared, _ref_events, _ = _run_facade(monkeypatch, tmp_path, "true") + assert "ceo@example.com" not in prepared["guidance"] + + +def test_cache_keeps_raw_text_redaction_applied_per_surface(monkeypatch, tmp_path): + """The reference cache must hold RAW advisor text — redaction happens at + each consuming surface. Otherwise a mid-session mode change would leak + (cache pre-redacted with weaker mode) or double-redact.""" + _prepared, _ref_events, facade = _run_facade(monkeypatch, tmp_path, "full") + assert any("ceo@example.com" in text for _l, text, _a in facade._ref_cache_outputs) + + +def test_full_mode_covers_one_shot_aggregate_moa_context(monkeypatch, tmp_path): + """The /moa one-shot synthesis path also honors full mode.""" + home = tmp_path / ".hermes" + _privacy_config(home, "full") + monkeypatch.setenv("HERMES_HOME", str(home)) + calls = _install_fake_llm(monkeypatch) + + from agent.moa_loop import aggregate_moa_context + + aggregate_moa_context( + user_prompt="review this", + api_messages=[{"role": "user", "content": "review this"}], + reference_models=[{"provider": "openai-codex", "model": "gpt-5.5"}], + aggregator={"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + ) + + agg_calls = [c for c in calls if c["task"] == "moa_aggregator"] + assert agg_calls, "aggregator synthesis call expected" + agg_input = str(agg_calls[0]["messages"]) + assert "ceo@example.com" not in agg_input + assert "[redacted email]" in agg_input From 30bb55588fc05dc2afea9fdeef8d8f5fe016cb72 Mon Sep 17 00:00:00 2001 From: Gabriel Steenhoek Date: Tue, 14 Jul 2026 16:11:34 -0500 Subject: [PATCH 051/552] fix(gateway): retry detached restart watcher without breakaway The Windows /restart watcher's outer Popen spawns the watcher with windows_detach_popen_kwargs() (which carries CREATE_BREAKAWAY_FROM_JOB), but a restrictive parent job object can reject that bit with OSError and the current call has no retry. Preserve the current watcher implementation and add a focused breakaway-denied fallback. Preserved from current main: watcher_python / pythonw.exe selection, the str(restart_after_s) deadline, the scrubbed watcher_env, the intentional no-breakaway inline respawn, and the entire POSIX setsid/bash path. - primary keeps **windows_detach_popen_kwargs() - on OSError, retry the same argv/env with creationflags=windows_detach_flags_without_breakaway() - on dual failure, log a definitive, path-safe warning (interpreter basename + numeric winerror/errno only) and return without crashing Replace the superseded breakaway-first inline design and its AST tests with focused behavioral coverage that drives the real coroutine with a mocked subprocess.Popen (retry, argv/env/DEVNULL preservation, POSIX single-session kwarg, no-breakaway inline respawn, secret-safe logging). Co-Authored-By: Claude Opus 4.8 --- contributors/emails/phixxation@gmail.com | 1 + gateway/run.py | 67 ++++++- tests/tools/test_windows_native_support.py | 198 +++++++++++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 contributors/emails/phixxation@gmail.com diff --git a/contributors/emails/phixxation@gmail.com b/contributors/emails/phixxation@gmail.com new file mode 100644 index 00000000000..1bc5a8ff69f --- /dev/null +++ b/contributors/emails/phixxation@gmail.com @@ -0,0 +1 @@ +VerbalChainsaw diff --git a/gateway/run.py b/gateway/run.py index 1a3be8a897a..99cc8a90c70 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6970,7 +6970,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # that triggered the /restart command closing its console. if sys.platform == "win32": import textwrap - from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + from hermes_cli._subprocess_compat import ( + windows_detach_flags_without_breakaway, + windows_detach_popen_kwargs, + ) cmd_argv = [*hermes_cmd, "gateway", "restart"] watcher = textwrap.dedent( @@ -7042,13 +7045,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if watcher_env.get("PYTHONPATH"): pythonpath.append(watcher_env["PYTHONPATH"]) watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) - subprocess.Popen( - [watcher_python, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=watcher_env, - **windows_detach_popen_kwargs(), - ) + watcher_argv = [ + watcher_python, + "-c", + watcher, + str(current_pid), + str(restart_after_s), + *cmd_argv, + ] + # The watcher process must itself break away from any job object the + # parent CLI lives in (Electron/Tauri-wrapped Hermes Desktop, Windows + # Terminal, schtasks shells); otherwise it is reaped when the CLI + # exits and the gateway never respawns. windows_detach_popen_kwargs() + # carries CREATE_BREAKAWAY_FROM_JOB, but a restrictive job object + # (no JOB_OBJECT_LIMIT_BREAKAWAY_OK) rejects that bit with + # ERROR_ACCESS_DENIED, surfaced as OSError. Retry once without the + # breakaway bit, preserving argv and the scrubbed watcher_env. + # Mirrors the canonical fallback in + # hermes_cli/gateway_windows.py::_spawn_detached. + try: + subprocess.Popen( + watcher_argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=watcher_env, + **windows_detach_popen_kwargs(), + ) + except OSError: + try: + subprocess.Popen( + watcher_argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=watcher_env, + creationflags=windows_detach_flags_without_breakaway(), + ) + except OSError as exc: + # Both spawn attempts failed (a breakaway-denying job object + # is the common cause, but OSError covers others too). + # Record a minimal, path-safe diagnostic and return without + # crashing the caller: state plainly that no watcher was + # started, and log only the interpreter basename and a + # numeric error code — never argv, env, watcher source, or + # str(exc) (which can carry a full interpreter path for a + # FileNotFoundError). + winerror = getattr(exc, "winerror", None) + error_code = winerror if winerror is not None else exc.errno + error_field = "winerror" if winerror is not None else "errno" + logger.warning( + "Detached restart watcher was not started after the " + "no-breakaway retry (%s; %s=%r). The gateway will not " + "be respawned by this restart attempt.", + os.path.basename(watcher_python), + error_field, + error_code, + ) return cmd = " ".join(shlex.quote(part) for part in hermes_cmd) diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 58bbc007a50..b4187cf02a0 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -11,8 +11,10 @@ Windows runner. from __future__ import annotations +import asyncio import os import signal +import subprocess import sys from pathlib import Path from unittest import mock @@ -1139,3 +1141,199 @@ class TestWindowlessGatewayRestartSpec: assert cwd == "C:/hermes" assert env["VIRTUAL_ENV"] == str(Path("C:/venv")) assert "PYTHONPATH" in env + + +# --------------------------------------------------------------------------- +# gateway/run.py :: GatewayRunner._launch_detached_restart_command +# outer watcher Popen breakaway-denied fallback (PR #42993) +# --------------------------------------------------------------------------- + + +class TestGatewayRunRestartWatcherOuterPopenFallback: + """The Windows ``/restart`` watcher in ``gateway.run`` spawns an outer + detached ``python -c `` process with + ``windows_detach_popen_kwargs()`` (which carries + ``CREATE_BREAKAWAY_FROM_JOB``). A restrictive parent job object rejects + the breakaway bit with ``ERROR_ACCESS_DENIED`` (surfaced as ``OSError``); + the launcher must retry once without breakaway, preserving argv and the + scrubbed environment, and only warn — never crash, never leak secrets — + if the retry also fails. + + Behavioral: drives the real coroutine with a mocked ``subprocess.Popen`` + rather than asserting on source text. Runs on Linux CI via a + ``sys.platform`` patch; the breakaway-bit assertions are gated on the + real host being Windows because ``_subprocess_compat`` caches + ``IS_WINDOWS`` at import time. + """ + + @staticmethod + def _fake_self(): + from types import SimpleNamespace + + return SimpleNamespace( + _detached_restart_helper_started=False, + _restart_drain_timeout=0.0, + ) + + @classmethod + def _drive(cls, gr): + asyncio.run( + gr.GatewayRunner._launch_detached_restart_command(cls._fake_self()) + ) + + def test_outer_watcher_retries_without_breakaway_on_oserror(self, monkeypatch): + import gateway.run as gr + from hermes_cli._subprocess_compat import ( + IS_WINDOWS, + windows_detach_flags_without_breakaway, + windows_detach_popen_kwargs, + ) + + monkeypatch.setattr(gr.sys, "platform", "win32") + monkeypatch.setattr(gr, "_resolve_hermes_bin", lambda: ["hermes"]) + + calls = [] + + def fake_popen(argv, **kwargs): + calls.append((argv, kwargs)) + if len(calls) == 1: + raise OSError(5, "Access is denied") # ERROR_ACCESS_DENIED + return MagicMock() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + + self._drive(gr) + + assert len(calls) == 2, "outer watcher must retry exactly once on OSError" + (argv1, kw1), (argv2, kw2) = calls + + # argv is identical across primary and fallback, and every current + # watcher parameter survives: + # [watcher_python, "-c",