- 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.
105 lines
3.9 KiB
Python
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())
|