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
1 change: 1 addition & 0 deletions eval/harbor/run_tb21_nano_max.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/nano/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions src/nano/tool_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions src/tool_system/tools/bash/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``,
Expand Down
56 changes: 56 additions & 0 deletions tests/nano/test_nano_bash.py
Original file line number Diff line number Diff line change
@@ -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)
Loading