darioooooo0o's picture
tiny-agent-112m: base + RL weights, tokenizer, code, model card
4397e12 verified
Raw History Blame Contribute Delete
7.44 kB
"""The agent's tools: Pi's default four (bash, read, edit, write) + submit, run against one workspace.
Mechanics shared by Claude Code / Pi / OpenCode / Hermes:
* read returns line-numbered text with offset/limit;
* edit is exact string replacement, must match exactly once unless replace_all, and requires
a prior read of that file in this episode;
* every failure comes back as a tool result (never an exception), so the model can recover;
* long outputs are head/tail truncated with a marker that says how to see more.
bash runs inside bubblewrap: no network, read-only /usr, only the workspace is writable, with
CPU/memory/file-size/process limits and a wall-clock timeout.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
MAX_RESULT_CHARS = 6000 # ~1.5k tokens: sized for an 8k-context model, not harness defaults
READ_DEFAULT_LIMIT = 200
BASH_TIMEOUT_S = 10
TOOL_SPECS = [
("bash", "command", "Run a shell command in /work (no network)."),
("read", "path, offset?, limit?", "Read a file with line numbers (offset is 1-based)."),
("edit", "path, old_string, new_string, replace_all?", "Replace exact text in a file you have read."),
("write", "path, content", "Create or overwrite a file."),
("submit", "answer", "Finish with the final answer."),
]
def truncate(text: str, limit: int = MAX_RESULT_CHARS, hint: str = "") -> str:
if len(text) <= limit:
return text
head, tail = text[: limit * 2 // 3], text[-limit // 3:]
omitted = text[len(head): len(text) - len(tail)]
n_lines = omitted.count("\n")
return f"{head}\n[... truncated {len(omitted)} chars / {n_lines} lines{hint} ...]\n{tail}"
def bwrap_cmd(workdir: str, command: str, timeout: int) -> list[str]:
limits = "ulimit -v 2000000; ulimit -t %d; ulimit -f 20000; ulimit -u 256;" % timeout
return ["timeout", "-k", "1", str(timeout), "bwrap", "--unshare-all", "--die-with-parent", "--new-session",
"--ro-bind", "/usr", "/usr", "--symlink", "usr/lib64", "/lib64", "--symlink", "usr/bin", "/bin",
"--symlink", "usr/lib", "/lib", "--symlink", "usr/sbin", "/sbin", "--proc", "/proc", "--dev", "/dev",
"--tmpfs", "/tmp", "--bind", workdir, "/work", "--chdir", "/work", "--clearenv",
"--setenv", "HOME", "/work", "--setenv", "PATH", "/usr/bin", "--setenv", "LANG", "C.UTF-8",
# no .pyc: an edit within the same second and of the same size would reuse stale bytecode
"--setenv", "PYTHONDONTWRITEBYTECODE", "1",
*(["--ro-bind", "/etc/abrt", "/etc/abrt"] if os.path.isdir("/etc/abrt") else []),
"--", "bash", "-c", limits + command]
@dataclass
class Workspace:
"""A throwaway directory on tmpfs holding one episode's files."""
files: dict[str, str]
root: str = ""
read_paths: set = field(default_factory=set)
submitted: str | None = None
def __post_init__(self):
base = "/dev/shm/tiny-agent-ws"
os.makedirs(base, exist_ok=True)
self.root = tempfile.mkdtemp(dir=base)
for rel, content in self.files.items():
p = os.path.join(self.root, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w") as f:
f.write(content)
def close(self):
shutil.rmtree(self.root, ignore_errors=True)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
# -- path handling: tool paths are relative to /work (absolute /work/... also accepted)
def _resolve(self, path: str) -> str | None:
if path.startswith("/work/"):
path = path[len("/work/"):]
elif path == "/work":
path = "."
full = os.path.realpath(os.path.join(self.root, path))
if full != self.root and not full.startswith(self.root + os.sep):
return None
return full
def call(self, name: str, args: dict) -> str:
try:
fn = getattr(self, f"tool_{name}", None)
if fn is None:
return f"Error: unknown tool '{name}'. Available: bash, read, edit, write, submit."
return fn(**args)
except (TypeError, ValueError, AttributeError, OverflowError, OSError) as e: # offset="ten", path=5, a dir
return f"Error: bad arguments for {name}: {e}"
def tool_bash(self, command: str, timeout: int = BASH_TIMEOUT_S) -> str:
timeout = max(1, min(int(timeout), 30))
try:
r = subprocess.run(bwrap_cmd(self.root, command, timeout), capture_output=True, timeout=timeout + 5)
except subprocess.TimeoutExpired:
return f"Error: command timed out after {timeout}s"
out = (r.stdout + r.stderr).decode("utf-8", "replace")
if r.returncode == 124:
out += f"\n[timed out after {timeout}s]"
elif r.returncode:
out += f"\n[exit code {r.returncode}]"
return truncate(out.strip() or "(no output)", hint=", narrow the command (e.g. | head, grep)")
def tool_read(self, path: str, offset: int = 1, limit: int = READ_DEFAULT_LIMIT) -> str:
full = self._resolve(path)
if full is None:
return "Error: path is outside /work"
if os.path.isdir(full):
return f"Error: {path} is a directory. Use bash with ls."
if not os.path.exists(full):
return f"Error: file not found: {path}"
with open(full, errors="replace") as f:
lines = f.read().split("\n")
self.read_paths.add(full)
offset, limit = max(1, int(offset)), max(1, int(limit))
chunk = lines[offset - 1: offset - 1 + limit]
body = "\n".join(f"{offset + i:6d}\t{l}" for i, l in enumerate(chunk))
rest = len(lines) - (offset - 1 + len(chunk))
if rest > 0:
body += f"\n[{rest} more lines, use offset={offset + len(chunk)}]"
return truncate(body or "(empty file)", hint=", use a smaller limit")
def tool_edit(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
full = self._resolve(path)
if full is None:
return "Error: path is outside /work"
if not os.path.exists(full):
return f"Error: file not found: {path}"
if full not in self.read_paths:
return f"Error: read {path} before editing it"
text = open(full).read()
n = text.count(old_string)
if n == 0:
return "Error: old_string not found in file"
if n > 1 and not replace_all:
return f"Error: old_string appears {n} times; add context to make it unique or set replace_all"
with open(full, "w") as f:
f.write(text.replace(old_string, new_string))
return f"Edited {path} ({n} replacement{'s' if n > 1 else ''})"
def tool_write(self, path: str, content: str) -> str:
full = self._resolve(path)
if full is None:
return "Error: path is outside /work"
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(content)
self.read_paths.add(full)
return f"Wrote {path} ({content.count(chr(10)) + 1} lines)"
def tool_submit(self, answer: str) -> str:
self.submitted = str(answer)
return "Submitted."