diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3e0b0c7a94..19ec984055 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -50,28 +50,6 @@ from agent.turn_response_check import check_api_response from agent.turn_response_intake import normalize_model_response from agent.turn_tool_round import run_tool_round from hermes_logging import set_session_context -# Resolved lazily by agent.turn_* via ``from agent.conversation_loop import X`` — tests -# patch them here, so they must stay bound in this namespace. -from agent.conversation_compression import conversation_history_after_compression # noqa: F401 -from agent.model_metadata import ( # noqa: F401 - # ----------------------------------------------------------------- Session hygiene: auto-compress - # pathologically large transcripts Long-lived gateway sessions can accumulate enough history that every - # new message rehydrates an oversized transcript, causing repeated truncation/context failures. Detect - # this early and compress proactively — before the agent even starts. (#628) Token source priority: 1. - # Actual API-reported prompt_tokens from the last turn (stored in session_entry.last_prompt_tokens) 2. - # Rough char-based estimate (str(msg)//4). Overestimates by 30-50% on code/JSON-heavy sessions, but that - # just means hygiene fires a bit early — safe and harmless. - # ----------------------------------------------------------------- - estimate_messages_tokens_rough, - estimate_request_tokens_rough, - save_context_length, -) -from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff # noqa: F401 -from agent.turn_recovery import ( # noqa: F401 - describe_invalid_response, - interruptible_backoff_sleep, - validate_response_shape, -) from tools.skill_provenance import set_current_write_origin from utils import base_url_host_matches diff --git a/agent/turn_empty_response.py b/agent/turn_empty_response.py index 60c8ee91de..026cfbea5b 100644 --- a/agent/turn_empty_response.py +++ b/agent/turn_empty_response.py @@ -49,7 +49,7 @@ def _retry_empty( signature is recorded and deterministic empties stop burning paid retries (fails open: missing usage or any output keeps the budget). Returns ``(action_or_None, interrupt_result, deterministic_empty)``.""" - from agent.conversation_loop import jittered_backoff + from agent.retry_utils import jittered_backoff if empty_candidate: _empty_guard.record_empty_attempt( diff --git a/agent/turn_overflow.py b/agent/turn_overflow.py index 79a17683af..c11f783e0f 100644 --- a/agent/turn_overflow.py +++ b/agent/turn_overflow.py @@ -146,10 +146,8 @@ class _Recovery(OverflowVerdict): ``fail_on_timeout`` a host timeout (recovery spent its wait budget with no committed summary) ends the turn via the typed contract, since re-sending would hit the same overflow.""" - from agent.conversation_loop import ( - _COMPRESSION_TIMEOUT_FINAL_RESPONSE, _compression_deferred_result, - conversation_history_after_compression, - ) + from agent.conversation_compression import conversation_history_after_compression + from agent.conversation_loop import _COMPRESSION_TIMEOUT_FINAL_RESPONSE, _compression_deferred_result agent = self.agent before = self.messages @@ -185,7 +183,7 @@ class _Recovery(OverflowVerdict): token-budget errors). Same-message-count compression (tool-result pruning, in-place summarization) can shrink the request, so re-estimate rather than trust the array length. Returns ``(deferred_verdict, shrank, new_tokens)``.""" - from agent.conversation_loop import estimate_messages_tokens_rough + from agent.model_metadata import estimate_messages_tokens_rough original_len = len(self.messages) original_tokens = estimate_messages_tokens_rough(self.messages) @@ -212,7 +210,7 @@ class _Recovery(OverflowVerdict): def request_tokens(self) -> int: """Overhead-aware request size (msgs + tools + system) so LCM forced-overflow recovery arms on the TRUE request, not the tool-blind message count.""" - from agent.conversation_loop import estimate_request_tokens_rough + from agent.model_metadata import estimate_request_tokens_rough return estimate_request_tokens_rough(self.api_messages, tools=self.agent.tools or None) @@ -221,7 +219,7 @@ def _recover_payload_too_large(st: _Recovery, _retry: TurnRetryState) -> Overflo """413: compress and retry. A 413 is a BYTE-size error, so progress is scored in payload bytes — never the token estimate, which is deliberately byte-blind to images and wedged sessions on "no progress".""" - from agent.conversation_loop import estimate_messages_tokens_rough + from agent.model_metadata import estimate_messages_tokens_rough agent = st.agent exhausted = st.count_attempt(payload_too_large=True) @@ -317,7 +315,7 @@ def _adopt_provider_context_limit(st: _Recovery, error_msg: str, old_ctx: int) - """Shrink context_length only when the provider reports the real limit; else keep the window and compress. Guessed probe tiers can turn a configured 1M window into 256K/128K/64K. Returns the provider-reported limit, or ``None``.""" - from agent.conversation_loop import save_context_length + from agent.model_metadata import save_context_length agent = st.agent compressor = agent.context_compressor diff --git a/agent/turn_preflight.py b/agent/turn_preflight.py index c131002fd2..cc2a5ff011 100644 --- a/agent/turn_preflight.py +++ b/agent/turn_preflight.py @@ -251,8 +251,9 @@ def compress_after_tool_results( list (never rebuild ``conversation_history`` for it).""" from agent.conversation_loop import ( _HANDOFF_SKIP_FINAL_RESPONSE, _midturn_request_pressure_tokens, - _should_skip_model_call_for_reference_handoff, estimate_request_tokens_rough, + _should_skip_model_call_for_reference_handoff, ) + from agent.model_metadata import estimate_request_tokens_rough def _verdict(end_turn: bool) -> PostToolCompressionVerdict: return PostToolCompressionVerdict( diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index a25f37a994..4dd2f1b5a1 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -264,7 +264,7 @@ def _print_nous_401_diagnostics(agent: Any, api_error: Exception) -> None: def _print_anthropic_401_diagnostics(agent: Any, key: Any) -> None: """Anthropic 401 that survived a credential refresh: show auth method + fixes.""" - from agent.anthropic_adapter import _is_oauth_token + from agent.anthropic_credentials import _is_oauth_token from agent.azure_identity_adapter import is_token_provider from hermes_constants import display_hermes_home _plines(agent, "🔐 Anthropic 401 — authentication failed.") @@ -526,7 +526,7 @@ def recover_after_classification( _retry.reasoning_mandatory_retry_attempted = True agent._reasoning_disable_rejected = True try: - from hermes_cli.models import refresh_reasoning_caps_async + from hermes_cli.models_reasoning_caps import refresh_reasoning_caps_async refresh_reasoning_caps_async(agent.provider) except Exception: pass @@ -977,10 +977,9 @@ def compute_error_backoff( limits (capped at 600s: Anthropic Tier 1 buckets reset in ~171s, so a 120s cap re-tripped the limit); otherwise jittered backoff, replaced by the adaptive policy for 429s / Z.AI overloads. Normal retries are buffered; long Z.AI Coding waits surface immediately.""" - # Resolved through the loop module so tests that patch - # ``agent.conversation_loop.jittered_backoff`` / ``adaptive_rate_limit_backoff`` - # (incl. the run_agent conftest fast-backoff fixture) keep intercepting. - from agent.conversation_loop import adaptive_rate_limit_backoff, jittered_backoff + # Imported lazily so tests that patch ``agent.retry_utils.jittered_backoff`` / + # ``adaptive_rate_limit_backoff`` (incl. the run_agent conftest fast-backoff fixture) intercept. + from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff _retry_after = None _resp_headers = getattr(getattr(api_error, "response", None), "headers", None) if is_rate_limited else None @@ -1245,10 +1244,9 @@ def route_classified_error( (immediately) and transport failures (after 1 retry) unless credential-pool rotation may still recover (upstream-aggregator 429s always fall back); persistent 401/403 → fallback chain once; genuine Nous 429 → cross-session breaker + re-enter the loop exactly once.""" - from agent.conversation_loop import ( - _arm_fallback_restart, _ra, conversation_history_after_compression, - estimate_request_tokens_rough, - ) + from agent.conversation_compression import conversation_history_after_compression + from agent.conversation_loop import _arm_fallback_restart, _ra + from agent.model_metadata import estimate_request_tokens_rough _provider_overflow_recovery_pending = False is_rate_limited = False diff --git a/agent/turn_request_assembly.py b/agent/turn_request_assembly.py index 09cdd4beb3..8eb9beadb9 100644 --- a/agent/turn_request_assembly.py +++ b/agent/turn_request_assembly.py @@ -113,8 +113,9 @@ def assemble_api_request( user merge and surrogate stripping, so the same row's bytes never vary across turns.""" from agent.conversation_loop import ( _apply_context_engine_selection, _canonicalize_api_tool_calls, _clone_message_for_send, - _midturn_request_pressure_tokens, _pressure_with_real_floor, estimate_messages_tokens_rough, + _midturn_request_pressure_tokens, _pressure_with_real_floor, ) + from agent.model_metadata import estimate_messages_tokens_rough api_messages, effective_system = build_api_messages( agent, messages, current_turn_user_idx=current_turn_user_idx, diff --git a/agent/turn_response_check.py b/agent/turn_response_check.py index cc04110267..07dab60ceb 100644 --- a/agent/turn_response_check.py +++ b/agent/turn_response_check.py @@ -93,7 +93,7 @@ def check_api_response( """Verify ``response`` in the original order. The retry buffer is NOT cleared on success (bytes back != usable content); ``_preflight_compression_blocked``/``_last_preflight_pressure`` reset only when the usage fold re-arms the compression budget.""" - from agent.conversation_loop import validate_response_shape + from agent.turn_recovery import validate_response_shape def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ResponseCheckVerdict: return ResponseCheckVerdict( @@ -232,10 +232,9 @@ def retry_invalid_response( """Malformed/empty provider response: fire the error hook, stop the spinner, eager fallback (empty responses often mean rate limiting), terminal result at max retries, else jittered backoff that preserves a pending redirect.""" - from agent.conversation_loop import ( - _arm_fallback_restart, describe_invalid_response, interruptible_backoff_sleep, - jittered_backoff, - ) + from agent.conversation_loop import _arm_fallback_restart + from agent.retry_utils import jittered_backoff + from agent.turn_recovery import describe_invalid_response, interruptible_backoff_sleep def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> InvalidResponseVerdict: return InvalidResponseVerdict( diff --git a/agent/turn_usage.py b/agent/turn_usage.py index 2fd8f264c7..131e2cd348 100644 --- a/agent/turn_usage.py +++ b/agent/turn_usage.py @@ -31,8 +31,7 @@ class ResponseUsageOutcome: def _loop_mod(): - """Lazy ``agent.conversation_loop`` so tests patching - ``agent.conversation_loop.save_context_length`` still intercept (and no import cycle).""" + """Lazy ``agent.conversation_loop`` import (avoids an import cycle).""" import agent.conversation_loop as _cl return _cl @@ -149,7 +148,9 @@ def record_response_usage( if getattr(compressor, "_context_probed", False): ctx = compressor.context_length if getattr(compressor, "_context_probe_persistable", False): - _loop_mod().save_context_length(agent.model, agent.base_url, ctx) + from agent.model_metadata import save_context_length + + save_context_length(agent.model, agent.base_url, ctx) agent._safe_print(f"{agent.log_prefix}💾 Cached context length: {ctx:,} tokens for {agent.model}") compressor._context_probed = False compressor._context_probe_persistable = False diff --git a/tests/agent/test_native_preflight_estimate.py b/tests/agent/test_native_preflight_estimate.py index 307a485407..ac542c5749 100644 --- a/tests/agent/test_native_preflight_estimate.py +++ b/tests/agent/test_native_preflight_estimate.py @@ -108,10 +108,8 @@ def test_preflight_wrapper_falls_back_to_generic_when_ineligible(): def test_midturn_pressure_uses_pruned_estimate_when_eligible(): - from agent.conversation_loop import ( - _midturn_request_pressure_tokens, - estimate_messages_tokens_rough, - ) + from agent.conversation_loop import _midturn_request_pressure_tokens + from agent.model_metadata import estimate_messages_tokens_rough agent = _codex_agent() messages = [{"role": "system", "content": "be brief"}] + _history_with_checkpoint() @@ -133,8 +131,8 @@ def test_midturn_pressure_falls_back_to_generic_plus_tools_when_ineligible(): from agent.conversation_loop import ( _estimate_tools_tokens_rough, _midturn_request_pressure_tokens, - estimate_messages_tokens_rough, ) + from agent.model_metadata import estimate_messages_tokens_rough agent = _codex_agent( api_mode="chat_completions", diff --git a/tests/run_agent/conftest.py b/tests/run_agent/conftest.py index 711c93c5d5..f786b8467f 100644 --- a/tests/run_agent/conftest.py +++ b/tests/run_agent/conftest.py @@ -26,21 +26,8 @@ import pytest @pytest.fixture(autouse=True) def _fast_retry_backoff(monkeypatch): """Short-circuit retry backoff for all tests in this directory.""" - try: - import run_agent - except ImportError: - return - - monkeypatch.setattr(run_agent, "jittered_backoff", lambda *a, **k: 0.0) - # The conversation loop was extracted out of run_agent.py into - # ``agent.conversation_loop``, which imports ``jittered_backoff`` - # directly (``from agent.retry_utils import jittered_backoff``). - # Patching ``run_agent.jittered_backoff`` alone misses every retry - # path under the new module — tests that exercise rate-limit / - # invalid-response / server-error retries burn real wall-clock - # seconds per retry. Patch both for full coverage. - try: - from agent import conversation_loop as _conv_loop - monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 0.0) - except ImportError: - pass + # The agent.turn_* retry paths import ``jittered_backoff`` lazily from + # ``agent.retry_utils``; patch it there so rate-limit / invalid-response / + # server-error retries don't burn real wall-clock seconds. + from agent import retry_utils as _retry_utils + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0) diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 2c99ff03a0..0f54c11a7a 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -35,7 +35,8 @@ 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) + from agent import retry_utils as _retry_utils + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0) # --------------------------------------------------------------------------- @@ -80,9 +81,9 @@ def _make_413_error(*, use_status_code=True, message="Request entity too large") @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"), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -363,7 +364,7 @@ class TestHTTP413Compression: patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), - patch("agent.conversation_loop.save_context_length") as mock_save, + patch("agent.model_metadata.save_context_length") as mock_save, ): mock_compress.return_value = ( [{"role": "user", "content": "compressed summary"}], @@ -465,7 +466,7 @@ class TestPreflightCompression: with ( patch.object(agent.context_compressor, "compress", side_effect=_fake_compress), patch.object(agent, "_build_system_prompt", return_value="new system prompt") as build_prompt, - patch("run_agent.estimate_request_tokens_rough", return_value=42), + patch("agent.conversation_compression.estimate_request_tokens_rough", return_value=42), ): compressed, new_system_prompt = agent._compress_context( [{"role": "user", "content": "hello"}], @@ -634,9 +635,9 @@ class TestPreflightCompression: # Keep the turn-prologue preflight quiet-by-size so only the # in-loop pre-API pressure gate fires. patch("agent.turn_context.estimate_request_tokens_rough", return_value=10_000), - patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669), + patch("agent.model_metadata.estimate_request_tokens_rough", return_value=144_669), patch( - "agent.conversation_loop.estimate_messages_tokens_rough", + "agent.model_metadata.estimate_messages_tokens_rough", return_value=144_669, ), patch.object( @@ -734,7 +735,7 @@ class TestPreflightCompression: with ( patch("agent.turn_context.estimate_request_tokens_rough", side_effect=_rough_estimate), - patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate), + patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_rough_estimate), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -785,7 +786,7 @@ class TestPreflightCompression: with ( patch("agent.turn_context.estimate_request_tokens_rough", side_effect=_rough_estimate), - patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate), + patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_rough_estimate), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -843,7 +844,7 @@ class TestPreflightCompression: with ( patch("agent.turn_context.estimate_request_tokens_rough", side_effect=_rough_estimate), - patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate), + patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_rough_estimate), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -957,11 +958,11 @@ class TestPreflightCompression: return_value=144_669, ), patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=144_669, ), patch( - "agent.conversation_loop.estimate_messages_tokens_rough", + "agent.model_metadata.estimate_messages_tokens_rough", return_value=144_669, ), patch.object( @@ -1236,7 +1237,7 @@ class TestToolResultPreflightCompression: large_result = "x" * 100_000 with ( - patch("run_agent.handle_function_call", return_value=large_result), + patch("model_tools.handle_function_call", return_value=large_result), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -1270,21 +1271,23 @@ class TestToolResultPreflightCompression: ) agent.client.chat.completions.create.side_effect = [tool_resp, ok_resp] - # First provider request is small. The tool result pushes the fully - # assembled request over threshold; rebuilding after compression only + # First provider request is small. The post-tool check's raw-message + # estimate (the inner call made by ``estimate_request_tokens_rough``) + # stays well under threshold, but the tool result pushes the fully + # assembled pre-API request over it; rebuilding after compression only # trims it from 150K to 148K. Raw-message estimation is much smaller, # which previously made the no-op pass look successful and allowed two # more immediate summaries. assembled_estimates = iter( - [1_000, 150_000, 148_000, 148_000, 148_000] + [1_000, 25_000, 150_000, 148_000, 148_000, 148_000] ) with ( patch( - "agent.conversation_loop.estimate_messages_tokens_rough", + "agent.model_metadata.estimate_messages_tokens_rough", side_effect=lambda *_a, **_k: next(assembled_estimates), ), - patch("run_agent.handle_function_call", return_value="x" * 100_000), + patch("model_tools.handle_function_call", return_value="x" * 100_000), patch.object( agent, "_compress_context", diff --git a/tests/run_agent/test_compression_budget_rearm.py b/tests/run_agent/test_compression_budget_rearm.py index dd4b9322d8..d91eef72d1 100644 --- a/tests/run_agent/test_compression_budget_rearm.py +++ b/tests/run_agent/test_compression_budget_rearm.py @@ -95,9 +95,9 @@ def test_pre_api_compression_budget_rearms_only_after_pressure_clears( anchor) and therefore still compacts on the rough estimate. """ with ( - patch("run_agent.get_tool_definitions", return_value=[_tool_definition()]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[_tool_definition()]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch("agent.model_metadata.get_model_context_length", return_value=256_000), patch("agent.context_compressor.get_model_context_length", return_value=256_000), ): @@ -198,7 +198,7 @@ def test_pre_api_compression_budget_rearms_only_after_pressure_clears( return_value=10, ), patch( - "agent.conversation_loop.estimate_messages_tokens_rough", + "agent.model_metadata.estimate_messages_tokens_rough", side_effect=_next_estimate, ), patch( diff --git a/tests/run_agent/test_compression_lock_defer.py b/tests/run_agent/test_compression_lock_defer.py index 920a046246..1eb5e69553 100644 --- a/tests/run_agent/test_compression_lock_defer.py +++ b/tests/run_agent/test_compression_lock_defer.py @@ -44,7 +44,8 @@ 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) + from agent import retry_utils as _retry_utils + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0) def _make_tool_defs(*names: str) -> list: @@ -91,9 +92,9 @@ def _make_overflow_error(): @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"), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -286,11 +287,11 @@ class TestPreApiLockDeferDoesNotBurnBudget: return_value=10, ), patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=500_000, ), patch( - "agent.conversation_loop.estimate_messages_tokens_rough", + "agent.model_metadata.estimate_messages_tokens_rough", return_value=500_000, ), patch.object(agent, "_compress_context", side_effect=_lock_then_success), diff --git a/tests/run_agent/test_overflow_overhead_aware_tokens.py b/tests/run_agent/test_overflow_overhead_aware_tokens.py index bcff0f889f..cbcb083763 100644 --- a/tests/run_agent/test_overflow_overhead_aware_tokens.py +++ b/tests/run_agent/test_overflow_overhead_aware_tokens.py @@ -33,7 +33,8 @@ def _no_sleep(monkeypatch): """Short-circuit all time.sleep and jittered_backoff calls.""" import time as _time monkeypatch.setattr(_time, "sleep", lambda *_a, **_k: None) - monkeypatch.setattr(run_agent, "jittered_backoff", lambda *a, **k: 0.0) + from agent import retry_utils as _retry_utils + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0) def _make_tool_defs(*names: str) -> list: @@ -66,9 +67,9 @@ def _mock_response(content="Hello", finish_reason="stop", tool_calls=None, usage @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"), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -117,7 +118,7 @@ class TestHTTP413OverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ) as mock_estimate, patch.object(agent, "_compress_context") as mock_compress, @@ -161,7 +162,7 @@ class TestHTTP413OverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, @@ -214,7 +215,7 @@ class TestContextOverflowOverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ) as mock_estimate, patch.object(agent, "_compress_context") as mock_compress, @@ -255,7 +256,7 @@ class TestContextOverflowOverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, @@ -288,7 +289,7 @@ class TestContextOverflowOverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ), patch.object(agent, "_compress_context") as mock_compress, @@ -343,7 +344,7 @@ class TestLongContextTierOverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ), patch.object(agent, "_compress_context") as mock_compress, @@ -382,7 +383,7 @@ class TestLongContextTierOverheadAwareTokens: with ( patch( - "agent.conversation_loop.estimate_request_tokens_rough", + "agent.model_metadata.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, diff --git a/tests/run_agent/test_proactive_prune_loop_wiring.py b/tests/run_agent/test_proactive_prune_loop_wiring.py index 259bbbb25b..435b55515b 100644 --- a/tests/run_agent/test_proactive_prune_loop_wiring.py +++ b/tests/run_agent/test_proactive_prune_loop_wiring.py @@ -93,9 +93,9 @@ def _quiet_compressor() -> MagicMock: @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"), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -126,7 +126,7 @@ def _run_tool_loop(agent, n_tool_iterations: int): patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), patch( - "run_agent.handle_function_call", + "model_tools.handle_function_call", lambda name, args, task_id=None, **kwargs: json.dumps({"ok": True}), ), ): @@ -145,7 +145,7 @@ class TestProactivePruneLoopWiring: with ( patch.object(agent, "_compress_context", side_effect=_compress) as compress, patch( - "agent.conversation_loop.conversation_history_after_compression", + "agent.conversation_compression.conversation_history_after_compression", return_value=[], ), ): diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index b7610da930..90cf8d6798 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -50,7 +50,8 @@ def _make_tool_defs(*names: str) -> list: def test_is_destructive_command_treats_cp_as_mutating(): - assert run_agent._is_destructive_command("cp .env.local .env") is True + from agent.tool_dispatch_helpers import _is_destructive_command + assert _is_destructive_command("cp .env.local .env") is True @@ -62,10 +63,10 @@ def agent(): """Minimal AIAgent with mocked OpenAI client and tool loading.""" with ( patch( - "run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search") + "model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search") ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -188,11 +189,11 @@ def test_malformed_memory_config_still_builds_default_store(): return_value=malformed, ), patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("memory"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): agent = AIAgent( api_key="test-k...7890", @@ -215,11 +216,11 @@ def agent_with_memory_tool(): """Agent whose valid_tool_names includes 'memory'.""" with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search", "memory"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-k...7890", @@ -252,11 +253,11 @@ def test_aiagent_reuses_existing_errors_log_handler(): with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): AIAgent( api_key="test-k...7890", @@ -292,10 +293,10 @@ class TestProviderModelNormalization: def test_aiagent_strips_matching_native_provider_prefix(self): with ( patch( - "run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search") + "model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search") ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): agent = AIAgent( model="zai/glm-5.1", @@ -559,7 +560,7 @@ class TestSessionJsonSnapshotOptIn: # The sanitizer is the chokepoint: every session-ID-derived artifact # path goes through it, so it must always yield a single, traversal-free # path segment while leaving legitimate IDs untouched. - f = run_agent._safe_session_filename_component + from agent.session_persistence import _safe_session_filename_component as f for raw in ("../../etc/passwd", "/abs/path", "..\\win\\trav", "a/b/c"): out = f(raw) assert "/" not in out and "\\" not in out and ".." not in out, out @@ -674,8 +675,8 @@ class TestInit: def test_anthropic_base_url_accepted(self): """Anthropic base URLs should route to native Anthropic client.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter._anthropic_sdk") as mock_anthropic, ): agent = AIAgent( @@ -691,9 +692,9 @@ class TestInit: def test_tool_delay_kwarg_is_deprecated_noop(self): """tool_delay stays accepted for compatibility but warns and is ignored.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): with pytest.warns(DeprecationWarning, match="tool_delay"): a = AIAgent( @@ -710,9 +711,9 @@ class TestInit: def test_prompt_caching_claude_openrouter(self): """Claude model via OpenRouter should enable prompt caching.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-k...7890", @@ -727,9 +728,9 @@ class TestInit: def test_prompt_caching_non_claude(self): """Non-Claude model should disable prompt caching.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", @@ -745,8 +746,8 @@ class TestInit: def test_prompt_caching_native_anthropic(self): """Native Anthropic provider should enable prompt caching.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter._anthropic_sdk"), ): a = AIAgent( @@ -762,9 +763,9 @@ class TestInit: def test_prompt_caching_cache_ttl_defaults_without_config(self): """cache_ttl stays 5m when prompt_caching is absent from config.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch("hermes_cli.config.load_config", return_value={}), patch("hermes_cli.config.load_config_readonly", return_value={}), ): a = AIAgent( @@ -783,9 +784,9 @@ class TestInit: def test_prompt_caching_disabled_by_falsy_cache_ttl(self, falsy_value): """Falsy cache_ttl values should fully disable prompt caching.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"prompt_caching": {"cache_ttl": falsy_value}}, @@ -811,9 +812,9 @@ class TestInit: """The disable must survive anthropic_prompt_cache_policy() re-derivation (called during /model switch and fallback activation).""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"prompt_caching": {"cache_ttl": False}}, @@ -842,9 +843,9 @@ class TestInit: def test_constructor_max_tokens_wins_over_config(self): """Explicit constructor max_tokens keeps programmatic callers stable.""" with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"model": {"max_tokens": 4096}}, @@ -969,10 +970,10 @@ class TestBuildSystemPrompt: def test_can_use_soul_identity_even_when_context_files_are_skipped(self): 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("run_agent.load_soul_md", return_value="SOUL IDENTITY"), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("terminal")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), + patch("agent.prompt_builder.load_soul_md", return_value="SOUL IDENTITY"), ): agent = AIAgent( api_key="test-k...7890", @@ -1099,14 +1100,14 @@ class TestBuildSystemPrompt: } with ( - patch("run_agent.get_tool_definitions", return_value=tools), + patch("model_tools.get_tool_definitions", return_value=tools), patch( - "run_agent.check_toolset_requirements", + "model_tools.check_toolset_requirements", side_effect=AssertionError("should not re-check toolset requirements"), ), - patch("run_agent.get_toolset_for_tool", create=True, side_effect=toolset_map.get), - patch("run_agent.build_skills_system_prompt", return_value="SKILLS_PROMPT") as mock_skills, - patch("run_agent.OpenAI"), + patch("model_tools.get_toolset_for_tool", create=True, side_effect=toolset_map.get), + patch("agent.prompt_builder.build_skills_system_prompt", return_value="SKILLS_PROMPT") as mock_skills, + patch("agent.process_bootstrap.OpenAI"), ): agent = AIAgent( api_key="test-k...7890", @@ -1130,11 +1131,11 @@ class TestToolUseEnforcementConfig: """Create an agent with tools and a specific enforcement config.""" with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("terminal", "web_search"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": {"tool_use_enforcement": tool_use_enforcement}}, @@ -1180,9 +1181,9 @@ class TestToolUseEnforcementConfig: """Even with enforcement=true, no injection when agent has no tools.""" from agent.prompt_builder import TOOL_USE_ENFORCEMENT_GUIDANCE with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": {"tool_use_enforcement": True}}, @@ -1214,11 +1215,11 @@ class TestExecutionGuidanceConfig: agent_cfg["execution_guidance"] = execution_guidance with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("terminal", "web_search"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": agent_cfg}, @@ -1284,11 +1285,11 @@ class TestTaskCompletionGuidance: agent_cfg.update(extra_cfg) with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("terminal", "web_search"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": agent_cfg}, @@ -1325,9 +1326,9 @@ class TestTaskCompletionGuidance: tools it would be advice for a capability the agent doesn't have.""" from agent.prompt_builder import TASK_COMPLETION_GUIDANCE with ( - patch("run_agent.get_tool_definitions", return_value=[]), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.get_tool_definitions", return_value=[]), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": {"task_completion_guidance": True}}, @@ -1358,11 +1359,11 @@ class TestEnvironmentProbeIntegration: environment_probe=True): with ( patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=_make_tool_defs("terminal"), ), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("run_agent.OpenAI"), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI"), patch( "hermes_cli.config.load_config", return_value={"agent": {"environment_probe": environment_probe}}, @@ -1697,7 +1698,7 @@ class TestExecuteToolCalls: mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) messages = [] with patch( - "run_agent.handle_function_call", return_value="search result" + "model_tools.handle_function_call", return_value="search result" ) as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") # enabled_tools passes the agent's own valid_tool_names @@ -1715,7 +1716,7 @@ class TestExecuteToolCalls: mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) messages = [] with ( - patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch("model_tools.handle_function_call", return_value="ok") as mock_hfc, patch("agent.tool_executor.time.sleep") as mock_sleep, ): agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") @@ -1776,7 +1777,7 @@ class TestExecuteToolCalls: monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True) with ( - patch("run_agent.handle_function_call", side_effect=KeyboardInterrupt), + patch("model_tools.handle_function_call", side_effect=KeyboardInterrupt), patch("run_agent._set_interrupt"), patch("agent.interrupt_control._set_interrupt"), patch("agent.turn_facade._set_interrupt"), @@ -1834,7 +1835,7 @@ class TestExecuteToolCalls: "hermes_cli.lifecycle.invoke_hook", lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [], ) - with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: + with patch("model_tools.handle_function_call", return_value="ok") as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") mock_hfc.assert_not_called() assert len(messages) == 1 @@ -1881,7 +1882,7 @@ class TestExecuteToolCalls: tc = _mock_tool_call(name="web_search", arguments=None, call_id="c1") mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) messages = [] - with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: + with patch("model_tools.handle_function_call", return_value="ok") as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") mock_hfc.assert_not_called() assert len(messages) == 1 @@ -1896,7 +1897,7 @@ class TestExecuteToolCalls: mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) messages = [] big_result = "x" * 150_000 - with patch("run_agent.handle_function_call", return_value=big_result): + with patch("model_tools.handle_function_call", return_value=big_result): agent._execute_tool_calls(mock_msg, messages, "task-1") # Content should be replaced with persisted-output or truncation assert len(messages[0]["content"]) < 150_000 @@ -1908,7 +1909,7 @@ class TestExecuteToolCalls: messages = [] agent.tool_progress_callback = lambda *args, **kwargs: None - with patch("run_agent.handle_function_call", return_value="search result"), \ + with patch("model_tools.handle_function_call", return_value="search result"), \ patch.object(agent, "_safe_print") as mock_print: agent._execute_tool_calls(mock_msg, messages, "task-1") @@ -2044,7 +2045,7 @@ class TestConcurrentToolExecution: call_log.append(name) return json.dumps({"result": args.get("q", "")}) - with patch("run_agent.handle_function_call", side_effect=fake_handle): + with patch("model_tools.handle_function_call", side_effect=fake_handle): agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") assert len(messages) == 3 @@ -2074,7 +2075,7 @@ class TestConcurrentToolExecution: seen_args.append((kwargs["tool_call_id"], args)) return "ok" - with patch("run_agent.handle_function_call", side_effect=fake_handle): + with patch("model_tools.handle_function_call", side_effect=fake_handle): agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") # Only the valid call executed; the None-args call was rejected. @@ -2097,7 +2098,7 @@ class TestConcurrentToolExecution: _time.sleep(0.1) # Slow tool return f"result_{q}" - with patch("run_agent.handle_function_call", side_effect=fake_handle): + with patch("model_tools.handle_function_call", side_effect=fake_handle): agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") assert messages[0]["tool_call_id"] == "c1" @@ -2145,7 +2146,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_dispatches_to_handle_function_call(self, agent): """_invoke_tool should route regular tools through handle_function_call.""" - with patch("run_agent.handle_function_call", return_value="result") as mock_hfc: + with patch("model_tools.handle_function_call", return_value="result") as mock_hfc: result = agent._invoke_tool("web_search", {"q": "test"}, "task-1") mock_hfc.assert_called_once_with( "web_search", {"q": "test"}, "task-1", @@ -2171,7 +2172,7 @@ class TestConcurrentToolExecution: agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args)) agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result)) - with patch("run_agent.handle_function_call", return_value='{"success": true}'): + with patch("model_tools.handle_function_call", return_value='{"success": true}'): agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") assert starts == [("c1", "web_search", {"query": "hello"})] @@ -2221,7 +2222,7 @@ class TestConcurrentToolExecution: observed.append(kwargs) return '{"success": true}' - with patch("run_agent.handle_function_call", side_effect=handle_function_call): + with patch("model_tools.handle_function_call", side_effect=handle_function_call): agent._execute_tool_calls_sequential(mock_msg, [], "task-1") assert observed[0]["tool_request_middleware_trace"] == trace @@ -2242,7 +2243,7 @@ class TestConcurrentToolExecution: agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result)) agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args)) - with patch("run_agent.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'): + with patch("model_tools.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'): agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") assert starts[0][2]["text"].startswith("sk-pro") @@ -2282,7 +2283,7 @@ class TestConcurrentToolExecution: starts = [] agent.tool_start_callback = lambda *a: starts.append(a) - with patch("run_agent.handle_function_call", side_effect=AssertionError("should not run")): + with patch("model_tools.handle_function_call", side_effect=AssertionError("should not run")): agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") agent._checkpoint_mgr.ensure_checkpoint.assert_not_called() @@ -2326,7 +2327,7 @@ class TestConcurrentToolExecution: monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True) with patch( - "run_agent.handle_function_call", + "model_tools.handle_function_call", side_effect=AssertionError("middleware replacement must not dispatch"), ): if concurrent: @@ -2555,7 +2556,7 @@ class TestAgentRuntimePostHookOwnershipSync: assert tool_name in AGENT_RUNTIME_POST_HOOK_TOOL_NAMES with patch( - "run_agent.handle_function_call", + "model_tools.handle_function_call", side_effect=AssertionError("agent-runtime tools must stay inline"), ): agent._invoke_tool( @@ -2598,7 +2599,7 @@ class TestPathsOverlap: """Unit tests for the _paths_overlap helper.""" def test_same_path_overlaps(self): - from run_agent import _paths_overlap + from agent.tool_dispatch_helpers import _paths_overlap assert _paths_overlap(Path("src/a.py"), Path("src/a.py")) @@ -2610,8 +2611,7 @@ class TestPathsOverlap: class TestParallelScopePathNormalization: def test_extract_parallel_scope_path_normalizes_relative_to_cwd(self, tmp_path, monkeypatch): - from run_agent import _extract_parallel_scope_path - + from agent.tool_dispatch_helpers import _extract_parallel_scope_path monkeypatch.chdir(tmp_path) scoped = _extract_parallel_scope_path("write_file", {"path": "./notes.txt"}) @@ -2619,7 +2619,7 @@ class TestParallelScopePathNormalization: assert scoped == tmp_path / "notes.txt" def test_extract_parallel_scope_path_treats_relative_and_absolute_same_file_as_same_scope(self, tmp_path, monkeypatch): - from run_agent import _extract_parallel_scope_path, _paths_overlap + from agent.tool_dispatch_helpers import _extract_parallel_scope_path, _paths_overlap monkeypatch.chdir(tmp_path) abs_path = tmp_path / "notes.txt" @@ -2631,8 +2631,7 @@ class TestParallelScopePathNormalization: assert _paths_overlap(rel_scoped, abs_scoped) def test_should_parallelize_tool_batch_rejects_same_file_with_mixed_path_spellings(self, tmp_path, monkeypatch): - from run_agent import _should_parallelize_tool_batch - + from agent.tool_dispatch_helpers import _should_parallelize_tool_batch monkeypatch.chdir(tmp_path) tc1 = _mock_tool_call(name="write_file", arguments='{"path":"notes.txt","content":"one"}', call_id="c1") tc2 = _mock_tool_call(name="write_file", arguments=f'{{"path":"{tmp_path / "notes.txt"}","content":"two"}}', call_id="c2") @@ -2645,14 +2644,14 @@ class TestMcpParallelToolBatch: def test_mcp_tools_default_sequential(self): """MCP tools without supports_parallel_tool_calls are sequential.""" - from run_agent import _should_parallelize_tool_batch + from agent.tool_dispatch_helpers import _should_parallelize_tool_batch tc1 = _mock_tool_call(name="mcp__github__list_repos", arguments='{"org":"openai"}', call_id="c1") tc2 = _mock_tool_call(name="mcp__github__search_code", arguments='{"q":"test"}', call_id="c2") assert not _should_parallelize_tool_batch([tc1, tc2]) def test_mcp_tools_parallel_when_server_opted_in(self): """MCP tools from a parallel-safe server can run concurrently.""" - from run_agent import _should_parallelize_tool_batch + from agent.tool_dispatch_helpers import _should_parallelize_tool_batch from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock with _lock: _parallel_safe_servers.add("github") @@ -3213,7 +3212,7 @@ class TestRunConversation: resp2 = _mock_response(content="Done searching", finish_reason="stop") agent.client.chat.completions.create.side_effect = [resp1, resp2] with ( - patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call, + patch("model_tools.handle_function_call", return_value="search result") as mock_handle_function_call, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -3239,7 +3238,7 @@ class TestRunConversation: return [] with ( - patch("run_agent.handle_function_call", return_value="search result"), + patch("model_tools.handle_function_call", return_value="search result"), patch( "hermes_cli.lifecycle.has_hook", side_effect=lambda name: name in {"pre_api_request", "post_api_request"}, @@ -3388,7 +3387,7 @@ class TestRunConversation: agent.client.chat.completions.create.side_effect = [resp1, resp2] with ( - patch("run_agent.handle_function_call", return_value="search result"), + patch("model_tools.handle_function_call", return_value="search result"), patch.object(agent, "_safe_print") as mock_print, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -3645,7 +3644,7 @@ class TestRunConversation: agent.client.chat.completions.create.side_effect = _create monkeypatch.setattr(agent, "_build_api_kwargs", _build_api_kwargs) monkeypatch.setattr( - "agent.conversation_loop.jittered_backoff", + "agent.retry_utils.jittered_backoff", lambda *args, **kwargs: 0.0, ) @@ -3747,10 +3746,10 @@ class TestRunConversation: empty_resp = _mock_response(content=None, finish_reason="stop") agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp] - from agent import conversation_loop as _conv_loop + from agent import retry_utils as _retry_utils # Make backoff return 10.0 seconds - monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 10.0) + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 10.0) # Trigger the interrupt on the first sleep call inside the wait loop original_sleep = time.sleep @@ -3789,8 +3788,9 @@ class TestRunConversation: agent.client.chat.completions.create.side_effect = [empty_resp, ok_resp] from agent import conversation_loop as _conv_loop + from agent import retry_utils as _retry_utils - monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 7.5) + monkeypatch.setattr(_retry_utils, "jittered_backoff", lambda *a, **k: 7.5) # Fake clock: the retry loop gates on real time.time() < sleep_end, so # a no-op sleep alone busy-spins 7.5 wall-clock seconds. Advance a fake @@ -4172,7 +4172,7 @@ class TestRunConversation: agent.client.chat.completions.create.side_effect = [resp1, resp2] with ( - patch("run_agent.handle_function_call", return_value="result"), + patch("model_tools.handle_function_call", return_value="result"), patch.object( agent.context_compressor, "should_compress", return_value=True ), @@ -4360,7 +4360,7 @@ class TestRunConversation: ] with ( - patch("run_agent.handle_function_call", return_value="search result"), + patch("model_tools.handle_function_call", return_value="search result"), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4429,7 +4429,7 @@ class TestRunConversation: agent.client.chat.completions.create.return_value = resp with ( - patch("run_agent.handle_function_call") as mock_handle_function_call, + patch("model_tools.handle_function_call") as mock_handle_function_call, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4464,7 +4464,7 @@ class TestRunConversation: content="", finish_reason="stop", tool_calls=[good_tc], ) with ( - patch("run_agent.handle_function_call", return_value='{"success":true}') as mock_hfc, + patch("model_tools.handle_function_call", return_value='{"success":true}') as mock_hfc, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4509,7 +4509,7 @@ class TestRunConversation: final_resp = _mock_response(content="Done!", finish_reason="stop") with ( - patch("run_agent.handle_function_call", return_value='{"success":true}') as mock_hfc, + patch("model_tools.handle_function_call", return_value='{"success":true}') as mock_hfc, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4549,7 +4549,7 @@ class TestRunConversation: final_resp = _mock_response(content="Done!", finish_reason="stop") with ( - patch("run_agent.handle_function_call", return_value='{"success":true}') as mock_hfc, + patch("model_tools.handle_function_call", return_value='{"success":true}') as mock_hfc, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4588,7 +4588,7 @@ class TestRunConversation: agent.client.chat.completions.create.side_effect = [good_resp, bad_resp] with ( - patch("run_agent.handle_function_call", return_value='{"success":true}'), + patch("model_tools.handle_function_call", return_value='{"success":true}'), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4637,10 +4637,10 @@ class TestRunConversation: mock_connect = MagicMock(return_value=MagicMock()) with ( - patch("run_agent.handle_function_call", return_value="ok"), - patch("hermes_cli.kanban_db._record_task_failure", + patch("model_tools.handle_function_call", return_value="ok"), + patch("hermes_cli.kanban_db_dispatch._record_task_failure", mock_record_failure), - patch("hermes_cli.kanban_db.connect", mock_connect), + patch("hermes_cli.kanban_db_connect.connect", mock_connect), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -4687,8 +4687,8 @@ class TestRunConversation: mock_record_failure = MagicMock(return_value=False) with ( - patch("run_agent.handle_function_call", return_value="ok"), - patch("hermes_cli.kanban_db._record_task_failure", + patch("model_tools.handle_function_call", return_value="ok"), + patch("hermes_cli.kanban_db_dispatch._record_task_failure", mock_record_failure), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -5114,17 +5114,18 @@ class TestRetryExhaustion: usage=None, ) agent.client.chat.completions.create.return_value = bad_resp - # The conversation loop was extracted out of run_agent.py and pulls - # in time/jittered_backoff at module level — patch BOTH so the - # retry waits don't burn 18+ seconds of real wall-clock time here. + # The conversation loop was extracted out of run_agent.py; the retry + # paths import jittered_backoff lazily from agent.retry_utils — patch + # all of them so the retry waits don't burn 18+ seconds of real wall-clock time here. from agent import conversation_loop as _conv_loop + from agent import retry_utils as _retry_utils with ( patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), patch("run_agent.time", self._make_fast_time_mock()), patch.object(_conv_loop, "time", self._make_fast_time_mock()), - patch.object(_conv_loop, "jittered_backoff", lambda *a, **k: 0.0), + patch.object(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0), ): result = agent.run_conversation("hello") assert result.get("completed") is False, ( @@ -5149,6 +5150,7 @@ class TestRetryExhaustion: return callback(request) from agent import conversation_loop as _conv_loop + from agent import retry_utils as _retry_utils with ( patch.object(agent, "_persist_session"), @@ -5156,7 +5158,7 @@ class TestRetryExhaustion: patch.object(agent, "_cleanup_task_resources"), patch("run_agent.time", self._make_fast_time_mock()), patch.object(_conv_loop, "time", self._make_fast_time_mock()), - patch.object(_conv_loop, "jittered_backoff", lambda *a, **k: 0.0), + patch.object(_retry_utils, "jittered_backoff", lambda *a, **k: 0.0), patch("agent.relay_llm.execute", side_effect=execute), patch( "agent.relay_llm.complete_logical_call", @@ -5333,7 +5335,7 @@ class TestNousCredentialRefresh: monkeypatch.setattr(agent, "_retire_shared_openai_client", _spy_retire) - with patch("run_agent.OpenAI", side_effect=_fake_openai): + with patch("agent.process_bootstrap.OpenAI", side_effect=_fake_openai): ok = agent._try_refresh_nous_client_credentials(force=True) assert ok is True @@ -5694,7 +5696,7 @@ class TestSafeWriter: def test_write_delegates_normally(self): """When stdout is healthy, _SafeWriter is transparent.""" - from run_agent import _SafeWriter + from agent.process_bootstrap import _SafeWriter from io import StringIO inner = StringIO() writer = _SafeWriter(inner) @@ -5707,7 +5709,7 @@ class TestSafeWriter: def test_installed_in_run_conversation(self, agent): """run_conversation installs _SafeWriter on stdio.""" import sys - from run_agent import _SafeWriter + from agent.process_bootstrap import _SafeWriter resp = _mock_response(content="Done", finish_reason="stop") agent.client.chat.completions.create.return_value = resp original_stdout = sys.stdout @@ -5837,7 +5839,7 @@ class TestFallbackAnthropicProvider: with ( patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=None), + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=None), ): mock_build.return_value = MagicMock() result = agent._try_activate_fallback() @@ -5860,7 +5862,7 @@ class TestFallbackAnthropicProvider: with ( patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=None), + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=None), ): agent._try_activate_fallback() @@ -5870,9 +5872,9 @@ class TestFallbackAnthropicProvider: def test_aiagent_uses_copilot_acp_client(): 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") as mock_openai, + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), + patch("agent.process_bootstrap.OpenAI") as mock_openai, patch("agent.copilot_acp_client.CopilotACPClient") as mock_acp_client, ): acp_client = MagicMock() @@ -5947,8 +5949,8 @@ class TestAnthropicBaseUrlPassthrough: def test_custom_proxy_base_url_passed_through(self): with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, ): mock_build.return_value = MagicMock() @@ -5969,8 +5971,8 @@ class TestAnthropicBaseUrlPassthrough: class TestAnthropicCredentialRefresh: def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, ): old_client = MagicMock() @@ -5991,7 +5993,7 @@ class TestAnthropicCredentialRefresh: agent.provider = "anthropic" with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"), + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"), patch("agent.anthropic_adapter.build_anthropic_client", return_value=new_client) as rebuild, ): assert agent._try_refresh_anthropic_client_credentials() is True @@ -6006,8 +6008,8 @@ class TestAnthropicCredentialRefresh: def test_anthropic_messages_create_preflights_refresh(self): with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), ): agent = AIAgent( @@ -6035,8 +6037,8 @@ class TestAnthropicCredentialRefresh: def test_anthropic_messages_create_falls_back_when_stream_unavailable(self): with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), + patch("model_tools.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("model_tools.check_toolset_requirements", return_value={}), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), ): agent = AIAgent( @@ -6394,11 +6396,12 @@ class TestStreamingApiCall: agent._save_trajectory = lambda *args, **kwargs: None import agent.conversation_loop as _conversation_loop + import agent.retry_utils as _retry_utils with ( - patch.object(_conversation_loop, "jittered_backoff", return_value=0.0), + patch.object(_retry_utils, "jittered_backoff", return_value=0.0), patch.object( - _conversation_loop, + _retry_utils, "adaptive_rate_limit_backoff", return_value=(0.0, None), ), @@ -6963,7 +6966,7 @@ class TestOAuthFlagAfterCredentialRefresh: agent._is_anthropic_oauth = False with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value="sk-ant-setup-oauth-token"), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), @@ -6982,7 +6985,7 @@ class TestOAuthFlagAfterCredentialRefresh: agent._is_anthropic_oauth = True with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value="sk-ant-api03-new-key"), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), @@ -7011,7 +7014,7 @@ class TestFallbackSetsOAuthFlag: return_value=(mock_client, None)), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=None), ): result = agent._try_activate_fallback() @@ -7034,7 +7037,7 @@ class TestFallbackSetsOAuthFlag: return_value=(mock_client, None)), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=None), ): result = agent._try_activate_fallback() @@ -7048,7 +7051,7 @@ class TestMemoryNudgeCounterPersistence: def test_counters_initialized_in_init(self): """Counters must exist on the agent after __init__.""" - with patch("run_agent.get_tool_definitions", return_value=[]): + with patch("model_tools.get_tool_definitions", return_value=[]): a = AIAgent( model="test", api_key="test-key", base_url="http://localhost:1234/v1", provider="openrouter", skip_context_files=True, skip_memory=True,