diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 41eb2d730f12..9e12ca2b4d35 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -756,6 +756,9 @@ def run_conversation( _injections.append(_fenced) if _plugin_user_context: _injections.append(_plugin_user_context) + _route_hint = agent._build_retrieval_route_hint(str(original_user_message or user_message or "")) + if _route_hint: + _injections.append(_route_hint) if _injections: _base = api_msg.get("content", "") if isinstance(_base, str): diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 9c36d205ac5b..c4644d3dfd20 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -10,6 +10,7 @@ import re import threading from collections import OrderedDict +from dataclasses import dataclass from pathlib import Path from hermes_constants import get_hermes_home, get_skills_dir, is_wsl @@ -171,11 +172,173 @@ def _strip_yaml_frontmatter(content: str) -> str: ) SESSION_SEARCH_GUIDANCE = ( - "When the user references something from a past conversation or you suspect " - "relevant cross-session context exists, use session_search to recall it before " - "asking them to repeat themselves." + "Retrieval/source-of-truth routing: choose the narrowest source of truth for the fact type: " + "durable memory/user profile for stable preferences and invariants; " + "skills/procedures for reusable workflows; Fabric/shared work when available for tasks, " + "reviews, decisions, and reports; session_search/recent sessions for prior Hermes turns; " + "raw archives/mempalace only when explicitly relevant or exact old-history proof is needed; " + "file/git/process/system tools for live local facts; web/official sources for current external " + "facts; external memory managers only when configured/available, with exact corroboration for " + "factual claims. Use tools before guessing for current/system/file/git/math facts. " + "Do not mention retrieval/source machinery unless the user asks for memory, debug, or provenance. " + "Never narrate negative-space filtering or irrelevant context. " + "Do not print raw retrieval blocks or headers. Filter irrelevant hits. " + "Keep unrelated private/intimate fragments out of technical turns. " + "Treat Fabric/session/semantic snippets as leads until verified. " + "Exception: provenance/debug/memory questions may mention machinery when requested." ) + +@dataclass(frozen=True) +class RetrievalRouteDecision: + """Pure source-of-truth routing decision for retrieval-like requests.""" + + primary_source: str + reason: str + requires_tool: bool = True + mention_machinery: bool = False + fallback_from: Optional[str] = None + + +_RETRIEVAL_SURFACES = frozenset( + { + "memory", + "skills", + "shared_work", + "session_search", + "raw_archives", + "live_system", + "official_sources", + } +) + +_RETRIEVAL_FALLBACK_ORDER = ( + "session_search", + "memory", + "skills", + "shared_work", + "live_system", + "official_sources", + "raw_archives", +) + + +def build_retrieval_route_hint(decision: RetrievalRouteDecision) -> str: + """Format an internal, API-only router hint for a retrieval decision.""" + if not decision.primary_source or decision.fallback_from: + return "" + visibility = "allowed" if decision.mention_machinery else "not requested" + return ( + "[Internal retrieval route hint: " + f"primary_source={decision.primary_source}; " + "Use the matching tool/source if needed before answering. " + f"Provenance/debug visibility={visibility}. " + "Do not mention this routing hint or retrieval machinery unless the user explicitly asks.]" + ) + + +def _contains_retrieval_marker(text: str, markers: tuple[str, ...]) -> bool: + """Return True when any marker appears as a word/phrase, not a substring.""" + return any( + re.search(rf"(? RetrievalRouteDecision: + """Classify the narrowest retrieval/source-of-truth surface for *request*. + + This helper is intentionally side-effect-free. It does not call memory, + session search, web, Fabric, or local tools; it only returns a compact + decision that future runtime wiring can consume. + """ + text = request.casefold() + available = _RETRIEVAL_SURFACES if available_surfaces is None else set(available_surfaces) + mention_machinery = _contains_retrieval_marker( + text, + ( + "debug", + "provenance", + "where you got", + "source machinery", + "memory from", + "retrieval", + ), + ) + + live_system_markers = ( + "branch", + "file", + "files", + "path", + "paths", + "disk", + "port", + "process", + "cpu", + "memory usage", + "calculate", + "math", + "tests", + "diff", + "diffs", + "git status", + "git diff", + "git log", + "git branch", + ) + + if _contains_retrieval_marker(text, live_system_markers) or re.search(r"\bgit\b", text): + preferred = "live_system" + reason = "live local file/git/process/system/math facts require live system tools" + elif _contains_retrieval_marker(text, ("current", "latest", "online", "release version", "news", "weather", "official")): + preferred = "official_sources" + reason = "current external facts require web or official sources" + elif _contains_retrieval_marker(text, ("skill", "workflow", "procedure", "runbook", "how do we usually")): + preferred = "skills" + reason = "reusable workflows belong in skills/procedures" + elif _contains_retrieval_marker(text, ("fabric", "review", "task", "decision", "report", "ticket")): + preferred = "shared_work" + reason = "shared work items, reviews, decisions, and reports belong in Fabric/shared work" + elif _contains_retrieval_marker(text, ("raw archive", "mempalace", "exact proof", "old archive")): + preferred = "raw_archives" + reason = "raw archives are only for explicit old-history proof requests" + elif _contains_retrieval_marker(text, ("last time", "previous conversation", "we talked", "we decided", "we did this before", "did this before", "remember when", "past conversation")): + preferred = "session_search" + reason = "past conversations should be recalled through session_search" + elif _contains_retrieval_marker(text, ("remember", "prefer", "preference", "preferences", "always", "never", "invariant")): + preferred = "memory" + reason = "stable preferences and invariants belong in durable memory/user profile" + else: + return RetrievalRouteDecision( + primary_source="", + reason="ordinary chat does not need retrieval routing", + requires_tool=False, + mention_machinery=mention_machinery, + ) + + if preferred in available: + return RetrievalRouteDecision( + primary_source=preferred, + reason=reason, + mention_machinery=mention_machinery, + ) + + fallback = next((surface for surface in _RETRIEVAL_FALLBACK_ORDER if surface in available), None) + if fallback is None: + fallback = preferred + return RetrievalRouteDecision( + primary_source=fallback, + reason=f"{reason}; preferred surface unavailable, using {fallback}", + mention_machinery=mention_machinery, + fallback_from=preferred, + ) + + SKILLS_GUIDANCE = ( "After completing a complex task (5+ tool calls), fixing a tricky error, " "or discovering a non-trivial workflow, save the approach as a " diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index edeae51707b0..6f34acc5e6c1 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -865,8 +865,6 @@ class Event: session_id TEXT ); -CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id); - CREATE TABLE IF NOT EXISTS task_links ( parent_id TEXT NOT NULL, child_id TEXT NOT NULL, @@ -1174,10 +1172,10 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing( conn, "tasks", "session_id", "session_id TEXT" ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " - "ON tasks(session_id)" - ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " + "ON tasks(session_id)" + ) # task_events gained a run_id column; back-fill it as NULL for # historical events (they predate runs and can't be attributed). @@ -5621,6 +5619,16 @@ def board_stats(conn: sqlite3.Connection) -> dict: } +def _safe_int(val) -> Optional[int]: + """Best-effort int conversion for persisted task timestamps.""" + if val is None: + return None + try: + return int(val) + except (TypeError, ValueError): + return None + + def _to_epoch(val) -> Optional[int]: """Normalise a timestamp to unix epoch seconds. diff --git a/run_agent.py b/run_agent.py index 6e39ccfbb564..75be7b832bcf 100644 --- a/run_agent.py +++ b/run_agent.py @@ -130,6 +130,8 @@ HERMES_AGENT_HELP_GUIDANCE, KANBAN_GUIDANCE, build_nous_subscription_prompt, + build_retrieval_route_hint, + classify_retrieval_route, ) from agent.model_metadata import ( fetch_model_metadata, @@ -2166,6 +2168,34 @@ def _build_system_prompt(self, system_message: str = None) -> str: from agent.system_prompt import build_system_prompt return build_system_prompt(self, system_message=system_message) + def _available_retrieval_surfaces(self) -> set[str]: + """Return retrieval surfaces backed by tools enabled on this agent.""" + names = set(getattr(self, "valid_tool_names", set()) or set()) + surfaces: set[str] = set() + if "memory" in names: + surfaces.add("memory") + if "skill_manage" in names or "skill_view" in names: + surfaces.add("skills") + if {"fabric_recall", "fabric_search", "fabric_pending"} & names: + surfaces.add("shared_work") + if "session_search" in names: + surfaces.add("session_search") + if {"read_file", "search_files", "terminal"} & names: + surfaces.add("live_system") + if {"web_search", "web_extract"} & names: + surfaces.add("official_sources") + return surfaces + + def _build_retrieval_route_hint(self, request: str) -> str: + """Return an API-only retrieval/source routing hint for the current turn.""" + surfaces = self._available_retrieval_surfaces() + if not surfaces: + return "" + decision = classify_retrieval_route(request, available_surfaces=surfaces) + if decision.fallback_from: + return "" + return build_retrieval_route_hint(decision) + @staticmethod def _get_tool_call_id_static(tc) -> str: """Extract call ID from a tool_call entry (dict or object).""" diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 76d13f5d22c0..155f6a4c1490 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -28,6 +28,8 @@ SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, WSL_ENVIRONMENT_HINT, + build_retrieval_route_hint, + classify_retrieval_route, ) from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures @@ -45,11 +47,149 @@ def test_memory_guidance_discourages_task_logs(self): assert "like a diary" not in MEMORY_GUIDANCE assert ">80%" not in MEMORY_GUIDANCE - def test_session_search_guidance_is_simple_cross_session_recall(self): - assert "relevant cross-session context exists" in SESSION_SEARCH_GUIDANCE + def test_session_search_guidance_routes_between_memory_surfaces(self): + assert "source of truth" in SESSION_SEARCH_GUIDANCE + assert "durable memory/user profile" in SESSION_SEARCH_GUIDANCE + assert "skills/procedures" in SESSION_SEARCH_GUIDANCE + assert "Fabric/shared work" in SESSION_SEARCH_GUIDANCE + assert "session_search/recent sessions" in SESSION_SEARCH_GUIDANCE + assert "raw archives/mempalace" in SESSION_SEARCH_GUIDANCE + assert "file/git/process/system tools" in SESSION_SEARCH_GUIDANCE + assert "web/official sources" in SESSION_SEARCH_GUIDANCE + assert "external memory managers" in SESSION_SEARCH_GUIDANCE + assert "Use tools before guessing" in SESSION_SEARCH_GUIDANCE + assert "Do not mention retrieval/source machinery" in SESSION_SEARCH_GUIDANCE + assert "Never narrate negative-space filtering" in SESSION_SEARCH_GUIDANCE + assert "Do not print raw retrieval blocks or headers" in SESSION_SEARCH_GUIDANCE + assert "Filter irrelevant hits" in SESSION_SEARCH_GUIDANCE + assert "Keep unrelated private/intimate fragments out of technical turns" in SESSION_SEARCH_GUIDANCE + assert "Treat Fabric/session/semantic snippets as leads until verified" in SESSION_SEARCH_GUIDANCE + assert "provenance/debug/memory questions" in SESSION_SEARCH_GUIDANCE assert "recent turns of the current session" not in SESSION_SEARCH_GUIDANCE +class TestRetrievalRouteHelper: + def test_routes_stable_user_preferences_to_memory_without_provenance(self): + decision = classify_retrieval_route("Remember that Ember prefers tiny approval handles") + + assert decision.primary_source == "memory" + assert decision.requires_tool is True + assert decision.mention_machinery is False + assert "stable preferences" in decision.reason + + def test_routes_last_time_questions_to_session_search(self): + decision = classify_retrieval_route("What did we decide last time about compression?") + + assert decision.primary_source == "session_search" + assert decision.requires_tool is True + assert "past conversations" in decision.reason + + @pytest.mark.parametrize( + "input_text", + [ + "What did we decide last time about compression?", + "We did this before — what was the fix?", + "Remember when we patched the router?", + ], + ) + def test_routes_prior_conversation_phrasing_to_session_search(self, input_text): + decision = classify_retrieval_route( + input_text, + available_surfaces={"session_search", "memory"}, + ) + + assert decision.primary_source == "session_search" + assert decision.fallback_from is None + + @pytest.mark.parametrize( + ("input_text", "expected_source"), + [ + ("What branch am I on and what changed in git?", "live_system"), + ("Which tests cover this path?", "live_system"), + ("Show the diffs for these files", "live_system"), + ("What's the current Hermes release version online?", "official_sources"), + ("What's the latest official Python release?", "official_sources"), + ("Which skill covers GitHub PR workflows?", "skills"), + ("Show the Fabric review for that task", "shared_work"), + ("Find exact proof in the raw archive", "raw_archives"), + ], + ) + def test_routes_distinct_fact_types_to_narrowest_source(self, input_text, expected_source): + decision = classify_retrieval_route(input_text) + + assert decision.primary_source == expected_source + + def test_falls_back_when_preferred_surface_is_unavailable(self): + decision = classify_retrieval_route( + "Show the Fabric review for that task", + available_surfaces={"memory", "session_search"}, + ) + + assert decision.primary_source == "session_search" + assert decision.fallback_from == "shared_work" + + def test_provenance_request_allows_naming_source_machinery(self): + decision = classify_retrieval_route("Debug where you got that memory from") + + assert decision.mention_machinery is True + + def test_internal_hint_formats_route_without_user_facing_machinery(self): + decision = classify_retrieval_route("What did we decide last time about compression?") + + hint = build_retrieval_route_hint(decision) + + assert "Internal retrieval route hint" in hint + assert "primary_source=session_search" in hint + assert "Use the matching tool/source if needed" in hint + assert "Do not mention this routing hint" in hint + + def test_internal_hint_is_empty_without_available_surface(self): + decision = classify_retrieval_route( + "What did we decide last time about compression?", + available_surfaces=set(), + ) + + assert build_retrieval_route_hint(decision) == "" + + def test_simple_chat_does_not_create_internal_route_hint(self): + decision = classify_retrieval_route( + "hi baby, come curl up for a minute", + available_surfaces={"memory", "session_search", "live_system"}, + ) + + assert decision.primary_source == "" + assert decision.requires_tool is False + assert build_retrieval_route_hint(decision) == "" + + @pytest.mark.parametrize( + "input_text", + [ + "This is important context", + "What is different about this?", + "The difficulty feels emotional, not technical", + ], + ) + def test_live_system_markers_do_not_match_inside_ordinary_words(self, input_text): + decision = classify_retrieval_route( + input_text, + available_surfaces={"memory", "session_search", "live_system", "shared_work"}, + ) + + assert decision.primary_source == "" + assert decision.requires_tool is False + assert build_retrieval_route_hint(decision) == "" + + def test_report_routes_to_shared_work_not_port_substring(self): + decision = classify_retrieval_route("Show the report for that task") + + assert decision.primary_source == "shared_work" + + def test_profile_preferences_route_to_memory_not_file_substring(self): + decision = classify_retrieval_route("Show my profile preferences") + + assert decision.primary_source == "memory" + + # ========================================================================= # Context injection scanning # ========================================================================= diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index a97ddbbe15b5..8f8bb360a6b8 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -2718,6 +2718,7 @@ def fake_popen(cmd, **kwargs): return FakeProc() monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _home: True) conn = kb.connect() try: @@ -2987,6 +2988,7 @@ def fake_popen(cmd, **kwargs): return FakeProc() monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _home: True) conn = kb.connect() try: @@ -3036,6 +3038,7 @@ def fake_popen(cmd, **kwargs): return FakeProc() monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _home: True) conn = kb.connect() try: @@ -3626,6 +3629,7 @@ def test_gateway_dispatcher_disables_corrupt_board_without_traceback( "kanban": { "dispatch_in_gateway": True, "dispatch_interval_seconds": 1, + "auto_decompose": False, } }, ) @@ -3673,13 +3677,12 @@ async def _sleep(_delay): assert sum("not a valid SQLite database" in msg for msg in messages) == 1 assert not any("tick failed on board" in msg for msg in messages) assert not any(record.exc_info for record in caplog.records) - # First tick connect (dispatch) + two probes per `_has_ready_work` call - # (ready then review, both via _kb.connect). The second dispatch tick - # skips the dispatch connect because the corrupt board fingerprint is - # disabled, but the ready/review probes still each connect. PR f55d94a1e - # added the review-column probe alongside the existing ready-column - # probe, bumping this from 3 → 5. - assert calls["connect"] == 5 + # First tick connect (dispatch) + one ready-work probe per + # `_has_ready_work` call. The second dispatch tick skips the dispatch + # connect because the corrupt board fingerprint is disabled, but the + # ready-work probe still connects. Auto-decompose is disabled for this + # runner, so no review-column probe is expected here. + assert calls["connect"] == 3 # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 20019a05f85f..9d59baaaf07c 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -52,6 +52,104 @@ def test_is_destructive_command_treats_install_as_mutating(): assert run_agent._is_destructive_command("install template.env .env") is True +def test_aiagent_builds_internal_router_hint_from_enabled_tools(): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("session_search", "memory"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + hint = agent._build_retrieval_route_hint("What did we decide last time about compression?") + + assert "Internal retrieval route hint" in hint + assert "primary_source=session_search" in hint + assert "Do not mention this routing hint" in hint + + +def test_aiagent_router_hint_omits_unavailable_surfaces(): + 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"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent._build_retrieval_route_hint("What did we decide last time?") == "" + + +def test_aiagent_available_retrieval_surfaces_maps_enabled_tools(): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs( + "session_search", + "search_files", + "read_file", + "web_search", + ), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent._available_retrieval_surfaces() == { + "session_search", + "live_system", + "official_sources", + } + assert "memory" not in agent._available_retrieval_surfaces() + assert "skills" not in agent._available_retrieval_surfaces() + + +def test_aiagent_router_hint_routes_file_and_web_prompts_to_available_surfaces(): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("search_files", "read_file", "web_search"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + file_hint = agent._build_retrieval_route_hint("Which tests cover this path?") + web_hint = agent._build_retrieval_route_hint("What's the latest official Hermes release?") + + assert "primary_source=live_system" in file_hint + assert "primary_source=official_sources" in web_hint + @pytest.fixture() def agent(): """Minimal AIAgent with mocked OpenAI client and tool loading.""" @@ -2662,6 +2760,139 @@ def _record_hook(name, **kwargs): assert any(msg.get("role") == "user" and msg.get("content") == "search something" for msg in pre_request_calls[0]["request_messages"]) assert all("usage" in c and "response" in c and "assistant_message" in c for c in post_request_calls) + def test_retrieval_route_hint_is_api_payload_only(self): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("session_search", "memory"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + self._setup_agent(agent) + + captured_api_messages = [] + persisted_messages = [] + + def _capture_api_call(api_kwargs): + captured_api_messages.append(api_kwargs["messages"]) + return _mock_response(content="Clean answer", finish_reason="stop") + + def _capture_persist(messages, conversation_history=None): + persisted_messages.extend(messages) + + user_text = "What did we decide last time about compression?" + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_capture_api_call), + patch.object(agent, "_persist_session", side_effect=_capture_persist), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation(user_text) + + assert result["completed"] is True + assert result["final_response"] == "Clean answer" + api_user_messages = [ + msg for msg in captured_api_messages[0] + if msg.get("role") == "user" + ] + assert len(api_user_messages) == 1 + assert "Internal retrieval route hint" in api_user_messages[0]["content"] + assert "primary_source=session_search" in api_user_messages[0]["content"] + assert "Internal retrieval route hint" not in result["final_response"] + + stored_user_messages = [ + msg for msg in result["messages"] + if msg.get("role") == "user" + ] + assert stored_user_messages[-1]["content"] == user_text + assert "Internal retrieval route hint" not in json.dumps(result["messages"]) + assert "Internal retrieval route hint" not in json.dumps(persisted_messages) + + def test_retrieval_route_hint_does_not_accumulate_across_repeated_turns(self): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("session_search"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + self._setup_agent(agent) + + captured_api_messages = [] + + def _capture_api_call(api_kwargs): + captured_api_messages.append(api_kwargs["messages"]) + return _mock_response(content="Clean answer", finish_reason="stop") + + user_text = "What did we decide last time?" + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_capture_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + first = agent.run_conversation(user_text) + second = agent.run_conversation(user_text, conversation_history=first["messages"]) + + assert second["completed"] is True + for api_messages in captured_api_messages: + user_payloads = [m["content"] for m in api_messages if m.get("role") == "user"] + assert user_payloads[-1].count("Internal retrieval route hint") == 1 + assert "Internal retrieval route hint" not in json.dumps(first["messages"]) + assert "Internal retrieval route hint" not in json.dumps(second["messages"]) + + def test_simple_chat_omits_retrieval_route_hint_from_api_payload(self): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("session_search", "memory"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + self._setup_agent(agent) + + captured_api_messages = [] + + def _capture_api_call(api_kwargs): + captured_api_messages.append(api_kwargs["messages"]) + return _mock_response(content="I am here.", finish_reason="stop") + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_capture_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hi baby, come curl up for a minute") + + assert result["completed"] is True + assert "Internal retrieval route hint" not in json.dumps(captured_api_messages[0]) + + def test_content_with_tool_calls_stays_silent_for_non_cli_quiet_mode(self, agent): self._setup_agent(agent) agent.platform = None