From 081ccf10205f220dbcc0c8589d89e31e83acd362 Mon Sep 17 00:00:00 2001 From: agentforce314 <273884145+agentforce314@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:21:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(nano):=20long-running=20work=20support=20?= =?UTF-8?q?=E2=80=94=20no=20bash=20timeout=20ceiling,=20anti-poll=20guidan?= =?UTF-8?q?ce,=20600-turn=20runner=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dominant failure class in tb21-nano-flash-max-2 (16 of 25 incorrect tasks) is wall-clock death while still working, and the mechanism is measurable: clawcodex bash's 2-minute default / 10-minute cap forces every long build or training run into nohup-and-poll loops — 225 poll-pattern commands across the failed tasks, each poll a full max-thinking model turn. compile-compcert burned 111 polls, 307 bash calls, and died at the 300-turn adapter ceiling mid-build; caffe-cifar-10 (163 calls), train-fasttext (67), make-mips-interpreter (116) show the same signature. pi's bash has no default timeout and pays zero turns for a 25-minute make. Nano now matches that contract: * no default timeout (24h supervision deadline — the abort signal and the task wall-clock remain the backstops; _run_supervised still polls abort continuously) and no 10-minute cap on explicit timeouts; the 1000ms floor stays; stock behavior byte-identical (nano-gated at the resolution site) * Bash nano doc states the contract; one prompt guideline encodes the general practice ('run long builds/training as ONE blocking command, tee to a log — never nohup-and-poll') * runner default --ak max_turns=600 (NANO_MAX_TURNS to override) so an artificial turn ceiling cannot kill a marathon task that is working 5 new tests (nano accepts >cap timeouts, stock still rejects, defaults both ways, floor kept); 70 nano + 125 bash regression tests pass. Co-Authored-By: Claude Fable 5 --- eval/harbor/run_tb21_nano_max.sh | 1 + src/nano/prompt.py | 3 ++ src/nano/tool_docs.py | 7 +++- src/tool_system/tools/bash/bash_tool.py | 24 +++++++++-- tests/nano/test_nano_bash.py | 56 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 tests/nano/test_nano_bash.py diff --git a/eval/harbor/run_tb21_nano_max.sh b/eval/harbor/run_tb21_nano_max.sh index 41ca96cc..91475904 100755 --- a/eval/harbor/run_tb21_nano_max.sh +++ b/eval/harbor/run_tb21_nano_max.sh @@ -83,6 +83,7 @@ PYTHONPATH="$ROOT/eval/harbor" harbor run \ --ak "source=$WHEEL" \ --ak nano=1 \ --ak effort=max \ + --ak "max_turns=${NANO_MAX_TURNS:-600}" \ ${AK_EXTRA[@]+"${AK_EXTRA[@]}"} echo diff --git a/src/nano/prompt.py b/src/nano/prompt.py index 0576ee41..eefeddc6 100644 --- a/src/nano/prompt.py +++ b/src/nano/prompt.py @@ -76,6 +76,9 @@ "files merely by opening them", "- Get a minimal working version of the requested deliverable in " "place early, then iterate to improve it", + "- Run long builds, training runs, or downloads as ONE blocking bash " + "command (tee output to a log) — never launch with nohup and poll in " + "a loop", "- Before finishing, re-read the task and verify each explicit " "requirement against what you actually produced; plausible output " "is not verified output", diff --git a/src/nano/tool_docs.py b/src/nano/tool_docs.py index 05c36d38..2043b6b1 100644 --- a/src/nano/tool_docs.py +++ b/src/nano/tool_docs.py @@ -22,8 +22,11 @@ ), "Bash": ( "Executes a bash command and returns stdout+stderr. The working " - "directory persists between commands; shell state does not. Long " - "output is truncated. Quote paths containing spaces." + "directory persists between commands; shell state does not. No " + "default timeout: a long build or training run can block in a " + "single call (tee output to a log file) — pass timeout (ms) only " + "when you want one. Long output is truncated. Quote paths " + "containing spaces." ), # Edit is intentionally absent: the nano registry swaps in # NanoEditTool (src/nano/edit_tool.py), which carries its own doc for diff --git a/src/tool_system/tools/bash/bash_tool.py b/src/tool_system/tools/bash/bash_tool.py index f6aecb44..84b6f6db 100644 --- a/src/tool_system/tools/bash/bash_tool.py +++ b/src/tool_system/tools/bash/bash_tool.py @@ -659,19 +659,37 @@ def _bash_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: ) # Resolve timeout: prefer explicit timeout (ms), fall back to timeout_s (legacy), then default + # + # Nano mode removes the ceiling entirely (pi's bash has no default + # timeout). Measured on TB 2.1 (tb21-nano-flash-max-2): the 2-minute + # default / 10-minute cap forced nohup-and-poll loops on every long + # build or training run — 225 poll-pattern commands across the 25 + # failed tasks, with compile-compcert alone burning 111 polls and + # dying at the 300-turn ceiling mid-build. One blocking call costs + # zero turns; the benchmark/task wall-clock and the abort signal + # (checked continuously by _run_supervised) remain the backstops. + from src.nano.state import is_nano_mode as _bash_is_nano + + _nano = _bash_is_nano() + _NANO_NO_TIMEOUT_S = 86_400 # 24h — effectively "until abort/wall-clock" timeout_ms = tool_input.get("timeout") if timeout_ms is not None: max_ms = get_max_timeout_ms() if not isinstance(timeout_ms, (int, float)) or timeout_ms < 1000: raise ToolInputError("timeout must be at least 1000 ms") - if timeout_ms > max_ms: + if timeout_ms > max_ms and not _nano: raise ToolInputError(f"timeout must not exceed {max_ms} ms") timeout_s = int(timeout_ms / 1000) else: timeout_s = tool_input.get("timeout_s") if timeout_s is None: - timeout_s = int(get_default_timeout_ms() / 1000) - if not isinstance(timeout_s, int) or timeout_s < 1 or timeout_s > 600: + timeout_s = ( + _NANO_NO_TIMEOUT_S if _nano + else int(get_default_timeout_ms() / 1000) + ) + if not isinstance(timeout_s, int) or timeout_s < 1 or ( + timeout_s > 600 and not _nano + ): raise ToolInputError("timeout_s must be an integer between 1 and 600") # Persist cwd across invocations (port of ``typescript/src/utils/Shell.ts``, diff --git a/tests/nano/test_nano_bash.py b/tests/nano/test_nano_bash.py new file mode 100644 index 00000000..3ec1ee7b --- /dev/null +++ b/tests/nano/test_nano_bash.py @@ -0,0 +1,56 @@ +"""Nano bash: no timeout ceiling (pi parity for long-running work). + +TB 2.1 evidence (tb21-nano-flash-max-2): the 2-minute default / 10-minute +cap forced nohup-and-poll loops — 225 poll-pattern commands across the 25 +failed tasks; compile-compcert burned 111 polls and hit the 300-turn +ceiling mid-build. Under nano a long build blocks in ONE call; the abort +signal and the task wall-clock remain the backstops. Stock behavior is +byte-identical. +""" + +from __future__ import annotations + +import pytest + +from src.nano.state import set_nano_mode +from src.tool_system.errors import ToolInputError +from src.tool_system.tools import BashTool + + +@pytest.fixture +def ctx(tmp_path): + from src.tool_system.context import ToolContext + + return ToolContext(cwd=tmp_path, workspace_root=tmp_path) + + +def _run(ctx, **extra): + return BashTool.call({"command": "echo long-ok", **extra}, ctx) + + +def test_nano_accepts_timeouts_beyond_the_stock_cap(ctx): + set_nano_mode(True) + result = _run(ctx, timeout=1_200_000) # 20 minutes + assert "long-ok" in str(result.output) + + +def test_stock_still_rejects_beyond_cap(ctx): + with pytest.raises(ToolInputError, match="must not exceed"): + _run(ctx, timeout=1_200_000) + + +def test_nano_default_runs_without_explicit_timeout(ctx): + set_nano_mode(True) + result = _run(ctx) + assert "long-ok" in str(result.output) + + +def test_stock_default_unchanged(ctx): + result = _run(ctx) + assert "long-ok" in str(result.output) + + +def test_minimum_floor_still_enforced_in_nano(ctx): + set_nano_mode(True) + with pytest.raises(ToolInputError, match="at least 1000 ms"): + _run(ctx, timeout=10)