- 00-kanban.md rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It tracked only the Rust runtime before, so the whole OOP track (wovm shipped, woc Tasks 1-8 shipped) was invisible. - Board now records iteration 3's known gaps instead of silently owing them: `?T` plumbed but unenforced; E205/E201/E203/E204 dead, so structural interface satisfaction is unchecked. - New discarded.md — settled rejections with reasons so they are not re-proposed: inheritance, `abstract` newtypes, Money/SKU/Float, Dynamic/cast/ macro/extern, AOT-to-C, Menhir, shared engine state, external deployer. - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. - Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Rules were half-fiction — 33 of 34 exploration files were already tracked, so they swallowed only *new* files. - Dropped the docs ignore rules (exploration, oop-vm, superpowers/plans, examples/agent-loop, examples/mcp-think) with a do-not-re-add comment; added __pycache__/*.pyc. `tests/` stays ignored but warns that the next plan lands the corpus there.
285 lines
12 KiB
Python
285 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""agent-loop — a complete agent in one file, on a local model.
|
|
|
|
The whole trick behind Claude Code, opencode, Cursor and every other
|
|
"agentic" tool is one loop:
|
|
|
|
send the transcript + tool schemas to the model
|
|
while the model answers with tool calls:
|
|
run the tools, append the results to the transcript
|
|
send again
|
|
print the final text
|
|
|
|
Everything else those tools add (permissions, context management,
|
|
sub-agents) is elaboration on that loop. This file IS the loop, small
|
|
enough to read in one sitting: an OpenAI-compatible chat endpoint
|
|
(llama-server from prototypes/llama-moe-stream, or Ollama) + three
|
|
read-only repo tools + the guards that keep the loop alive when the
|
|
model misbehaves.
|
|
|
|
Run (one-shot): python3 agent.py "what does crates/rt/src/shard.rs do?"
|
|
Run (interactive): python3 agent.py
|
|
|
|
Configuration (environment):
|
|
LLM_URL OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
|
LLM_MODEL model name / alias (default qwen3-coder)
|
|
LLM_TIMEOUT per-request timeout in seconds (default 300)
|
|
AGENT_ROOT directory the tools may touch (default: current directory)
|
|
MAX_TURNS tool rounds per user message (default 20)
|
|
|
|
Dependencies: Python standard library only.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8080/v1").rstrip("/")
|
|
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3-coder")
|
|
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "300"))
|
|
ROOT = os.path.realpath(os.environ.get("AGENT_ROOT", os.getcwd()))
|
|
MAX_TURNS = int(os.environ.get("MAX_TURNS", "20"))
|
|
MAX_RESULT = 8_000 # chars of tool output fed back per call
|
|
SKIP_DIRS = {".git", "target", "node_modules", "build", "__pycache__", ".cache"}
|
|
|
|
|
|
# ---------------------------------------------------------------- the tools
|
|
# Schemas are the contract shown to the model; implementations are the
|
|
# security boundary. Read-only on purpose — an agent is exactly as dangerous
|
|
# as its tools, never more.
|
|
|
|
TOOLS = [
|
|
{"type": "function", "function": {
|
|
"name": "list_dir",
|
|
"description": "List one directory: entries with a trailing / for "
|
|
"subdirectories and a byte size for files.",
|
|
"parameters": {"type": "object", "properties": {
|
|
"path": {"type": "string",
|
|
"description": "directory, relative to the project root"},
|
|
}, "required": ["path"]}}},
|
|
{"type": "function", "function": {
|
|
"name": "read_file",
|
|
"description": "Read a text file with line numbers. Large files are "
|
|
"windowed — pass offset (1-based first line) and limit "
|
|
"(max lines) to page through.",
|
|
"parameters": {"type": "object", "properties": {
|
|
"path": {"type": "string", "description": "file, relative to the project root"},
|
|
"offset": {"type": "integer", "description": "first line to show, 1-based (default 1)"},
|
|
"limit": {"type": "integer", "description": "max lines to show (default 200)"},
|
|
}, "required": ["path"]}}},
|
|
{"type": "function", "function": {
|
|
"name": "search",
|
|
"description": "Search file contents under a directory with a Python "
|
|
"regular expression. Returns path:line: text matches. "
|
|
"Use a specific pattern — results cap at 100 matches.",
|
|
"parameters": {"type": "object", "properties": {
|
|
"pattern": {"type": "string", "description": "Python regex to find"},
|
|
"path": {"type": "string", "description": "directory to search, relative to the project root (default: whole root)"},
|
|
}, "required": ["pattern"]}}},
|
|
]
|
|
|
|
|
|
class ToolError(Exception):
|
|
"""A tool refusing to do something — reported to the model, never fatal."""
|
|
|
|
|
|
def _resolve(path: str) -> str:
|
|
"""Confine every path the model asks for to ROOT (symlinks resolved)."""
|
|
full = os.path.realpath(os.path.join(ROOT, path))
|
|
if full != ROOT and not full.startswith(ROOT + os.sep):
|
|
raise ToolError(f"path escapes the project root: {path}")
|
|
return full
|
|
|
|
|
|
def list_dir(path: str = ".") -> str:
|
|
full = _resolve(path)
|
|
if not os.path.isdir(full):
|
|
raise ToolError(f"not a directory: {path}")
|
|
rows = []
|
|
for name in sorted(os.listdir(full)):
|
|
p = os.path.join(full, name)
|
|
rows.append(f"{name}/" if os.path.isdir(p)
|
|
else f"{name} ({os.path.getsize(p)} bytes)")
|
|
return "\n".join(rows) or "(empty directory)"
|
|
|
|
|
|
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
|
|
full = _resolve(path)
|
|
if os.path.isdir(full):
|
|
raise ToolError(f"{path} is a directory — use list_dir")
|
|
try:
|
|
with open(full, errors="replace") as f:
|
|
lines = f.readlines()
|
|
except FileNotFoundError:
|
|
raise ToolError(f"no such file: {path}")
|
|
if not lines:
|
|
return "(empty file)"
|
|
offset = max(1, int(offset))
|
|
limit = max(1, min(int(limit), 1000))
|
|
window = lines[offset - 1: offset - 1 + limit]
|
|
if not window:
|
|
raise ToolError(f"{path} has only {len(lines)} lines; offset {offset} is past the end")
|
|
out = "".join(f"{i}\t{line}" for i, line in enumerate(window, offset))
|
|
last = offset + len(window) - 1
|
|
if last < len(lines):
|
|
out += (f"\n[agent] showing lines {offset}-{last} of {len(lines)} — "
|
|
f"call again with offset={last + 1} for more")
|
|
return out
|
|
|
|
|
|
def search(pattern: str, path: str = ".") -> str:
|
|
try:
|
|
rx = re.compile(pattern)
|
|
except re.error as e:
|
|
raise ToolError(f"bad regex {pattern!r}: {e}")
|
|
start = _resolve(path)
|
|
hits: list[str] = []
|
|
for dirpath, dirnames, filenames in os.walk(start): # never follows symlinks
|
|
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
for fname in sorted(filenames):
|
|
full = os.path.join(dirpath, fname)
|
|
if os.path.islink(full) or os.path.getsize(full) > 2_000_000:
|
|
continue
|
|
try:
|
|
with open(full, errors="replace") as f:
|
|
head = f.read(1024)
|
|
if "\0" in head: # binary — skip
|
|
continue
|
|
f.seek(0)
|
|
for i, line in enumerate(f, 1):
|
|
if rx.search(line):
|
|
rel = os.path.relpath(full, ROOT)
|
|
hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
|
|
if len(hits) >= 100:
|
|
hits.append("[agent] 100-match cap hit — tighten the pattern or narrow the path")
|
|
return "\n".join(hits)
|
|
except OSError:
|
|
continue
|
|
return "\n".join(hits) or f"no matches for {pattern!r} under {path}"
|
|
|
|
|
|
TOOL_IMPLS = {"list_dir": list_dir, "read_file": read_file, "search": search}
|
|
|
|
|
|
# ---------------------------------------------------------- executing calls
|
|
# The one rule that keeps the loop alive: NEVER raise at the model. Whatever
|
|
# goes wrong — unknown tool, broken JSON, missing file — comes back as the
|
|
# tool result, in words. A tool-calling model reads the error and corrects
|
|
# itself on the next round; an exception would just kill the conversation.
|
|
|
|
def execute(call: dict) -> str:
|
|
fn_block = call.get("function") or {}
|
|
name = fn_block.get("name", "")
|
|
impl = TOOL_IMPLS.get(name)
|
|
if impl is None:
|
|
return f"[agent] unknown tool {name!r} — available: {', '.join(TOOL_IMPLS)}"
|
|
try:
|
|
args = json.loads(fn_block.get("arguments") or "{}")
|
|
except json.JSONDecodeError as e:
|
|
return f"[agent] arguments were not valid JSON ({e}) — resend the call with corrected JSON"
|
|
if not isinstance(args, dict):
|
|
return "[agent] arguments must be a JSON object"
|
|
try:
|
|
out = impl(**args)
|
|
except ToolError as e:
|
|
return f"[agent] {e}"
|
|
except TypeError as e:
|
|
return f"[agent] bad arguments for {name}: {e}"
|
|
except OSError as e:
|
|
return f"[agent] {name} failed: {e}"
|
|
if len(out) > MAX_RESULT:
|
|
out = (out[:MAX_RESULT] + f"\n[agent] truncated — {len(out)} chars total; "
|
|
"narrow the request (offset/limit, tighter pattern)")
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------ the loop
|
|
|
|
def chat(messages: list[dict]) -> dict:
|
|
"""One request to the model. Returns the assistant message verbatim."""
|
|
body = json.dumps({
|
|
"model": LLM_MODEL,
|
|
"messages": messages,
|
|
"tools": TOOLS,
|
|
"tool_choice": "auto",
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{LLM_URL}/chat/completions",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
|
data = json.load(resp)
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode(errors="replace")[:400]
|
|
raise SystemExit(f"the model endpoint rejected the request (HTTP {e.code}): {detail}")
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
raise SystemExit(
|
|
f"cannot reach the model at {LLM_URL} ({e}).\n"
|
|
"Start one first:\n"
|
|
" prototypes/llama-moe-stream/start-local-agents.sh # llama-server on :8080\n"
|
|
" LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b … # or a pulled Ollama model")
|
|
return data["choices"][0]["message"]
|
|
|
|
|
|
def run_turn(messages: list[dict]) -> str:
|
|
"""THE AGENT LOOP. Everything above exists to serve these few lines."""
|
|
for _ in range(MAX_TURNS):
|
|
msg = chat(messages)
|
|
messages.append(msg) # the transcript is the only state
|
|
calls = msg.get("tool_calls") or []
|
|
if not calls: # no tool call = the model is done
|
|
return _strip_think(msg.get("content") or "")
|
|
for call in calls:
|
|
fn = call.get("function") or {}
|
|
print(f" ⚙ {fn.get('name', '?')}({(fn.get('arguments') or '')[:120]})",
|
|
file=sys.stderr)
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"content": execute(call),
|
|
})
|
|
return (f"[agent] stopped after {MAX_TURNS} tool rounds without a final answer — "
|
|
"ask a narrower question or raise MAX_TURNS")
|
|
|
|
|
|
def _strip_think(text: str) -> str:
|
|
# Reasoning models may inline chain-of-thought as <think>…</think>;
|
|
# the user wants the conclusion, not the scratchpad.
|
|
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
|
|
|
|
# ----------------------------------------------------------------- the shell
|
|
|
|
SYSTEM = (f"You are a code assistant working inside the project rooted at {ROOT}. "
|
|
"Use the tools to look at real files before answering; never invent "
|
|
"file contents or paths. When you have enough evidence, answer "
|
|
"concisely and cite locations as path:line.")
|
|
|
|
|
|
def main() -> None:
|
|
messages: list[dict] = [{"role": "system", "content": SYSTEM}]
|
|
if len(sys.argv) > 1: # one-shot
|
|
messages.append({"role": "user", "content": " ".join(sys.argv[1:])})
|
|
print(run_turn(messages))
|
|
return
|
|
print(f"agent-loop — model {LLM_MODEL} at {LLM_URL}\n"
|
|
f"root {ROOT} (Ctrl-D to exit; the conversation persists across questions)")
|
|
while True: # interactive
|
|
try:
|
|
line = input("\nyou> ").strip()
|
|
except EOFError:
|
|
print()
|
|
return
|
|
if not line:
|
|
continue
|
|
messages.append({"role": "user", "content": line})
|
|
print(run_turn(messages))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|