Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/nano.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions eval/harbor/RUN_NANO_TB21.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/nano/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/nano/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,37 @@


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()``
as the API description (query.py:1049), so this alone cuts the tool
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 ``<available-deferred-tools>`` message-0
block (query.py's deferred list includes disabled tools) and bust
nano's byte-stability.
"""
registry = ToolRegistry()
for tool in NANO_TOOLS:
doc = NANO_TOOL_DOCS.get(tool.name)
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
16 changes: 16 additions & 0 deletions tests/nano/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
20 changes: 20 additions & 0 deletions tests/nano/test_nano_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <available-deferred-tools> 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.
Expand Down
Loading