diff --git a/eval/harbor/clawcodex_agent.py b/eval/harbor/clawcodex_agent.py index 40fe11df..0d5f915e 100644 --- a/eval/harbor/clawcodex_agent.py +++ b/eval/harbor/clawcodex_agent.py @@ -390,6 +390,7 @@ def __init__( advisor_effort: str | None = None, vision: str | None = None, nano: bool | str = False, + websearch: bool | str = False, *args, **kwargs, ): @@ -406,6 +407,15 @@ def __init__( nano if isinstance(nano, bool) else parse_bool_env_value(str(nano).lower(), name="nano") ) + # ``--ak websearch=1`` — opt nano into the WebSearch tool (seeded as + # config ``nano.websearch``; the tool additionally needs a resolvable + # TAVILY_API_KEY, which forward_keys carries by default). Mirrors + # pi's TB extension, whose websearch registers when the key is + # exported. No effect outside nano mode. + self._websearch = ( + websearch if isinstance(websearch, bool) + else parse_bool_env_value(str(websearch).lower(), name="websearch") + ) self._source = source # A ``source`` that resolves to a real file on the host is a # working-tree build to upload rather than a spec for uv to resolve @@ -855,6 +865,11 @@ async def _seed_container_settings( config: dict[str, Any] = {} if settings: config["settings"] = settings + if self._websearch: + # Nano's explicit WebSearch opt-in (src/nano/registry.py + # _nano_websearch_configured) — key-presence alone must not + # grow the nano surface. + config["nano"] = {"websearch": True} env_block = self._host_env_keys() if env_block: config["env"] = env_block diff --git a/eval/harbor/run_tb21_nano_max.sh b/eval/harbor/run_tb21_nano_max.sh index 7802af3e..41ca96cc 100755 --- a/eval/harbor/run_tb21_nano_max.sh +++ b/eval/harbor/run_tb21_nano_max.sh @@ -63,6 +63,13 @@ if [ -n "${NANO_VISION:-}" ]; then AK_EXTRA+=(--ak "vision=$NANO_VISION") echo "vision: $NANO_VISION (nano registers vision_analyze)" fi +# NANO_WEBSEARCH=1 opts nano into the WebSearch tool (needs a resolvable +# TAVILY_API_KEY — forward_keys carries the host config env block by +# default). Matches the pi TB extension's websearch. +if [ "${NANO_WEBSEARCH:-0}" = "1" ]; then + AK_EXTRA+=(--ak websearch=1) + echo "websearch: enabled (nano registers WebSearch)" +fi PYTHONPATH="$ROOT/eval/harbor" harbor run \ --dataset terminal-bench/terminal-bench-2-1 \ diff --git a/src/nano/prompt.py b/src/nano/prompt.py index ec13bfb6..0576ee41 100644 --- a/src/nano/prompt.py +++ b/src/nano/prompt.py @@ -46,12 +46,13 @@ "Write": "create new files or fully overwrite existing ones", "Grep": "search file contents with regex (respects .gitignore)", "Glob": "find files by glob pattern", - # Conditional seventh tool — listed only when a vision model is - # configured and the registry registered it (see nano/registry.py). + # Conditional tools — listed only when configured and registered + # (see nano/registry.py). "vision_analyze": ( "ask a configured vision model a question about a local image " "(screenshots, plots, photos, rendered output)" ), + "WebSearch": "search the web (titles, URLs, snippets)", } # The three non-tool guidelines at the end are distilled from clawcodex's diff --git a/src/nano/registry.py b/src/nano/registry.py index 22b5b734..99f4f49a 100644 --- a/src/nano/registry.py +++ b/src/nano/registry.py @@ -73,4 +73,35 @@ def build_nano_registry() -> ToolRegistry: registry.register(VisionAnalyzeTool) except Exception: # noqa: BLE001 — a broken vision config never blocks nano pass + try: + from src.tool_system.tools import WebSearchTool + + if _nano_websearch_configured() and WebSearchTool.is_enabled(): + doc = NANO_TOOL_DOCS.get(WebSearchTool.name) + registry.register( + replace(WebSearchTool, prompt=lambda _doc=doc: _doc) + if doc else WebSearchTool + ) + except Exception: # noqa: BLE001 — a broken config never blocks nano + pass return registry + + +def _nano_websearch_configured() -> bool: + """Explicit opt-in for WebSearch on the nano surface. + + Reads global config ``nano.websearch is True`` (seeded by the harbor + adapter's ``--ak websearch=1``). Deliberately NOT keyed on the + TAVILY_API_KEY alone: keys are forwarded into benchmark containers by + default, and a key that happens to exist must not silently grow nano's + surface — the same explicitness bar as the vision config block. pi's + TB extension ships the same tool for the same reason (its runbook + exports TAVILY_API_KEY as a deliberate act). + """ + try: + from src import config as cfg_mod + + block = cfg_mod._get_default_manager().load_global().get("nano") + return isinstance(block, dict) and block.get("websearch") is True + except Exception: # noqa: BLE001 + return False diff --git a/src/nano/tool_docs.py b/src/nano/tool_docs.py index bc97c3a1..05c36d38 100644 --- a/src/nano/tool_docs.py +++ b/src/nano/tool_docs.py @@ -42,4 +42,11 @@ "Finds files by glob pattern (e.g. '**/*.py'), sorted by " "modification time. Use for locating files by name or path shape." ), + # Conditional tool — registered only when explicitly opted in + # (nano.websearch config) AND a search key resolves; see registry.py. + "WebSearch": ( + "Searches the web and returns result titles, URLs, and snippets. " + "Use for information you cannot derive locally (current versions, " + "external docs, error messages)." + ), } diff --git a/tests/nano/test_nano_registry.py b/tests/nano/test_nano_registry.py index 6a65f67d..ffffcc55 100644 --- a/tests/nano/test_nano_registry.py +++ b/tests/nano/test_nano_registry.py @@ -76,6 +76,27 @@ def test_vision_snippet_renders_in_prompt(vision_configured, tmp_path): assert "- vision_analyze: " in text +def test_websearch_joins_only_when_opted_in(monkeypatch): + import src.nano.registry as registry_mod + + monkeypatch.setattr( + registry_mod, "_nano_websearch_configured", lambda: True + ) + names = [t.name for t in build_nano_registry().list_tools()] + assert names[-1] == "WebSearch" + ws = next(t for t in build_nano_registry().list_tools() if t.name == "WebSearch") + assert len(ws.prompt()) < 400 # pi-length doc override applied + + +def test_websearch_absent_without_explicit_config(): + # WebSearch.is_enabled defaults True (key errors surface at call + # time), so registration must hinge purely on the explicit + # nano.websearch config — a resolvable TAVILY key alone (the default + # forwarded-keys benchmark environment) must not grow the surface. + names = {t.name for t in build_nano_registry().list_tools()} + assert "WebSearch" not in names + + def test_default_registry_instances_untouched(): # The nano registry carries copies; the shared static instances (and # therefore the default registry) must keep their full docs.