diff --git a/src/agent_loop.py b/src/agent_loop.py index ccf664c9e..2fce4e813 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -10448,6 +10448,11 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o or (_explicit_local_input and not _explicit_web_retrieval) ): domains.add("web") + if has( + r"\b(?:reviews?|ratings?|testimonials?|評判|レビュー)\b", + r"\b(?:worth|recommend(?:ed|ation)?|pros?\s+and\s+cons?|buying\s+guide)\b", + ): + domains.add("web") if _looks_like_explicit_browser_interaction(retrieval_query) and not ( _personal_domain_turn and not _strong_web_target ): diff --git a/src/turn_contract.py b/src/turn_contract.py index 67cf0905e..bc3c12e5d 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -699,6 +699,27 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None: ) ): explicitly_named_web.add("web_fetch") + if ( + re.search(r"(?:file://)?/(?:tmp_)?workspace(?:/|\b)", raw_text, re.I) + and re.search( + r"\b(?:build|create|edit|persist|produce|save|write)\b", + raw_text, + re.I, + ) + and re.search( + r"\b(?:artifacts?|director(?:y|ies)|files?|outputs?|results?)\b|" + r"\.(?:csv|html|json|jsonl|md|tex|txt)\b", + raw_text, + re.I, + ) + ): + # Explicit native Web names seal selection to the named tools. + # Compound autonomous jobs also require a bounded local + # read/write/verify surface; without it, requiring shell_files + # makes the whole contract fail closed and drops the Web tools. + explicitly_named_web.update( + {"read_file", "write_file", "edit_file", "python"} + ) return frozenset(explicitly_named_web) if ( re.search(r"\b(?:look\s*up|search|find)\b", text, re.I) @@ -718,6 +739,21 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None: # begin on an arbitrary browser page can ground an answer in stale or # unrelated content without ever establishing a current source set. return frozenset({"web_search"}) + if re.search( + r"\b(?:reviews?|ratings?|評判|レビュー|testimonials?)\b", + text, + re.I, + ) and re.search( + r"\b(?:what(?:'s|\s+is)|how\s+are|which|best|good|bad|worth|recommend|" + r"compare|pros?|cons?|opinions?|thoughts?|about)\b", + text, + re.I, + ): + # Product/service review requests are current public-web lookups even + # when the user does not say "search". Route them to web_search before + # the model sees a schema; otherwise a no-tool contract invites raw + # provider-specific markup (notably DeepSeek DSML) that cannot execute. + return frozenset({"web_search"}) if re.search( r"\buse\s+(?:the\s+)?(?:odysseus\s+)?web_search\b", raw_text, @@ -3371,6 +3407,12 @@ def required_read_operation_for_request(message: str, history: Iterable = ()) -> re.search(r"\b(?:saved\s+)?memor(?:y|ies|es)\b", text, re.I) and re.search(r"\b(?:pull\s+up|peek|list|show|saved)\b", text, re.I) and not re.search(r"\b(?:add|edit|change|delete|forget)\b", text, re.I) + and not re.search( + r"\b(?:never|without)\s+(?:(?:using|relying\s+on)\s+)?(?:my\s+)?memory\b|" + r"\bdo\s+not\s+(?:use|rely\s+on)\s+(?:my\s+)?memory\b", + text, + re.I, + ) ): return RequiredReadOperation("manage_memory", {"action": "list"}, maximum) if ( @@ -4073,8 +4115,15 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum # Explicit native-tool requests are stronger than incidental domain # words in the research subject (for example, Git ``pull`` must not # route to scheduled tasks). Keep the whole read-only web family so a - # weak search can recover through fetch/browser without schema growth. - return frozenset({"search_browser"}) + # weak search can recover through fetch/browser. A compound artifact + # workflow may also have been deliberately selected with a bounded + # workspace tool surface; preserve that independent family. + selected = selected_tools_for_request(raw_text) + selected_families = ( + frozenset().union(*(_families_for_tool(tool) for tool in selected)) + if selected else frozenset() + ) + return selected_families or frozenset({"search_browser"}) if ( re.search(r"\b(?:latest|recent|current|today(?:'s)?)\b", text, re.I) and re.search(r"\b(?:info(?:rmation)?|news|nees|updates?)\b", text, re.I) diff --git a/tests/test_minimal_native_tool_prompt.py b/tests/test_minimal_native_tool_prompt.py index 25f68e641..9faf35f7f 100644 --- a/tests/test_minimal_native_tool_prompt.py +++ b/tests/test_minimal_native_tool_prompt.py @@ -192,6 +192,16 @@ def test_typo_heavy_product_problem_is_complete_not_ambiguous_fragment() -> None assert not _is_ambiguous_short_low_signal(text) +def test_product_review_request_routes_to_web_search_without_search_verb() -> None: + from src.turn_contract import selected_tools_for_request + + assert selected_tools_for_request("What's Dyson pencil vac reviews") == frozenset({"web_search"}) + assert "web" in _classify_agent_request( + [{"role": "user", "content": "What's Dyson pencil vac reviews"}], + "What's Dyson pencil vac reviews", + )["domains"] + + def test_standalone_link_fragment_gets_clarification_path() -> None: from pathlib import Path diff --git a/tests/test_turn_contract.py b/tests/test_turn_contract.py index cb1b46b09..dae85ecb2 100644 --- a/tests/test_turn_contract.py +++ b/tests/test_turn_contract.py @@ -2267,6 +2267,19 @@ def test_explicit_native_web_workflow_outranks_incidental_task_words(): assert selected_tools_for_request(message) == {"web_search", "web_fetch"} +def test_explicit_native_web_workflow_preserves_workspace_artifact_tools(): + message = ( + "Use Odysseus web_search to locate each RFC, then web_fetch every page. " + "Create /tmp_workspace/results/inventory.jsonl, read the local inputs, " + "and run the Python validator before finishing." + ) + selected = selected_tools_for_request(message) + assert selected == { + "web_search", "web_fetch", "read_file", "write_file", "edit_file", "python", + } + assert requested_capabilities(message) == {"search_browser", "shell_files"} + + def test_typoed_webhook_inventory_selects_admin_reader(): message = "list my webhoks again" assert selected_tools_for_request(message) == {"manage_webhooks"}