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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1986,6 +1986,27 @@ def __init__(
_agent_section = {}
self._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")

# Optional opt-in: cap the number of historical turns whose
# reasoning_content is replayed to the API. Set via
# agent.max_reasoning_history_turns (int or null/missing). Default
# is None (no pruning, current behaviour). Models in the blocklist
# short-circuit the prune (one-time warning), since they require
# their own reasoning to be replayed for correctness (refs #15250
# DeepSeek, #17400 Kimi).
_raw_max_reasoning = _agent_section.get("max_reasoning_history_turns")
try:
self._max_reasoning_history_turns = (
int(_raw_max_reasoning) if _raw_max_reasoning is not None else None
)
if (
self._max_reasoning_history_turns is not None
and self._max_reasoning_history_turns < 0
):
self._max_reasoning_history_turns = None
except (TypeError, ValueError):
self._max_reasoning_history_turns = None
self._strip_reasoning_blocked_warned = False

# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:
Expand Down Expand Up @@ -5484,6 +5505,78 @@ def _get_tool_call_name_static(tc) -> str:

_VALID_API_ROLES = frozenset({"system", "user", "assistant", "tool", "function", "developer"})

# Models whose own reasoning_content must be replayed in history for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This model-substring blocklist does not match the current provider contract: run_agent.py:5486-5504 deliberately detects Kimi/Moonshot from the active provider/endpoint because OpenRouter-style re-exports can use the same model name but reject their native reasoning replay protocol. It also omits the current MiMo requirement.

# correctness — they short-circuit any opt-in reasoning-history prune.
_REASONING_HISTORY_REQUIRED_MODELS = ("deepseek", "kimi", "moonshot")

def _prune_reasoning_history(
self,
api_messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Return ``api_messages`` with reasoning fields stripped from older
assistant turns when ``agent.max_reasoning_history_turns`` is set.

Default behaviour (no config) is unchanged — full history replays.
When set to ``N`` (positive int), only the last ``N`` assistant
turns retain their ``reasoning_content`` / ``reasoning`` /
``reasoning_details`` keys; older turns have these keys removed
from the per-call copy. The stored session messages are never
mutated.

Models matching ``_REASONING_HISTORY_REQUIRED_MODELS`` short-circuit
the prune with a one-time warning. DeepSeek (#15250) and Kimi
(#17400) require their own reasoning to be replayed; pruning would
break tool-call accuracy for them.
"""
n = self._max_reasoning_history_turns
if n is None or n <= 0:
return api_messages

model_lower = (self.model or "").lower()
if any(
blocked in model_lower
for blocked in self._REASONING_HISTORY_REQUIRED_MODELS
):
if not self._strip_reasoning_blocked_warned:
logger.warning(
"agent.max_reasoning_history_turns=%d ignored: model %r "
"is in the reasoning-history-required blocklist "
"(DeepSeek/Kimi/Moonshot require their own "
"reasoning_content to be replayed). No-op for this "
"agent instance.",
n,
self.model,
)
self._strip_reasoning_blocked_warned = True
return api_messages

assistant_indices = [
i for i, msg in enumerate(api_messages)
if isinstance(msg, dict) and msg.get("role") == "assistant"
]
if len(assistant_indices) <= n:
return api_messages

keep_indices = set(assistant_indices[-n:])
result: List[Dict[str, Any]] = []
for i, msg in enumerate(api_messages):
if (
i in keep_indices
or not isinstance(msg, dict)
or msg.get("role") != "assistant"
):
result.append(msg)
continue
# Drop reasoning fields on a shallow copy; preserve everything else.
stripped = {
k: v
for k, v in msg.items()
if k not in ("reasoning_content", "reasoning", "reasoning_details")
}
result.append(stripped)
return result


@staticmethod
def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fix orphaned tool_call / tool_result pairs before every LLM call.
Expand Down Expand Up @@ -10811,6 +10904,7 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
# tool_call was summarized away; Responses API rejects that as
# "No tool call found for function call output".
api_messages = self._sanitize_api_messages(api_messages)
api_messages = self._prune_reasoning_history(api_messages)

# Same safety net as the main loop: drop thinking-only assistant
# turns so Anthropic-family providers don't 400 the summary call.
Expand Down Expand Up @@ -11582,6 +11676,7 @@ def run_conversation(
# gated on context_compressor — so orphans from session loading or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A turn that was previously sent with reasoning changes once it ages beyond N, invalidating the historical request prefix. AGENTS.md:1132-1137 explicitly forbids altering past context mid-conversation to preserve prompt caching; this needs a cache-safe design rather than a per-call moving cutoff.

# manual message manipulation are always caught.
api_messages = self._sanitize_api_messages(api_messages)
api_messages = self._prune_reasoning_history(api_messages)

# Drop thinking-only assistant turns (reasoning but no visible
# output and no tool_calls) and merge any adjacent user messages
Expand Down
157 changes: 157 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5187,3 +5187,160 @@ def test_on_turn_start_uses_user_turn_count(self):
import inspect
src = inspect.getsource(AIAgent.run_conversation)
assert "on_turn_start(self._user_turn_count" in src


class TestPruneReasoningHistory:
"""Tests for the opt-in agent.max_reasoning_history_turns prune.

The prune drops reasoning_content / reasoning / reasoning_details from
assistant messages older than the last N turns when the user opts in via
config. DeepSeek/Kimi/Moonshot are blocked since they need their own
reasoning replayed for correctness.
"""

def _make_agent(self, model="x-ai/grok-4.3", max_turns=None):
with (
patch(
"run_agent.get_tool_definitions",
return_value=_make_tool_defs("terminal"),
),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
patch(
"hermes_cli.config.load_config",
return_value={"agent": {"max_reasoning_history_turns": max_turns}},
),
):
a = AIAgent(
model=model,
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()
return a

@staticmethod
def _build_history(num_turns):
"""Build a fake message list with `num_turns` assistant turns,
each with reasoning_content. Interleaves user/tool messages."""
msgs = []
for i in range(num_turns):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({
"role": "assistant",
"content": f"a{i}",
"reasoning_content": f"thought-{i}",
"reasoning": f"alt-thought-{i}",
"reasoning_details": [{"type": "summary", "text": f"d{i}"}],
})
return msgs

def test_default_no_op(self):
"""No config → reasoning fields preserved on every assistant turn."""
agent = self._make_agent(max_turns=None)
msgs = self._build_history(5)
result = agent._prune_reasoning_history(msgs)
# Same length, every assistant still has reasoning_content
assistants = [m for m in result if m.get("role") == "assistant"]
assert len(assistants) == 5
for a in assistants:
assert "reasoning_content" in a
assert "reasoning" in a
assert "reasoning_details" in a

def test_prune_keeps_last_n_on_grok(self):
"""On grok-4.3 with N=2, only the last 2 assistant turns retain reasoning."""
agent = self._make_agent(model="x-ai/grok-4.3", max_turns=2)
msgs = self._build_history(5)
result = agent._prune_reasoning_history(msgs)
assistants = [m for m in result if m.get("role") == "assistant"]
assert len(assistants) == 5
# First 3 should be stripped
for a in assistants[:3]:
assert "reasoning_content" not in a
assert "reasoning" not in a
assert "reasoning_details" not in a
# Other fields preserved
assert a["content"].startswith("a")
assert a["role"] == "assistant"
# Last 2 should retain
for a in assistants[-2:]:
assert a["reasoning_content"].startswith("thought-")
assert a["reasoning"].startswith("alt-thought-")
assert a["reasoning_details"][0]["type"] == "summary"

def test_blocklist_deepseek_no_op_with_warning(self, caplog):
"""DeepSeek is blocked: prune returns input unchanged + warning."""
import logging
agent = self._make_agent(model="deepseek/deepseek-v4-pro", max_turns=2)
msgs = self._build_history(5)
with caplog.at_level(logging.WARNING, logger="run_agent"):
result = agent._prune_reasoning_history(msgs)
# No pruning happened
for a in [m for m in result if m.get("role") == "assistant"]:
assert "reasoning_content" in a
assert any(
"blocklist" in rec.getMessage() and "deepseek" in rec.getMessage().lower()
for rec in caplog.records
)

def test_blocklist_kimi_no_op(self):
"""Kimi is blocked: prune returns input unchanged."""
agent = self._make_agent(model="moonshotai/kimi-k2", max_turns=2)
msgs = self._build_history(5)
result = agent._prune_reasoning_history(msgs)
for a in [m for m in result if m.get("role") == "assistant"]:
assert "reasoning_content" in a

def test_blocklist_warning_is_idempotent(self, caplog):
"""The blocklist warning fires only once per agent instance."""
import logging
agent = self._make_agent(model="deepseek/deepseek-v4-pro", max_turns=2)
msgs = self._build_history(5)
with caplog.at_level(logging.WARNING, logger="run_agent"):
agent._prune_reasoning_history(msgs)
agent._prune_reasoning_history(msgs)
agent._prune_reasoning_history(msgs)
warnings = [rec for rec in caplog.records if "blocklist" in rec.getMessage()]
assert len(warnings) == 1

def test_negative_n_is_no_op(self):
"""Negative N is normalised to None at init → no-op."""
agent = self._make_agent(model="x-ai/grok-4.3", max_turns=-1)
assert agent._max_reasoning_history_turns is None
msgs = self._build_history(5)
result = agent._prune_reasoning_history(msgs)
for a in [m for m in result if m.get("role") == "assistant"]:
assert "reasoning_content" in a

def test_assistant_count_below_n_no_op(self):
"""If assistant turns are fewer than N, nothing to prune."""
agent = self._make_agent(model="x-ai/grok-4.3", max_turns=10)
msgs = self._build_history(3)
result = agent._prune_reasoning_history(msgs)
for a in [m for m in result if m.get("role") == "assistant"]:
assert "reasoning_content" in a

def test_non_assistant_messages_untouched(self):
"""User and tool messages are never modified."""
agent = self._make_agent(model="x-ai/grok-4.3", max_turns=1)
msgs = self._build_history(5)
result = agent._prune_reasoning_history(msgs)
for original, after in zip(msgs, result):
if original.get("role") != "assistant":
# Same message instance returned (no copy)
assert original is after

def test_does_not_mutate_input(self):
"""The stored session messages are never mutated."""
agent = self._make_agent(model="x-ai/grok-4.3", max_turns=2)
msgs = self._build_history(5)
agent._prune_reasoning_history(msgs)
# Original list still has reasoning on every assistant
for a in [m for m in msgs if m.get("role") == "assistant"]:
assert "reasoning_content" in a
assert "reasoning" in a
assert "reasoning_details" in a
Loading