diff --git a/contributors/emails/andrexibiza@gmail.com b/contributors/emails/andrexibiza@gmail.com new file mode 100644 index 000000000000..efa930813a29 --- /dev/null +++ b/contributors/emails/andrexibiza@gmail.com @@ -0,0 +1 @@ +andrexibiza diff --git a/contributors/emails/andrexibiza@users.noreply.github.com b/contributors/emails/andrexibiza@users.noreply.github.com new file mode 100644 index 000000000000..efa930813a29 --- /dev/null +++ b/contributors/emails/andrexibiza@users.noreply.github.com @@ -0,0 +1 @@ +andrexibiza diff --git a/plugins/agent/__init__.py b/plugins/agent/__init__.py new file mode 100644 index 000000000000..376e371b81b6 --- /dev/null +++ b/plugins/agent/__init__.py @@ -0,0 +1 @@ +# Agent mixins package (run_agent.py extraction) diff --git a/plugins/agent/mixins/__init__.py b/plugins/agent/mixins/__init__.py new file mode 100644 index 000000000000..a50ced46fde7 --- /dev/null +++ b/plugins/agent/mixins/__init__.py @@ -0,0 +1 @@ +# Mixin modules extracted verbatim from run_agent.py (wave 1) diff --git a/plugins/agent/mixins/reasoning_echo_mixin.py b/plugins/agent/mixins/reasoning_echo_mixin.py new file mode 100644 index 000000000000..6a84546b4fb2 --- /dev/null +++ b/plugins/agent/mixins/reasoning_echo_mixin.py @@ -0,0 +1,106 @@ +"""Provider reasoning-content echo-back helpers (run_agent.py shard s5, c7). + +Extracted verbatim from run_agent.py (wave 1, shard s5, cluster c7, 24 +move-votes). Method bodies are character-for-character copies; only this +header and the import block are new. ``logger`` is bound to the same logger +name as run_agent's module logger so log records keep their origin. + +All heavy dependencies are imported lazily inside the methods +(``agent.message_sanitization.matches_reasoning_echo_family``, +``agent.agent_runtime_helpers``), so the mixin needs no module-level +third-party imports. Per-instance state referenced via ``self.`` +(``_thinking_pad_cache``) is created on first use; class attributes stay on +``AIAgent`` and resolve through the MRO. +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger("run_agent") + + +class ReasoningEchoMixin: + def _needs_thinking_reasoning_pad(self) -> bool: + """Return True when the active provider enforces reasoning_content echo-back. + + DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays + of assistant tool-call messages that omit ``reasoning_content`` (refs + #15250, #17400). Xiaomi MiMo thinking mode has the same requirement. + + Result cached on the AIAgent instance keyed by (provider, model, + base_url); invalidated whenever ``switch_model()`` / + ``_try_activate_fallback()`` mutate any of those. This is hot — the + agent loop hits ~16 invocations per turn, each of which would + otherwise re-run ~5 ``base_url_host_matches`` (and therefore + ``urlparse``) calls under it. Caching drops the per-turn cost from + ~5us × 16 = ~80us to <1us. + """ + key = (self.provider, self.model, getattr(self, "_base_url_lower", self.base_url)) + cached = getattr(self, "_thinking_pad_cache", None) + if cached is not None and cached[0] == key: + return cached[1] + result = ( + self._needs_deepseek_tool_reasoning() + or self._needs_kimi_tool_reasoning() + or self._needs_mimo_tool_reasoning() + ) + self._thinking_pad_cache = (key, result) + return result + + def _needs_kimi_tool_reasoning(self) -> bool: + """Return True when the current provider is Kimi / Moonshot thinking mode. + + Kimi ``/coding`` and Moonshot thinking mode both require + ``reasoning_content`` on every assistant tool-call message; omitting + it causes the next replay to fail with HTTP 400. + + Detection is host-driven, not model-name-driven: aggregators like + OpenRouter that re-export Kimi/Moonshot models speak their own + protocol and reject ``reasoning_content`` echoes. We only enable the + kimi-reasoning replay when the request actually targets a + kimi/moonshot endpoint or the dedicated kimi-coding provider. + + Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. + """ + from agent.message_sanitization import matches_reasoning_echo_family + return matches_reasoning_echo_family( + "kimi", self.provider, None, self.base_url + ) + + def _needs_deepseek_tool_reasoning(self) -> bool: + """Return True when the current provider is DeepSeek thinking mode. + + DeepSeek V4 thinking mode requires ``reasoning_content`` on every + assistant tool-call turn; omitting it causes HTTP 400 when the + message is replayed in a subsequent API request (#15250). + + Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. + """ + from agent.message_sanitization import matches_reasoning_echo_family + return matches_reasoning_echo_family( + "deepseek", (self.provider or "").lower(), self.model, self.base_url + ) + + def _needs_mimo_tool_reasoning(self) -> bool: + """Return True when the current provider is Xiaomi MiMo thinking mode. + + MiMo thinking mode requires ``reasoning_content`` on every assistant + tool-call message when replaying history; omitting it causes HTTP 400. + Refs: https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content + + Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. + """ + from agent.message_sanitization import matches_reasoning_echo_family + return matches_reasoning_echo_family( + "mimo", (self.provider or "").lower(), self.model, self.base_url + ) + + def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None: + """Forwarder — see ``agent.agent_runtime_helpers.copy_reasoning_content_for_api``.""" + from agent.agent_runtime_helpers import copy_reasoning_content_for_api + return copy_reasoning_content_for_api(self, source_msg, api_msg) + + def _reapply_reasoning_echo_for_provider(self, api_messages: list) -> int: + """Forwarder — see ``agent.agent_runtime_helpers.reapply_reasoning_echo_for_provider``.""" + from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider + return reapply_reasoning_echo_for_provider(self, api_messages) diff --git a/plugins/agent/mixins/tool_execution_mixin.py b/plugins/agent/mixins/tool_execution_mixin.py new file mode 100644 index 000000000000..1fc3c8fecd3e --- /dev/null +++ b/plugins/agent/mixins/tool_execution_mixin.py @@ -0,0 +1,163 @@ +"""Tool-call execution dispatch helpers (run_agent.py shard s5, c16). + +Extracted verbatim from run_agent.py (wave 1, shard s5, cluster c16, 28 +move-votes). Method bodies are character-for-character copies; only this +header and the import block are new. ``logger`` is bound to the same logger +name as run_agent's module logger so log records keep their origin. + +Per-request helpers are imported lazily inside the methods +(``agent.tool_executor``, ``agent.tool_dispatch_helpers``, +``agent.chat_completion_helpers``, ``agent.agent_runtime_helpers``, +``tools.delegate_tool``); only ``get_active_env`` (used by +``_execute_tool_calls``) needs a module-level import. Instance state +referenced via ``self.`` (``_executing_tools``, ``_delegate_depth``) stays on +``AIAgent`` and resolves through the MRO. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Optional + +from tools.terminal_tool import get_active_env + +logger = logging.getLogger("run_agent") + + +class ToolExecutionMixin: + def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: + """Execute tool calls from the assistant message and append results to messages. + + The segment planner splits the batch into maximal contiguous runs of + parallel-safe calls (read-only tools, non-overlapping file targets, + opted-in MCP tools) separated by sequential barriers (interactive, + unsafe, or unrecognized tools). Homogeneous batches keep their + original single-path dispatch; mixed batches execute segment by + segment in emission order so safe subsets still run concurrently + while side-effect ordering is preserved. + """ + tool_calls = assistant_message.tool_calls + + # Allow _vprint during tool execution even with stream consumers + self._executing_tools = True + try: + if len(tool_calls) <= 1: + return self._execute_tool_calls_sequential( + assistant_message, messages, effective_task_id, api_call_count + ) + + from agent.tool_dispatch_helpers import _plan_tool_batch_segments + _active_env = get_active_env(effective_task_id) + _exec_cwd = Path(_active_env.cwd) if _active_env is not None and _active_env.cwd else None + segments = _plan_tool_batch_segments(tool_calls, execution_cwd=_exec_cwd) + + if len(segments) == 1: + kind = segments[0][0] + if kind == "parallel": + return self._execute_tool_calls_concurrent( + assistant_message, messages, effective_task_id, api_call_count + ) + return self._execute_tool_calls_sequential( + assistant_message, messages, effective_task_id, api_call_count + ) + + from agent.tool_executor import execute_tool_calls_segmented + return execute_tool_calls_segmented( + self, assistant_message, messages, effective_task_id, api_call_count, + segments=segments, + ) + finally: + self._executing_tools = False + + def _dispatch_delegate_task(self, function_args: dict) -> str: + """Single call site for delegate_task dispatch. + + New DELEGATE_TASK_SCHEMA fields only need to be added here to reach all + invocation paths (concurrent, sequential, inline). + """ + from tools.delegate_tool import ( + _strip_model_hidden_task_fields, + delegate_task as _delegate_task, + ) + # Delegations from the top-level MODEL always run in the background — + # the model does not get to choose. delegate_task returns immediately + # with a handle (one per task) and each subagent's result re-enters the + # conversation as a new message when it finishes. This applies to BOTH + # a single task and a fan-out batch (each task becomes its own + # independent background subagent). The one exception: + # - A delegation from an ORCHESTRATOR SUBAGENT (depth > 0) stays + # synchronous: the orchestrator needs its workers' results within + # its own turn to compose a summary, and a subagent doesn't own the + # gateway session the async result would route back to. + # The schema-level `background` param is intentionally ignored here. + _is_subagent = getattr(self, "_delegate_depth", 0) > 0 + return _delegate_task( + goal=function_args.get("goal"), + context=function_args.get("context"), + tasks=_strip_model_hidden_task_fields(function_args.get("tasks")), + max_iterations=function_args.get("max_iterations"), + role=function_args.get("role"), + background=(not _is_subagent), + parent_agent=self, + ) + + def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str, + tool_call_id: Optional[str] = None, messages: list = None, + pre_tool_block_checked: bool = False, + skip_tool_request_middleware: bool = False, + tool_request_middleware_trace: Optional[list[dict[str, Any]]] = None, + skip_tool_execution_middleware: bool = False) -> str: + """Forwarder — see ``agent.agent_runtime_helpers.invoke_tool``.""" + from agent.agent_runtime_helpers import invoke_tool + return invoke_tool( + self, + function_name, + function_args, + effective_task_id, + tool_call_id, + messages, + pre_tool_block_checked, + skip_tool_request_middleware, + tool_request_middleware_trace, + skip_tool_execution_middleware, + ) + + @staticmethod + def _wrap_verbose(label: str, text: str, indent: str = " ") -> str: + """Word-wrap verbose tool output to fit the terminal width. + + Splits *text* on existing newlines and wraps each line individually, + preserving intentional line breaks (e.g. pretty-printed JSON). + Returns a ready-to-print string with *label* on the first line and + continuation lines indented. + """ + import shutil as _shutil + import textwrap as _tw + cols = _shutil.get_terminal_size((120, 24)).columns + wrap_width = max(40, cols - len(indent)) + out_lines: list[str] = [] + for raw_line in text.split("\n"): + if len(raw_line) <= wrap_width: + out_lines.append(raw_line) + else: + wrapped = _tw.wrap(raw_line, width=wrap_width, + break_long_words=True, + break_on_hyphens=False) + out_lines.extend(wrapped or [raw_line]) + body = ("\n" + indent).join(out_lines) + return f"{indent}{label}{body}" + + def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: + """Forwarder — see ``agent.tool_executor.execute_tool_calls_concurrent``.""" + from agent.tool_executor import execute_tool_calls_concurrent + return execute_tool_calls_concurrent(self, assistant_message, messages, effective_task_id, api_call_count) + + def _execute_tool_calls_sequential(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: + """Forwarder — see ``agent.tool_executor.execute_tool_calls_sequential``.""" + from agent.tool_executor import execute_tool_calls_sequential + return execute_tool_calls_sequential(self, assistant_message, messages, effective_task_id, api_call_count) + + def _handle_max_iterations(self, messages: list, api_call_count: int) -> str: + """Forwarder — see ``agent.chat_completion_helpers.handle_max_iterations``.""" + from agent.chat_completion_helpers import handle_max_iterations + return handle_max_iterations(self, messages, api_call_count) diff --git a/run_agent.py b/run_agent.py index 23eb9777b532..8d566659968d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -221,6 +221,8 @@ def _session_source_for_agent(platform: Optional[str]) -> str: _trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg` ) from utils import atomic_json_write, base_url_host_matches, base_url_hostname, env_float, is_truthy_value, model_forces_max_completion_tokens +from plugins.agent.mixins.tool_execution_mixin import ToolExecutionMixin +from plugins.agent.mixins.reasoning_echo_mixin import ReasoningEchoMixin # Internal flags that mark a message as ephemeral empty-response/prefill @@ -409,7 +411,7 @@ def __init__( } -class AIAgent: +class AIAgent(ToolExecutionMixin, ReasoningEchoMixin): """ AI Agent with tool calling capabilities. @@ -7152,91 +7154,6 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic from agent.chat_completion_helpers import build_assistant_message return build_assistant_message(self, assistant_message, finish_reason) - def _needs_thinking_reasoning_pad(self) -> bool: - """Return True when the active provider enforces reasoning_content echo-back. - - DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays - of assistant tool-call messages that omit ``reasoning_content`` (refs - #15250, #17400). Xiaomi MiMo thinking mode has the same requirement. - - Result cached on the AIAgent instance keyed by (provider, model, - base_url); invalidated whenever ``switch_model()`` / - ``_try_activate_fallback()`` mutate any of those. This is hot — the - agent loop hits ~16 invocations per turn, each of which would - otherwise re-run ~5 ``base_url_host_matches`` (and therefore - ``urlparse``) calls under it. Caching drops the per-turn cost from - ~5us × 16 = ~80us to <1us. - """ - key = (self.provider, self.model, getattr(self, "_base_url_lower", self.base_url)) - cached = getattr(self, "_thinking_pad_cache", None) - if cached is not None and cached[0] == key: - return cached[1] - result = ( - self._needs_deepseek_tool_reasoning() - or self._needs_kimi_tool_reasoning() - or self._needs_mimo_tool_reasoning() - ) - self._thinking_pad_cache = (key, result) - return result - - def _needs_kimi_tool_reasoning(self) -> bool: - """Return True when the current provider is Kimi / Moonshot thinking mode. - - Kimi ``/coding`` and Moonshot thinking mode both require - ``reasoning_content`` on every assistant tool-call message; omitting - it causes the next replay to fail with HTTP 400. - - Detection is host-driven, not model-name-driven: aggregators like - OpenRouter that re-export Kimi/Moonshot models speak their own - protocol and reject ``reasoning_content`` echoes. We only enable the - kimi-reasoning replay when the request actually targets a - kimi/moonshot endpoint or the dedicated kimi-coding provider. - - Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. - """ - from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "kimi", self.provider, None, self.base_url - ) - - def _needs_deepseek_tool_reasoning(self) -> bool: - """Return True when the current provider is DeepSeek thinking mode. - - DeepSeek V4 thinking mode requires ``reasoning_content`` on every - assistant tool-call turn; omitting it causes HTTP 400 when the - message is replayed in a subsequent API request (#15250). - - Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. - """ - from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "deepseek", (self.provider or "").lower(), self.model, self.base_url - ) - - def _needs_mimo_tool_reasoning(self) -> bool: - """Return True when the current provider is Xiaomi MiMo thinking mode. - - MiMo thinking mode requires ``reasoning_content`` on every assistant - tool-call message when replaying history; omitting it causes HTTP 400. - Refs: https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content - - Rule table owner: ``agent.message_sanitization.reasoning_echo_family``. - """ - from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "mimo", (self.provider or "").lower(), self.model, self.base_url - ) - - def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None: - """Forwarder — see ``agent.agent_runtime_helpers.copy_reasoning_content_for_api``.""" - from agent.agent_runtime_helpers import copy_reasoning_content_for_api - return copy_reasoning_content_for_api(self, source_msg, api_msg) - - def _reapply_reasoning_echo_for_provider(self, api_messages: list) -> int: - """Forwarder — see ``agent.agent_runtime_helpers.reapply_reasoning_echo_for_provider``.""" - from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider - return reapply_reasoning_echo_for_provider(self, api_messages) - @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: "str | None" = None) -> dict: """Strip Codex Responses API fields from tool_calls for strict providers. @@ -7582,143 +7499,6 @@ def _guardrail_block_result(self, decision: ToolGuardrailDecision) -> str: self._set_tool_guardrail_halt(decision) return toolguard_synthetic_result(decision) - def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: - """Execute tool calls from the assistant message and append results to messages. - - The segment planner splits the batch into maximal contiguous runs of - parallel-safe calls (read-only tools, non-overlapping file targets, - opted-in MCP tools) separated by sequential barriers (interactive, - unsafe, or unrecognized tools). Homogeneous batches keep their - original single-path dispatch; mixed batches execute segment by - segment in emission order so safe subsets still run concurrently - while side-effect ordering is preserved. - """ - tool_calls = assistant_message.tool_calls - - # Allow _vprint during tool execution even with stream consumers - self._executing_tools = True - try: - if len(tool_calls) <= 1: - return self._execute_tool_calls_sequential( - assistant_message, messages, effective_task_id, api_call_count - ) - - from agent.tool_dispatch_helpers import _plan_tool_batch_segments - _active_env = get_active_env(effective_task_id) - _exec_cwd = Path(_active_env.cwd) if _active_env is not None and _active_env.cwd else None - segments = _plan_tool_batch_segments(tool_calls, execution_cwd=_exec_cwd) - - if len(segments) == 1: - kind = segments[0][0] - if kind == "parallel": - return self._execute_tool_calls_concurrent( - assistant_message, messages, effective_task_id, api_call_count - ) - return self._execute_tool_calls_sequential( - assistant_message, messages, effective_task_id, api_call_count - ) - - from agent.tool_executor import execute_tool_calls_segmented - return execute_tool_calls_segmented( - self, assistant_message, messages, effective_task_id, api_call_count, - segments=segments, - ) - finally: - self._executing_tools = False - - def _dispatch_delegate_task(self, function_args: dict) -> str: - """Single call site for delegate_task dispatch. - - New DELEGATE_TASK_SCHEMA fields only need to be added here to reach all - invocation paths (concurrent, sequential, inline). - """ - from tools.delegate_tool import ( - _strip_model_hidden_task_fields, - delegate_task as _delegate_task, - ) - # Delegations from the top-level MODEL always run in the background — - # the model does not get to choose. delegate_task returns immediately - # with a handle (one per task) and each subagent's result re-enters the - # conversation as a new message when it finishes. This applies to BOTH - # a single task and a fan-out batch (each task becomes its own - # independent background subagent). The one exception: - # - A delegation from an ORCHESTRATOR SUBAGENT (depth > 0) stays - # synchronous: the orchestrator needs its workers' results within - # its own turn to compose a summary, and a subagent doesn't own the - # gateway session the async result would route back to. - # The schema-level `background` param is intentionally ignored here. - _is_subagent = getattr(self, "_delegate_depth", 0) > 0 - return _delegate_task( - goal=function_args.get("goal"), - context=function_args.get("context"), - tasks=_strip_model_hidden_task_fields(function_args.get("tasks")), - max_iterations=function_args.get("max_iterations"), - role=function_args.get("role"), - background=(not _is_subagent), - parent_agent=self, - ) - - def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str, - tool_call_id: Optional[str] = None, messages: list = None, - pre_tool_block_checked: bool = False, - skip_tool_request_middleware: bool = False, - tool_request_middleware_trace: Optional[list[dict[str, Any]]] = None, - skip_tool_execution_middleware: bool = False) -> str: - """Forwarder — see ``agent.agent_runtime_helpers.invoke_tool``.""" - from agent.agent_runtime_helpers import invoke_tool - return invoke_tool( - self, - function_name, - function_args, - effective_task_id, - tool_call_id, - messages, - pre_tool_block_checked, - skip_tool_request_middleware, - tool_request_middleware_trace, - skip_tool_execution_middleware, - ) - - @staticmethod - def _wrap_verbose(label: str, text: str, indent: str = " ") -> str: - """Word-wrap verbose tool output to fit the terminal width. - - Splits *text* on existing newlines and wraps each line individually, - preserving intentional line breaks (e.g. pretty-printed JSON). - Returns a ready-to-print string with *label* on the first line and - continuation lines indented. - """ - import shutil as _shutil - import textwrap as _tw - cols = _shutil.get_terminal_size((120, 24)).columns - wrap_width = max(40, cols - len(indent)) - out_lines: list[str] = [] - for raw_line in text.split("\n"): - if len(raw_line) <= wrap_width: - out_lines.append(raw_line) - else: - wrapped = _tw.wrap(raw_line, width=wrap_width, - break_long_words=True, - break_on_hyphens=False) - out_lines.extend(wrapped or [raw_line]) - body = ("\n" + indent).join(out_lines) - return f"{indent}{label}{body}" - - def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: - """Forwarder — see ``agent.tool_executor.execute_tool_calls_concurrent``.""" - from agent.tool_executor import execute_tool_calls_concurrent - return execute_tool_calls_concurrent(self, assistant_message, messages, effective_task_id, api_call_count) - - def _execute_tool_calls_sequential(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: - """Forwarder — see ``agent.tool_executor.execute_tool_calls_sequential``.""" - from agent.tool_executor import execute_tool_calls_sequential - return execute_tool_calls_sequential(self, assistant_message, messages, effective_task_id, api_call_count) - - def _handle_max_iterations(self, messages: list, api_call_count: int) -> str: - """Forwarder — see ``agent.chat_completion_helpers.handle_max_iterations``.""" - from agent.chat_completion_helpers import handle_max_iterations - return handle_max_iterations(self, messages, api_call_count) - def _conversation_root_id(self) -> Optional[str]: """Resolve the stable conversation id for Portal usage attribution. diff --git a/tests/run_agent/test_s5_reasoning_echo_tool_exec_mixins.py b/tests/run_agent/test_s5_reasoning_echo_tool_exec_mixins.py new file mode 100644 index 000000000000..ce129efd9a1e --- /dev/null +++ b/tests/run_agent/test_s5_reasoning_echo_tool_exec_mixins.py @@ -0,0 +1,211 @@ +"""Regression tests for run_agent.py shard s5 mixin extraction (w1a). + +Covers the two highest-agreement move clusters extracted verbatim into +plugins/agent/mixins/: + * c16 -> ToolExecutionMixin (tool-call execution dispatch) + * c7 -> ReasoningEchoMixin (reasoning_content echo-back policy) + +Pure/lightweight methods only; the heavy forwarders are tested for wiring +(delegation to the agent.* target with identical arguments). Bare-adapter +pattern (object.__new__(AIAgent) + stub config attrs) matches the existing +tests/run_agent suite. +""" + +import types + +import pytest + +from agent import agent_runtime_helpers +from agent import chat_completion_helpers +from agent import tool_executor +from plugins.agent.mixins.tool_execution_mixin import ToolExecutionMixin +from plugins.agent.mixins.reasoning_echo_mixin import ReasoningEchoMixin +from run_agent import AIAgent + +MOVED_METHODS = [ + # c7 / ReasoningEchoMixin + "_needs_thinking_reasoning_pad", + "_needs_kimi_tool_reasoning", + "_needs_deepseek_tool_reasoning", + "_needs_mimo_tool_reasoning", + "_copy_reasoning_content_for_api", + "_reapply_reasoning_echo_for_provider", + # c16 / ToolExecutionMixin + "_execute_tool_calls", + "_dispatch_delegate_task", + "_invoke_tool", + "_wrap_verbose", + "_execute_tool_calls_concurrent", + "_execute_tool_calls_sequential", + "_handle_max_iterations", +] + + +def _bare_agent(**attrs): + agent = object.__new__(AIAgent) + for k, v in attrs.items(): + setattr(agent, k, v) + return agent + + +# --------------------------------------------------------------------------- +# Mixin wiring +# --------------------------------------------------------------------------- + +def test_mixins_wired_into_aiagent(): + assert issubclass(AIAgent, ToolExecutionMixin) + assert issubclass(AIAgent, ReasoningEchoMixin) + for name in MOVED_METHODS: + assert hasattr(AIAgent, name), name + + +# --------------------------------------------------------------------------- +# c7 / ReasoningEchoMixin — provider reasoning-content echo-back policy +# --------------------------------------------------------------------------- + +def test_needs_deepseek_tool_reasoning_by_provider(): + agent = _bare_agent(provider="deepseek", model="deepseek-v4", + base_url="http://localhost:30000/v1") + assert agent._needs_deepseek_tool_reasoning() is True + + +def test_needs_deepseek_tool_reasoning_false_for_other_provider(): + agent = _bare_agent(provider="openai", model="gpt-4o", + base_url="http://localhost:30000/v1") + assert agent._needs_deepseek_tool_reasoning() is False + + +def test_needs_kimi_tool_reasoning_by_provider(): + agent = _bare_agent(provider="kimi-coding", model="kimi-k2", + base_url="http://localhost:30000/v1") + assert agent._needs_kimi_tool_reasoning() is True + + +def test_needs_mimo_tool_reasoning_by_provider(): + agent = _bare_agent(provider="xiaomi", model="mimo-1", + base_url="http://localhost:30000/v1") + assert agent._needs_mimo_tool_reasoning() is True + + +def test_needs_thinking_reasoning_pad_or_chain(): + agent = _bare_agent(provider="deepseek", model="deepseek-v4", + base_url="http://localhost:30000/v1") + assert agent._needs_thinking_reasoning_pad() is True + + +def test_needs_thinking_reasoning_pad_false_and_cached(): + agent = _bare_agent(provider="openai", model="gpt-4o", + base_url="http://localhost:30000/v1") + assert agent._needs_thinking_reasoning_pad() is False + # Second call must hit the per-instance cache keyed by + # (provider, model, base_url) — the family predicates must NOT re-run. + agent._needs_deepseek_tool_reasoning = lambda: (_ for _ in ()).throw( + AssertionError("cache miss: deepseek predicate re-ran")) + agent._needs_kimi_tool_reasoning = lambda: (_ for _ in ()).throw( + AssertionError("cache miss: kimi predicate re-ran")) + agent._needs_mimo_tool_reasoning = lambda: (_ for _ in ()).throw( + AssertionError("cache miss: mimo predicate re-ran")) + assert agent._needs_thinking_reasoning_pad() is False + + +def test_reasoning_echo_forwarders_delegate_verbatim(monkeypatch): + copy_calls = {} + + def fake_copy(agent, source_msg, api_msg): + copy_calls["self"] = agent + copy_calls["args"] = (source_msg, api_msg) + return None + + monkeypatch.setattr(agent_runtime_helpers, "copy_reasoning_content_for_api", fake_copy) + agent = _bare_agent() + src, dst = {"role": "assistant"}, {"role": "assistant"} + assert agent._copy_reasoning_content_for_api(src, dst) is None + assert copy_calls["self"] is agent + assert copy_calls["args"] == (src, dst) + + reapply_calls = {} + + def fake_reapply(agent, api_messages): + reapply_calls["self"] = agent + reapply_calls["args"] = api_messages + return 3 + + monkeypatch.setattr(agent_runtime_helpers, "reapply_reasoning_echo_for_provider", fake_reapply) + msgs = [{"role": "user", "content": "x"}] + assert agent._reapply_reasoning_echo_for_provider(msgs) == 3 + assert reapply_calls["self"] is agent + assert reapply_calls["args"] is msgs + + +# --------------------------------------------------------------------------- +# c16 / ToolExecutionMixin — tool-call execution dispatch +# --------------------------------------------------------------------------- + +def test_wrap_verbose_label_and_preserves_line_breaks(): + out = ToolExecutionMixin._wrap_verbose("OUT", "hello\nworld") + assert out.startswith(" OUT") + assert "\n world" in out + assert out.count("\n") == 1 + + +def test_invoke_tool_forwarder_delegates_verbatim(monkeypatch): + invoke_calls = {} + + def fake_invoke(self, function_name, function_args, effective_task_id, + tool_call_id, messages, pre_tool_block_checked, + skip_tool_request_middleware, tool_request_middleware_trace, + skip_tool_execution_middleware): + invoke_calls["self"] = self + invoke_calls["args"] = (function_name, function_args, effective_task_id) + return "tool-result" + + monkeypatch.setattr(agent_runtime_helpers, "invoke_tool", fake_invoke) + agent = _bare_agent() + out = agent._invoke_tool("read_file", {"path": "/tmp/x"}, "task-1") + assert out == "tool-result" + assert invoke_calls["self"] is agent + assert invoke_calls["args"] == ("read_file", {"path": "/tmp/x"}, "task-1") + + +def test_execute_tool_calls_concurrent_forwarder(monkeypatch): + calls = {} + + def fake(self, assistant_message, messages, effective_task_id, api_call_count): + calls["self"] = self + calls["args"] = (assistant_message, messages, effective_task_id, api_call_count) + return None + + monkeypatch.setattr(tool_executor, "execute_tool_calls_concurrent", fake) + agent = _bare_agent() + msg = types.SimpleNamespace(tool_calls=[]) + assert agent._execute_tool_calls_concurrent(msg, [], "task-1") is None + assert calls["self"] is agent + assert calls["args"] == (msg, [], "task-1", 0) + + +def test_execute_tool_calls_sequential_forwarder(monkeypatch): + calls = {} + + def fake(self, assistant_message, messages, effective_task_id, api_call_count): + calls["args"] = (assistant_message, messages, effective_task_id, api_call_count) + return None + + monkeypatch.setattr(tool_executor, "execute_tool_calls_sequential", fake) + agent = _bare_agent() + msg = types.SimpleNamespace(tool_calls=[]) + assert agent._execute_tool_calls_sequential(msg, [], "task-1") is None + assert calls["args"] == (msg, [], "task-1", 0) + + +def test_handle_max_iterations_forwarder(monkeypatch): + calls = {} + + def fake(self, messages, api_call_count): + calls["args"] = (messages, api_call_count) + return "max-iterations-halt" + + monkeypatch.setattr(chat_completion_helpers, "handle_max_iterations", fake) + agent = _bare_agent() + msgs = [{"role": "assistant", "content": "x"}] + assert agent._handle_max_iterations(msgs, 7) == "max-iterations-halt" + assert calls["args"] == (msgs, 7)