writeonce/docs/examples/agent-loop/test_loop.py
shoney.arickathil d5c76d1dcf docs: six-bucket status board; recover lost vision doc; stop ignoring docs/
- 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.
2026-08-10 21:52:59 +02:00

105 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Smoke-test the agent loop without any model.
Stands up a canned OpenAI-compatible /chat/completions endpoint on a local
port and drives agent.run_turn() through a scripted conversation:
round 1: the "model" calls read_file on notes.txt -> executed for real
round 2: it sends a tool call with broken JSON args -> fed back as an error, not a crash
round 3: it returns a final answer
This verifies the three load-bearing behaviours of the loop: tool execution
with result feedback, errors-as-results self-correction, and termination on
a plain (tool-call-free) answer.
Usage: python3 test_loop.py # standard library only, no model needed
"""
import json
import os
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
MARKER = "the WAL fsyncs before acknowledging the commit"
# What the fake model answers, round by round.
SCRIPT = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "type": "function", "function": {
"name": "read_file",
"arguments": json.dumps({"path": "notes.txt"})}}]},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_2", "type": "function", "function": {
"name": "search",
"arguments": '{"pattern": '}}]}, # deliberately broken JSON
{"role": "assistant",
"content": f"FINAL: per notes.txt, {MARKER}."},
]
REQUESTS: list[dict] = []
class MockModel(BaseHTTPRequestHandler):
def do_POST(self):
REQUESTS.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
body = json.dumps({"choices": [{"message": SCRIPT[len(REQUESTS) - 1]}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
def main() -> int:
server = ThreadingHTTPServer(("127.0.0.1", 0), MockModel)
threading.Thread(target=server.serve_forever, daemon=True).start()
workdir = tempfile.mkdtemp(prefix="agent-loop-test-")
with open(os.path.join(workdir, "notes.txt"), "w") as f:
f.write(f"Durability rule: {MARKER}.\n")
# agent.py reads its configuration at import time — set env first.
os.environ["LLM_URL"] = f"http://127.0.0.1:{server.server_address[1]}/v1"
os.environ["LLM_MODEL"] = "mock"
os.environ["AGENT_ROOT"] = workdir
sys.path.insert(0, HERE)
import agent
answer = agent.run_turn([
{"role": "system", "content": "test"},
{"role": "user", "content": "what is the durability rule?"},
])
server.shutdown()
def tool_results(request: dict) -> list[str]:
return [m["content"] for m in request["messages"] if m.get("role") == "tool"]
assert len(REQUESTS) == 3, f"expected 3 model rounds, got {len(REQUESTS)}"
# Round 2's request must carry the real file content back as a tool result.
round2 = tool_results(REQUESTS[1])
assert any(MARKER in r for r in round2), f"file content not fed back: {round2}"
print("ok — tool call executed, result appended to the transcript")
# Round 3's request must carry the JSON error as a result, not a crash.
round3 = tool_results(REQUESTS[2])
assert any("[agent]" in r and "JSON" in r for r in round3), \
f"broken arguments not reported back: {round3}"
print("ok — malformed tool arguments came back as an error result")
assert answer.startswith("FINAL:") and MARKER in answer, f"unexpected answer: {answer}"
print("ok — loop terminated on the tool-call-free answer")
print(f"\nOK — the agent loop round-trips tools, self-corrects, and stops.\n"
f"Now run it against a real model: python3 {os.path.join(HERE, 'agent.py')}")
return 0
if __name__ == "__main__":
sys.exit(main())