diff --git a/routes/chat_routes.py b/routes/chat_routes.py index dd2952ea2..5d58ef7fb 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -3390,8 +3390,8 @@ def setup_chat_routes( if _clean_v3_preview: from dataclasses import replace from src.clean_agent_preview import ( - MODE, NATIVE_WORKSPACE_TOOLS, PREVIEW_TOOLS, canonical, - scope_preview_contract, tool_family, + INTERACTIVE_CORE_TOOLS, MODE, NATIVE_WORKSPACE_TOOLS, PREVIEW_TOOLS, canonical, + scope_preview_contract, ) from src.turn_contract import resolve_full_inventory_contract _warm_canonical = {canonical(name) for name in _warm_tools} @@ -3414,41 +3414,6 @@ def setup_chat_routes( if canonical(s['function']['name']) != 'bash' or canonical(s['function']['name']) in _warm_canonical ] - # Browser automation is a deliberate capability, not a side - # effect of merely enabling ordinary Web search. Once a clean - # turn successfully uses it, typed execution evidence keeps it - # warm for the conversation so referential follow-ups can - # inspect the same page. - if ( - _explicit_browser_intent - and not set(_selected_tools or ()).intersection( - {'web_search', 'web_fetch'} - ) - ): - # Navigation and interaction are browser operations. Do - # not make the model choose between a site browser and the - # search/fetch APIs after the request has already made - # that distinction. An explicitly named brokered search or - # fetch tool is stronger than the generic URL/open signal; - # preserving it also prevents the browser-only filter from - # intersecting an exact web_fetch contract down to zero - # tools. A later turn can explicitly ask for Web search as - # a fallback. - _preview_schemas = [ - s for s in _preview_schemas - if tool_family(s['function']['name']) != 'search_browser' - or canonical(s['function']['name']) in ( - {'private_browser'} | NATIVE_WORKSPACE_TOOLS - ) - or canonical(s['function']['name']) in _warm_canonical - ] - elif not _clean_v3_private_browser_warm and not ( - _native_workspace_contract and _local_browser_render_intent - ) and 'private_browser' not in _warm_canonical: - _preview_schemas = [ - s for s in _preview_schemas - if canonical(s['function']['name']) != 'private_browser' - ] _turn_contract = scope_preview_contract( replace(resolve_full_inventory_contract( schemas=_preview_schemas, @@ -3467,9 +3432,9 @@ def setup_chat_routes( # OCR operation. Exact operations therefore stay exact; # ordinary native turns retain warm and workspace tools. extra_tools=( - frozenset() + INTERACTIVE_CORE_TOOLS if _exact_selected_native_chain - else _warm_tools | ( + else INTERACTIVE_CORE_TOOLS | _warm_tools | ( NATIVE_WORKSPACE_TOOLS | ( {"private_browser"} if _local_browser_render_intent else frozenset() ) diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 53072b609..d452b8af2 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -102,6 +102,13 @@ PREVIEW_TOOLS = ( READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS | BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS ) +# Keep a small recovery-capable surface on every interactive compact agent +# turn. Routing still adds domain tools, while policy and action guards remain +# authoritative for execution. Python is deliberately excluded here because +# the WebUI does not own a confined workspace. +INTERACTIVE_CORE_TOOLS = frozenset({ + 'web_search', 'web_fetch', 'private_browser', 'bash', 'ask_user', +}) # The interactive compact-v5 surface above stays unchanged. These tools are # added only for a server-validated ``odysseus-native`` request with an active, # confined workspace. This lets the model-specific clean runtime serve native diff --git a/src/turn_contract.py b/src/turn_contract.py index 0a7703f9a..118474e5a 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -3970,6 +3970,17 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum # Keep the common ``nees`` typo because a missed route leaves the model # with no way to answer and encourages it to ask unnecessary questions. return frozenset({"search_browser"}) + if ( + re.search(r"\b(?:online|on\s+the\s+(?:web|internet))\b", text, re.I) + and re.search( + r"\b(?:find|look|search|check|locate|get|download|available|manual|guide|docs?)\b", + text, + re.I, + ) + ): + # Explicitly asking Odysseus to look online is sufficient web intent, + # including referential follow-ups such as "Can you look online?". + return frozenset({"search_browser"}) if ( len(concrete_urls) >= 2 and re.search(r"\b(?:open|fetch|read|retrieve|check|use)\b", text, re.I) diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index d9c3f788c..6ec687020 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -3093,6 +3093,28 @@ def test_preview_contract_keeps_only_routed_family_from_trained_inventory(): assert scoped.active_capabilities == {'notes'} +def test_preview_contract_adds_safe_interactive_core_beside_routed_tools(): + core = {'web_search', 'web_fetch', 'private_browser', 'bash', 'ask_user'} + schemas = [ + schema for schema in FUNCTION_TOOL_SCHEMAS + if schema['function']['name'] in core | {'manage_notes'} + ] + preview = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy()) + routed = SimpleNamespace( + unavailable=frozenset(), offered=frozenset({'manage_notes'}), + required=frozenset({'manage_notes'}), capabilities=frozenset({'notes'}), + required_read_operation=None, + ) + + scoped = scope_preview_contract( + preview, routed, {'notes'}, extra_tools=core, + ) + + assert scoped.offered == core | {'manage_notes'} + assert scoped.required == {'manage_notes'} + assert {schema['function']['name'] for schema in scoped.schemas()} == core | {'manage_notes'} + + def test_preview_contract_keeps_available_family_when_an_independent_family_is_unavailable(): schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in { 'write_file', 'web_search', diff --git a/tests/test_turn_contract.py b/tests/test_turn_contract.py index 286e1e894..68f1ee7c6 100644 --- a/tests/test_turn_contract.py +++ b/tests/test_turn_contract.py @@ -34,6 +34,15 @@ def test_latest_topic_info_and_common_news_typo_route_to_web_search(): assert requested_capabilities(prompt) == frozenset({"search_browser"}) +@pytest.mark.parametrize("prompt", [ + "I'm using a WIKING fireplace; can I find an English manual online?", + "Can you look online", + "Could you search online for the operator's manual?", +]) +def test_explicit_online_lookup_requests_route_to_web_search(prompt): + assert requested_capabilities(prompt) == frozenset({"search_browser"}) + + def test_successfully_used_tool_stays_offered_when_next_turn_routes_elsewhere(): contract = resolve( {"notes"},