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
185 changes: 185 additions & 0 deletions src/nano/bash_tail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""pi-style rolling-tail output capture for nano bash.

Port of pi's shell-output.ts capture model (packages/agent/src/harness/
utils/shell-output.ts). Two properties, both absent from the stock tool:

* **Tail-keep, not head-keep.** For a long build or training run the
signal is at the END of the output (the error, the final metrics);
stock truncation keeps ``s[:limit]`` and drops exactly that.
* **The full output survives.** Once a stream crosses the reply limit it
spills, complete, to a temp file whose path is handed to the model —
so the model can grep the whole log afterward instead of re-running.

The lazy spill is lossless the way pi's is: memory is allowed to hold up
to 2x the limit before any trimming, and the spill file is created the
moment the total crosses 1x — everything captured so far is still in
memory at that point, so the file is complete from byte 0 and every later
chunk is appended as it arrives. Memory stays bounded at ~2x the limit
no matter how much the command prints.

Wired only under nano (the bash tool's nano gate); stock bash keeps its
unbounded in-memory capture and head-keep truncation, byte-identical.
Each spool is normally fed by exactly one drain thread, but a detached
descendant holding the pipe open can leave that thread alive past the
reader join — ``finish()`` then races a late ``feed()`` — so the tiny
uncontended lock is load-bearing, not decoration.
"""

from __future__ import annotations

import tempfile
import threading
from collections import deque
from dataclasses import dataclass


@dataclass
class TailCapture:
"""Finished capture of one stream (stdout or stderr)."""

text: str # decoded rolling tail — the whole stream when small
total_bytes: int
total_lines: int
spilled: bool
# Path of the complete-output temp file; None when spilling was
# requested but failed (disk error) — the tail is then all we have.
spill_path: str | None


class TailSpool:
def __init__(self, limit_bytes: int, label: str) -> None:
self._limit = max(1, limit_bytes)
self._label = label
self._chunks: deque[bytes] = deque()
self._mem_bytes = 0
self._total_bytes = 0
self._newlines = 0
self._ends_with_newline = True
self._spilled = False
self._spill_failed = False
self._file = None
self._path: str | None = None
self._lock = threading.Lock()
self._finished = False

def feed(self, chunk: bytes) -> None:
if not chunk:
return
with self._lock:
self._feed_locked(chunk)

def _feed_locked(self, chunk: bytes) -> None:
if self._finished:
# A straggler chunk from an abandoned drain thread after the
# result was already assembled — nothing left to attach it to.
return
self._total_bytes += len(chunk)
self._newlines += chunk.count(b"\n")
self._ends_with_newline = chunk.endswith(b"\n")
self._chunks.append(chunk)
self._mem_bytes += len(chunk)

if not self._spilled and self._total_bytes > self._limit:
self._spilled = True
self._open_spill()

if self._file is not None:
try:
self._file.write(chunk)
except (OSError, ValueError):
self._abandon_spill()

# Trim only after the spill file exists (or failed) — before that
# the memory copy is the only complete copy.
if self._spilled:
while self._mem_bytes > 2 * self._limit and len(self._chunks) > 1:
self._mem_bytes -= len(self._chunks.popleft())

def _open_spill(self) -> None:
try:
self._file = tempfile.NamedTemporaryFile(
mode="wb",
prefix=f"bash-{self._label}-",
suffix=".log",
delete=False,
)
self._path = self._file.name
# Nothing has been trimmed yet, so this is the stream from
# byte 0. The chunk that tripped the limit is already in
# ``_chunks`` and gets written by the caller's append.
for prior in list(self._chunks)[:-1]:
self._file.write(prior)
except OSError:
self._abandon_spill()

def _abandon_spill(self) -> None:
self._spill_failed = True
if self._file is not None:
try:
self._file.close()
except OSError:
pass
self._file = None
self._path = None

def finish(self) -> TailCapture:
with self._lock:
return self._finish_locked()

def _finish_locked(self) -> TailCapture:
self._finished = True
if self._file is not None:
try:
self._file.close()
except OSError:
self._abandon_spill()
self._file = None
tail = b"".join(self._chunks)
if self._spilled:
# The front of the tail may sit mid-UTF-8-sequence after a
# trim; skip continuation bytes so the decode starts clean
# (pi's trimToLastUtf8Bytes).
start = 0
while start < len(tail) and (tail[start] & 0xC0) == 0x80:
start += 1
tail = tail[start:]
total_lines = self._newlines + (
1 if self._total_bytes and not self._ends_with_newline else 0
)
return TailCapture(
text=tail.decode(errors="replace"),
total_bytes=self._total_bytes,
total_lines=total_lines,
spilled=self._spilled,
spill_path=self._path,
)


def _format_size(n: int) -> str:
if n < 1024:
return f"{n}B"
if n < 1024 * 1024:
return f"{n / 1024:.1f}KB"
return f"{n / (1024 * 1024):.1f}MB"


def render_tail(capture: TailCapture, limit_chars: int) -> str:
"""Reply text for one stream: the last ``limit_chars`` plus a footer
naming the totals and the complete-output file (pi's bash.ts footer).

Small streams (never spilled, within the limit) pass through
untouched — byte-identical to today's small-output replies.
"""
if not capture.spilled and len(capture.text) <= limit_chars:
return capture.text
shown = capture.text[-limit_chars:]
shown_lines = shown.count("\n") + (0 if shown.endswith("\n") else 1)
header = (
f"[Showing last {shown_lines} of {capture.total_lines} lines "
f"({_format_size(capture.total_bytes)} total)."
)
if capture.spill_path is not None:
footer = f"{header} Full output: {capture.spill_path}]"
else:
footer = f"{header} Earlier output was discarded.]"
return f"{shown}\n\n{footer}"
10 changes: 7 additions & 3 deletions src/nano/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@
"files merely by opening them",
"- Get a minimal working version of the requested deliverable in "
"place early, then iterate to improve it",
"- Before finishing, re-read the task and verify each explicit "
"requirement against what you actually produced; plausible output "
"is not verified output",
"- 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 list every explicit "
"requirement and constraint (exact formats, tolerances, numeric "
"bounds, required sources or methods), then test your artifact "
"against each one; plausible output is not verified output",
"- Be concise in your responses",
"- Show file paths clearly when working with files",
)
Expand Down
26 changes: 26 additions & 0 deletions src/nano/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ def build_nano_registry() -> ToolRegistry:
doc = NANO_TOOL_DOCS.get(tool.name)
if doc is not None:
tool = replace(tool, prompt=lambda _doc=doc: _doc)
if tool.name == "Bash":
tool = replace(tool, input_schema=_nano_bash_schema(tool.input_schema))
registry.register(tool)
try:
from src.tool_system.tools import VisionAnalyzeTool
Expand All @@ -87,6 +89,30 @@ def build_nano_registry() -> ToolRegistry:
return registry


def _nano_bash_schema(schema) -> dict:
"""Bash schema minus the background-execution trap.

``run_in_background`` needs the TaskOutput tool to retrieve results —
absent from the nano surface, so a backgrounded command strands its
output and forces the poll loops the no-timeout contract exists to
eliminate (tb21-nano-flash-max-2: 29 background launches across 9
trials, all long-running-work tasks). With no default timeout, one
blocking call is strictly better. The stale "(1-600)" cap text on the
legacy ``timeout_s`` goes too. additionalProperties stays false, so a
model that still passes run_in_background gets an actionable
validation error naming the property.
"""
out = dict(schema)
props = dict(out.get("properties") or {})
props.pop("run_in_background", None)
if "timeout_s" in props:
ts = dict(props["timeout_s"])
ts["description"] = "Timeout in seconds"
props["timeout_s"] = ts
out["properties"] = props
return out


def _nano_websearch_configured() -> bool:
"""Explicit opt-in for WebSearch on the nano surface.

Expand Down
10 changes: 8 additions & 2 deletions src/nano/tool_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@
),
"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 — pass timeout (ms) only when you want one. A "
"command with NO output for 10 minutes is killed as stuck, so "
"keep long jobs chatty (verbose flags, tee to a log). Long "
"output returns only the tail plus the path of a file holding "
"the full output — grep it, don't re-run. 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
Loading
Loading