From ddb58096a68775698fd8c8fe3e92f98ccfe6c19d Mon Sep 17 00:00:00 2001 From: agentforce314 <273884145+agentforce314@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:31:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(nano):=20serve/web=20gateway=20carries=20n?= =?UTF-8?q?ano=20=E2=80=94=20--nano=20flag=20+=20wire=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clawcodex serve and clawcodex web gain --nano, wiring the pi-shaped minimal profile (docs/nano.md) into the browser/desktop gateway the same way the TUI launcher wires it into its agent-server child: * serve_cli parses --nano into AgentServerConfig, so every session the gateway spawns builds the nano registry + prompt in _build_runtime — including sessions created with the composer's own provider/model (spawn_for's dataclasses.replace keeps the field). It also sets the process-global BEFORE the server starts: sessions spawn lazily, and /api/status has to tell the truth with no session in existence. * web_cli forwards --nano in _serve_argv (web stays a thin wrapper). * /api/status reports "nano" — the one pre-session surface the web client's welcome screen can read. * session.info stamps "nano" on both publish paths — _init_session_info (strict `is True`, always stamped, so an older agent's frame reads as not-nano rather than unknown) and publish_session_info (from get_settings, so a client doing full replaces keeps the chip across model switches and turn ends). * the agent's get_settings reply gains "nano": is_nano_mode() — the same live process-global the init frame reports. Tests: 10 new in tests/nano/test_nano_web.py — web argv forwarding, serve argparse → config + process-global (both directions), spawn_for override retention, /api/status true/false, strict session.info mapping (init + model-switch republish, via a nano-reporting FakeAgent and the stock one), and get_settings on the real _AgentSession harness. Existing web-cli argv tests extended with the new flag. tests/nano + tests/server + tests/entrypoints: 792 passed. Co-Authored-By: Claude Fable 5 --- docs/nano.md | 10 +- src/entrypoints/serve_cli.py | 22 +- src/entrypoints/web_cli.py | 10 +- src/server/agent_server.py | 7 + src/server/desktop_gateway_methods.py | 9 + src/server/desktop_serve.py | 6 + tests/entrypoints/test_web_cli.py | 6 +- tests/nano/test_nano_web.py | 308 ++++++++++++++++++++++++++ 8 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 tests/nano/test_nano_web.py diff --git a/docs/nano.md b/docs/nano.md index 7a7f2b19..f83040ca 100644 --- a/docs/nano.md +++ b/docs/nano.md @@ -11,6 +11,8 @@ chokepoints. clawcodex --nano -p "fix the failing test in src/parser.py" # headless clawcodex --nano # interactive TUI clawcodex tui --nano # explicit form +clawcodex web --nano # browser client +clawcodex serve --nano # desktop/web gateway ``` In the interactive TUI, the flag is forwarded to the spawned agent-server @@ -86,9 +88,11 @@ The Harbor adapter forwards nano with `--ak nano=1`; see 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 - `agent-server --http` process, `--nano` applies to every session it - hosts. + transport that is exactly one session; on a multi-session process + (`agent-server --http`, `clawcodex serve`, `clawcodex web`), `--nano` + applies to every session it hosts — including sessions created with + their own provider/model picks. The gateway reports it on + `/api/status` and stamps every `session.info` with `nano`. Design rationale and the pi study behind it: the harness comparison in `my-docs/clawcodex-nano/` (kept out of git) — headline: pi matched or diff --git a/src/entrypoints/serve_cli.py b/src/entrypoints/serve_cli.py index df2d9619..4c1e5730 100644 --- a/src/entrypoints/serve_cli.py +++ b/src/entrypoints/serve_cli.py @@ -22,7 +22,7 @@ clawcodex serve [--host H] [--port P] [--token T] [--workspace DIR] [--provider NAME] [--model M] [--effort E] - [--permission-mode MODE] + [--permission-mode MODE] [--nano] [--dangerously-skip-permissions] [--allow-dangerously-skip-permissions] """ @@ -97,6 +97,12 @@ def _build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--permission-mode", default=None, dest="permission_mode", help="default | acceptEdits | bypassPermissions | plan | auto") + parser.add_argument( + "--nano", action="store_true", + help="Nano mode (docs/nano.md): six tools, pi-style minimal prompt, " + "byte-stable context, /eco on, no MCP. Process-global: every " + "session this gateway hosts is nano.", + ) parser.add_argument("--dangerously-skip-permissions", action="store_true", dest="dangerously_skip_permissions", help="Bypass all permission checks (start in bypassPermissions).") @@ -197,6 +203,17 @@ def run_serve_subcommand(argv: list[str], *, on_ready: ReadyHook | None = None) print("serve: --fallback-model must differ from --model", file=sys.stderr) return 2 + if args.nano: + # Set the process-global BEFORE any session spawns (sessions are + # created lazily by the gateway), so pre-session surfaces — the + # ``/api/status`` nano field the web client's welcome screen reads — + # report the truth from the first request. ``_build_runtime`` sets it + # again per spawn (idempotent) and builds the nano registry off + # ``cfg.nano`` below. + from src.nano.state import set_nano_mode + + set_nano_mode(True) + token = args.token if args.token is not None else os.environ.get(TOKEN_ENV) or "" if not token: token = secrets.token_urlsafe(32) @@ -209,6 +226,9 @@ def run_serve_subcommand(argv: list[str], *, on_ready: ReadyHook | None = None) permission_mode=args.permission_mode, is_bypass_available=is_bypass_available, bypass_selectable=bypass_selectable, + # Rides every per-session copy (spawn_for's dataclasses.replace keeps + # it), so a session created with its own provider/model is still nano. + nano=bool(args.nano), max_turns=args.max_turns, ) diff --git a/src/entrypoints/web_cli.py b/src/entrypoints/web_cli.py index 7a2a7d88..b5221c93 100644 --- a/src/entrypoints/web_cli.py +++ b/src/entrypoints/web_cli.py @@ -10,7 +10,7 @@ clawcodex web [--host H] [--port P] [--no-open] [--build] [--workspace DIR] [--provider NAME] [--model M] [--effort E] - [--permission-mode MODE] [--allow-remote] + [--permission-mode MODE] [--nano] [--allow-remote] Binding ------- @@ -100,6 +100,12 @@ def _build_parser() -> argparse.ArgumentParser: help="Reasoning effort seed for sessions.") parser.add_argument("--permission-mode", default=None, dest="permission_mode", help="default | acceptEdits | bypassPermissions | plan | auto") + parser.add_argument( + "--nano", action="store_true", + help="Nano mode (docs/nano.md): six tools, pi-style minimal prompt, " + "byte-stable context, /eco on, no MCP. Every session this " + "server hosts is nano; the client shows a 'nano' chip.", + ) parser.add_argument("--dangerously-skip-permissions", action="store_true", dest="dangerously_skip_permissions", help="Bypass all permission checks (start in bypassPermissions).") @@ -222,6 +228,8 @@ def _serve_argv(args: argparse.Namespace) -> list[str]: argv += ["--effort", args.effort] if args.permission_mode: argv += ["--permission-mode", args.permission_mode] + if args.nano: + argv.append("--nano") if args.dangerously_skip_permissions: argv.append("--dangerously-skip-permissions") return argv diff --git a/src/server/agent_server.py b/src/server/agent_server.py index f725dec3..4c94e9f1 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -704,11 +704,18 @@ async def _handle_control_request(self, msg: dict) -> None: self._do_workflow_command(request_id, inner.get("name"), inner.get("args")) return if subtype == "get_settings": + from src.nano.state import is_nano_mode + self._reply(request_id, { "permission_mode": _current_mode(self.tool_context, self.config.permission_mode), "model": getattr(self.provider, "model", None), "provider": self.provider_name, "available_models": self._available_models(), + # Nano mode (docs/nano.md) — same live process-global the init + # frame reports, so the serve gateway's session.info republish + # (publish_session_info) keeps the client's nano chip truthful + # across model switches and turn ends. + "nano": is_nano_mode(), # The active fusion model's NAME, or "" when not on one. # ``model`` above stays the base model id (what serves the # turn, and what cost/context-window lookups key off), so diff --git a/src/server/desktop_gateway_methods.py b/src/server/desktop_gateway_methods.py index 7a062c3d..7c4772fb 100644 --- a/src/server/desktop_gateway_methods.py +++ b/src/server/desktop_gateway_methods.py @@ -118,6 +118,10 @@ def _init_session_info(init: dict[str, Any]) -> dict[str, Any]: provider = init.get("provider") if provider: payload["provider"] = provider + # Nano mode (docs/nano.md) — the client renders a "nano" chip beside the + # model name. Strict ``is True``, always stamped: an init frame without + # the field (an older agent) must read as not-nano, never as unknown. + payload["nano"] = init.get("nano") is True session_id = init.get("session_id") if session_id: payload["stored_session_id"] = session_id @@ -232,6 +236,11 @@ async def publish_session_info(self, **extra: Any) -> None: effort = settings.get("reasoning_effort") or settings.get("effort") if effort: payload["reasoning_effort"] = str(effort) + # Same strict mapping as _init_session_info, and stamped on every + # republish for the same reason model is: a client doing full + # replaces must never lose (or invent) the nano chip on a model + # switch or turn end. + payload["nano"] = settings.get("nano") is True payload.update(extra) await self._broadcast("session.info", payload) diff --git a/src/server/desktop_serve.py b/src/server/desktop_serve.py index 8008b42f..492d3c7d 100644 --- a/src/server/desktop_serve.py +++ b/src/server/desktop_serve.py @@ -130,6 +130,11 @@ async def health(_: Request) -> Response: async def status(request: Request) -> Response: if not _token_ok(state, _rest_token(request)): return JSONResponse({"error": "unauthorized"}, status_code=401) + # Nano is process-global (serve_cli sets it at startup, before any + # session spawns), so this is the one fact a client can show on its + # welcome screen — session.info carries it only once a session exists. + from src.nano.state import is_nano_mode + return JSONResponse( { "status": "ok", @@ -137,6 +142,7 @@ async def status(request: Request) -> Response: "protocol_version": state.protocol_version, "workspace": state.workspace, "app": "clawcodex", + "nano": is_nano_mode(), } ) diff --git a/tests/entrypoints/test_web_cli.py b/tests/entrypoints/test_web_cli.py index e4de3b1e..374e35ff 100644 --- a/tests/entrypoints/test_web_cli.py +++ b/tests/entrypoints/test_web_cli.py @@ -72,7 +72,7 @@ def test_ipv6_host_is_bracketed() -> None: def _args(**overrides: object) -> argparse.Namespace: base = dict( host="127.0.0.1", port=0, token=None, workspace=None, provider=None, model=None, - effort=None, permission_mode=None, dangerously_skip_permissions=False, + effort=None, permission_mode=None, nano=False, dangerously_skip_permissions=False, ) base.update(overrides) return argparse.Namespace(**base) @@ -89,13 +89,13 @@ def test_serve_argv_forwards_every_agent_flag() -> None: _args( port=8317, token="t", workspace="/w", provider="deepseek", model="deepseek-v4-pro", effort="high", permission_mode="plan", - dangerously_skip_permissions=True, + nano=True, dangerously_skip_permissions=True, ) ) assert argv == [ "--host", "127.0.0.1", "--port", "8317", "--token", "t", "--workspace", "/w", "--provider", "deepseek", "--model", "deepseek-v4-pro", "--effort", "high", - "--permission-mode", "plan", "--dangerously-skip-permissions", + "--permission-mode", "plan", "--nano", "--dangerously-skip-permissions", ] diff --git a/tests/nano/test_nano_web.py b/tests/nano/test_nano_web.py new file mode 100644 index 00000000..8c20a506 --- /dev/null +++ b/tests/nano/test_nano_web.py @@ -0,0 +1,308 @@ +"""Nano mode on the web/desktop gateway path (``clawcodex serve`` / ``web``). + +Covers the whole flag chain — ``clawcodex web`` argv → ``clawcodex serve`` +argparse → process-global + ``AgentServerConfig`` → per-session spawn — plus +the wire surfaces the browser client reads: ``/api/status`` (welcome screen, +before any session) and ``session.info`` (init and every republish). + +The runtime itself (registry, prompt, provider switch) is pinned by +tests/nano/test_nano_tui.py on the same ``_AgentSession``; these tests pin the +transport in front of it. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import patch + +import pytest +from starlette.testclient import TestClient + +from src.nano.state import is_nano_mode, reset_nano_mode, set_nano_mode +from src.server.desktop_serve import DesktopServeState, build_app + +from tests.nano.test_nano_tui import _SessionHarness +from tests.server.test_desktop_gateway import ( + TOKEN, + FakeAgent, + FakeManager, + _connect, + _drain_for_response, + _rpc, +) + +# Nano/eco process-globals are reset around every test by +# tests/nano/conftest.py's autouse fixture. +pytestmark = pytest.mark.integration + + +# --------------------------------------------------------------------------- +# Flag plumbing: web argv → serve argparse → config + process-global +# --------------------------------------------------------------------------- + + +def test_web_serve_argv_forwards_nano(): + import argparse + + from src.entrypoints import web_cli + + def _args(**overrides): + base = dict( + host="127.0.0.1", port=8081, token=None, workspace=None, + provider=None, model=None, effort=None, permission_mode=None, + nano=False, dangerously_skip_permissions=False, + ) + base.update(overrides) + return argparse.Namespace(**base) + + assert "--nano" in web_cli._serve_argv(_args(nano=True)) + assert "--nano" not in web_cli._serve_argv(_args()) + + +def test_serve_cli_parses_nano_into_config(monkeypatch): + """--nano reaches AgentServerConfig AND flips the process-global before + the server starts — sessions spawn lazily, so /api/status must already + tell the truth with no session in existence.""" + import src.entrypoints.serve_cli as serve_mod + from src.bootstrap.state import reset_state_for_tests + + captured: dict = {} + + async def _fake_serve(args, workspace, token, agent_config, on_ready=None): + captured["config"] = agent_config + captured["nano_global"] = is_nano_mode() + return 0 + + monkeypatch.setattr(serve_mod, "_serve", _fake_serve) + + try: + assert serve_mod.run_serve_subcommand(["--port", "0", "--nano"]) == 0 + assert captured["config"].nano is True + assert captured["nano_global"] is True + + reset_nano_mode() + captured.clear() + assert serve_mod.run_serve_subcommand(["--port", "0"]) == 0 + assert captured["config"].nano is False + assert captured["nano_global"] is False + finally: + # run_serve_subcommand marks the process interactive; leave the suite + # as it found it. + reset_state_for_tests() + + +def test_spawn_for_override_keeps_nano(tmp_path: Path): + """A session created with the composer's own provider/model (spawn_for's + dataclasses.replace) must still be nano — otherwise picking a model on the + welcome screen would silently resurrect the maximal surface.""" + from src.server.agent_server import AgentServerConfig + + async def _noop_spawn(session_id, cwd, resume): # pragma: no cover - never called + raise AssertionError("base spawn must not be used when overriding") + + state = DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=FakeManager(), + spawn_agent=_noop_spawn, + protocol_version="0.1.0", + agent_config=AgentServerConfig(nano=True), + ) + + seen: dict = {} + + def _capture_make_spawn(config): + seen["config"] = config + return _noop_spawn + + with patch("src.server.agent_server.make_spawn_agent", _capture_make_spawn): + state.spawn_for("deepseek", "deepseek-v4-flash", "high") + + assert seen["config"].nano is True + assert seen["config"].provider_name == "deepseek" + + +# --------------------------------------------------------------------------- +# /api/status — the welcome screen's pre-session nano fact +# --------------------------------------------------------------------------- + + +def _bare_state(tmp_path: Path) -> DesktopServeState: + async def spawn(session_id, cwd, resume): # pragma: no cover - not spawned here + raise AssertionError("status tests never spawn a session") + + return DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=FakeManager(), + spawn_agent=spawn, + protocol_version="0.1.0", + ) + + +def test_api_status_reports_nano(tmp_path: Path): + set_nano_mode(True) + with TestClient(build_app(_bare_state(tmp_path))) as client: + reply = client.get("/api/status", headers={"X-ClawCodex-Session-Token": TOKEN}) + assert reply.status_code == 200 + assert reply.json()["nano"] is True + + +def test_api_status_nano_false_by_default(tmp_path: Path): + with TestClient(build_app(_bare_state(tmp_path))) as client: + reply = client.get("/api/status", headers={"X-ClawCodex-Session-Token": TOKEN}) + assert reply.status_code == 200 + assert reply.json()["nano"] is False + + +# --------------------------------------------------------------------------- +# session.info mapping — init frame and the get_settings republish +# --------------------------------------------------------------------------- + + +def test_init_session_info_maps_nano_strictly(): + """Strict ``is True`` and always stamped: an older agent's init frame + (no field) must read as not-nano, never as unknown.""" + from src.server.desktop_gateway_methods import _init_session_info + + assert _init_session_info({"model": "m", "nano": True})["nano"] is True + assert _init_session_info({"model": "m", "nano": False})["nano"] is False + assert _init_session_info({"model": "m"})["nano"] is False + # Truthy garbage is not a nano session. + assert _init_session_info({"model": "m", "nano": "yes"})["nano"] is False + + +class NanoFakeAgent(FakeAgent): + """The scripted gateway agent, reporting a nano session like the real + agent-server does: init frame + get_settings both carry the flag.""" + + async def messages_from_agent(self): + yield { + "type": "system", + "subtype": "init", + "cwd": "/tmp/w", + "permissionMode": "bypassPermissions", + "model": "fake", + "nano": True, + } + while True: + yield await self.queue.get() + + async def send_to_agent(self, frame: dict) -> None: + request = frame.get("request") or {} + if frame.get("type") == "control_request" and request.get("subtype") == "get_settings": + self.inbound.append(frame) + await self.queue.put({ + "type": "control_response", + "response": { + "subtype": "success", + "request_id": frame.get("request_id"), + "response": { + "model": self.model, + "provider": self.provider, + "permission_mode": self.permission_mode, + "nano": True, + }, + }, + }) + return + await super().send_to_agent(frame) + + +def _nano_fake_state(tmp_path: Path) -> tuple[DesktopServeState, list[NanoFakeAgent]]: + agents: list[NanoFakeAgent] = [] + + async def spawn(session_id, cwd, resume): + agent = NanoFakeAgent() + agents.append(agent) + return agent + + state = DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=FakeManager(), + spawn_agent=spawn, + protocol_version="0.1.0", + ) + return state, agents + + +def test_session_info_carries_nano_through_the_gateway(tmp_path: Path): + """session.create's info and the model-switch republish both stamp nano, + so the browser's chip survives every full session.info replace.""" + state, _ = _nano_fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() # gateway.ready + events: list[dict] = [] + _rpc(ws, 1, "session.create", {"cwd": "/tmp"}) + result = _drain_for_response(ws, 1, events)["result"] + assert result["info"]["nano"] is True + sid = result["session_id"] + + events.clear() + _rpc(ws, 2, "config.set", { + "session_id": sid, "key": "model", + "value": "new-model --provider newprov --session", + }) + assert _drain_for_response(ws, 2, events)["result"]["ok"] is True + infos = [e for e in events if e["type"] == "session.info"] + assert infos, "no session.info published after the model switch" + assert infos[-1]["payload"]["nano"] is True + + +def test_session_info_nano_false_for_a_default_agent(tmp_path: Path): + """The stock FakeAgent reports no nano anywhere — the gateway must say + False on both surfaces, not omit the field.""" + from tests.server.test_desktop_gateway import _fake_state + + state, _ = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() + events: list[dict] = [] + _rpc(ws, 1, "session.create", {"cwd": "/tmp"}) + result = _drain_for_response(ws, 1, events)["result"] + assert result["info"]["nano"] is False + + events.clear() + _rpc(ws, 2, "config.set", { + "session_id": result["session_id"], "key": "model", + "value": "new-model --provider newprov --session", + }) + assert _drain_for_response(ws, 2, events)["result"]["ok"] is True + infos = [e for e in events if e["type"] == "session.info"] + assert infos and infos[-1]["payload"]["nano"] is False + + +# --------------------------------------------------------------------------- +# get_settings on the real _AgentSession (the source publish_session_info reads) +# --------------------------------------------------------------------------- + + +class TestGetSettingsCarriesNano(_SessionHarness): + def _get_settings(self, sess) -> dict: + asyncio.run(sess._handle_control_request({ + "type": "control_request", + "request_id": "req-settings", + "request": {"subtype": "get_settings"}, + })) + frames = [ + call.args[1] + for call in sess.loop.call_soon_threadsafe.call_args_list + if len(call.args) == 2 and isinstance(call.args[1], dict) + ] + replies = [ + f["response"]["response"] for f in frames + if f.get("type") == "control_response" + and (f.get("response") or {}).get("request_id") == "req-settings" + ] + self.assertTrue(replies, "get_settings produced no reply") + return replies[-1] + + def test_nano_session_reports_nano(self) -> None: + sess = self._build(nano=True) + self.assertIs(self._get_settings(sess)["nano"], True) + + def test_default_session_reports_nano_false(self) -> None: + sess = self._build() + self.assertIs(self._get_settings(sess)["nano"], False)