diff --git a/docs/nano.md b/docs/nano.md index 5ed2f6f4..bc05a7ad 100644 --- a/docs/nano.md +++ b/docs/nano.md @@ -75,6 +75,12 @@ The Harbor adapter forwards nano with `--ak nano=1`; see reviewer model — its schema + instructions would break the fixed-payload and byte-stability contracts, and a two-model loop is not what a nano benchmark measures. +- **One conditional seventh tool**: when a vision model is explicitly + configured (global config `vision.enabled`, e.g. seeded by the harbor + adapter's `--ak vision=provider:model`), nano registers + `vision_analyze` — ask the vision model about a local image. This + mirrors pi's own terminal-bench extension, which adds the identical + tool for text-only main models; unconfigured nano stays exactly six. - `--allowed-tools`/`--disallowed-tools` still filter the six. - Nano is process-global (the /eco contract): on the TUI's `--stdio` transport that is exactly one session; on a multi-session diff --git a/eval/harbor/RUN_NANO_TB21.md b/eval/harbor/RUN_NANO_TB21.md index ad39b64d..9907134b 100644 --- a/eval/harbor/RUN_NANO_TB21.md +++ b/eval/harbor/RUN_NANO_TB21.md @@ -75,6 +75,16 @@ what the A/B measures. Don't describe the runs as "same tools". ## Fair nano-vs-pi setup +**Matching a pi run that used the TB extension** (vision_analyze + +websearch — e.g. `jobs/tb21-pi-flash-max-2`, which ran +`vision_model: gpt-5.6-luna`): add `--ak vision=openai:gpt-5.6-luna` to +the nano arm so nano registers its identically-named `vision_analyze` +tool. The 2026-08 run analysis found 4–5 of pi's exclusive wins used +vision/websearch (chess-best-move, video-processing, path-tracing…); +without the kwarg those tasks measure a capability gap, not the harness. +Nano has no websearch analog wired yet — note it when comparing. + + For a clean head-to-head against pi, pair **nano** with **stock pi** (`--ak tools=off`, its native 4-tool surface): both are then text-only with no web access — capability parity of absence on TB's image/web diff --git a/src/nano/prompt.py b/src/nano/prompt.py index 11dc5d10..ec13bfb6 100644 --- a/src/nano/prompt.py +++ b/src/nano/prompt.py @@ -46,6 +46,12 @@ "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). + "vision_analyze": ( + "ask a configured vision model a question about a local image " + "(screenshots, plots, photos, rendered output)" + ), } # 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 38fb4f32..22b5b734 100644 --- a/src/nano/registry.py +++ b/src/nano/registry.py @@ -40,7 +40,7 @@ def build_nano_registry() -> ToolRegistry: - """Registry holding exactly the six nano tools, none deferred. + """Registry holding the six nano tools, none deferred. Each tool is a registry-local copy carrying its pi-length doc (``NANO_TOOL_DOCS``) as ``prompt`` — ``query()`` sends ``tool.prompt()`` @@ -48,6 +48,17 @@ def build_nano_registry() -> ToolRegistry: payload from ~2K to ~250 tokens without touching the default registry's instances. Schemas are kept in full: parameter shapes are behavioral, docs are advisory. + + Plus one conditional seventh: ``vision_analyze``, registered only when + a vision model is explicitly configured (the tool's own is_enabled + gate — global config ``vision.enabled``, seeded by the harbor + adapter's ``--ak vision=provider:model``). This mirrors pi's own TB + extension, which adds the same tool for text-only main models; an + unconfigured nano stays exactly six. Conditional REGISTRATION rather + than registering-disabled is load-bearing: a registered-but-disabled + tool would resurrect the ```` message-0 + block (query.py's deferred list includes disabled tools) and bust + nano's byte-stability. """ registry = ToolRegistry() for tool in NANO_TOOLS: @@ -55,4 +66,11 @@ def build_nano_registry() -> ToolRegistry: if doc is not None: tool = replace(tool, prompt=lambda _doc=doc: _doc) registry.register(tool) + try: + from src.tool_system.tools import VisionAnalyzeTool + + if VisionAnalyzeTool.is_enabled(): + registry.register(VisionAnalyzeTool) + except Exception: # noqa: BLE001 — a broken vision config never blocks nano + pass return registry diff --git a/tests/nano/conftest.py b/tests/nano/conftest.py index c06551b2..8ee858db 100644 --- a/tests/nano/conftest.py +++ b/tests/nano/conftest.py @@ -21,6 +21,22 @@ def _reset_nano_state(): reset_eco() +@pytest.fixture(autouse=True) +def _vision_unconfigured(monkeypatch): + """Hermetic default: the dev machine's real vision config must not flip + the conditional seventh tool on. Vision-specific tests override.""" + import src.providers.vision_config as vision_config + + monkeypatch.setattr(vision_config, "vision_is_configured", lambda: False) + + +@pytest.fixture +def vision_configured(monkeypatch): + import src.providers.vision_config as vision_config + + monkeypatch.setattr(vision_config, "vision_is_configured", lambda: True) + + @pytest.fixture def no_skills(monkeypatch): """Hermetic prompt tests: no developer-machine skills leak in.""" diff --git a/tests/nano/test_nano_registry.py b/tests/nano/test_nano_registry.py index 8a8a90be..6a65f67d 100644 --- a/tests/nano/test_nano_registry.py +++ b/tests/nano/test_nano_registry.py @@ -56,6 +56,26 @@ def test_pi_length_docs_but_full_schemas(): assert json.dumps(dict(t.input_schema)) +def test_vision_analyze_joins_only_when_configured(vision_configured): + reg = build_nano_registry() + names = [t.name for t in reg.list_tools()] + assert names == [ + "Read", "Bash", "Edit", "Write", "Grep", "Glob", "vision_analyze", + ] + vision = next(t for t in reg.list_tools() if t.name == "vision_analyze") + # Registered live, never deferred/disabled — a registered-but-disabled + # tool would resurrect the message-0 block. + assert not vision.should_defer + + +def test_vision_snippet_renders_in_prompt(vision_configured, tmp_path): + from src.nano.prompt import build_nano_prompt_text + + names = tuple(t.name for t in build_nano_registry().list_tools()) + text = build_nano_prompt_text(cwd=str(tmp_path), tool_names=names) + assert "- vision_analyze: " in text + + 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.