agentic-ai-engineering/pipeline/harness_loop.py

120 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Tiny durable loop harness primitives.
Loop engineering is just: pick next task, run one bounded step, write a receipt.
No dashboard required until this boring JSONL spine is proven useful.
"""
from __future__ import annotations
import argparse
import json
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Iterable, Optional
@dataclass
class LoopTask:
id: str
title: str
status: str = "todo"
@dataclass
class LoopEvent:
run_id: str
type: str
data: dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@dataclass
class CostBudget:
max_tokens: int
used_tokens: int = 0
def allow(self, estimate_tokens: int) -> bool:
return self.used_tokens + estimate_tokens <= self.max_tokens
def record(self, tokens: int) -> None:
self.used_tokens += tokens
def load_tasks(path: str) -> list[LoopTask]:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
return [LoopTask(**item) for item in raw]
def save_tasks(path: str, tasks: Iterable[LoopTask]) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump([asdict(task) for task in tasks], f, indent=2)
f.write("\n")
def next_task(tasks: Iterable[LoopTask]) -> Optional[LoopTask]:
return next((task for task in tasks if task.status == "todo"), None)
def append_event(path: str, event: LoopEvent) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(asdict(event), ensure_ascii=False) + "\n")
def read_events(path: str) -> list[LoopEvent]:
if not os.path.exists(path):
return []
with open(path, "r", encoding="utf-8") as f:
return [LoopEvent(**json.loads(line)) for line in f if line.strip()]
def run_one_cycle(tasks_path: str, events_path: str, run_id: str, budget: CostBudget, estimate_tokens: int = 0) -> dict[str, Any]:
"""Claim one todo task if budget allows; callers do the actual agent work."""
if not budget.allow(estimate_tokens):
event = LoopEvent(run_id, "blocked", {"reason": "token_budget", "estimate_tokens": estimate_tokens, "budget": asdict(budget)})
append_event(events_path, event)
return {"status": "blocked", "reason": "token_budget"}
tasks = load_tasks(tasks_path)
task = next_task(tasks)
if task is None:
event = LoopEvent(run_id, "done", {"reason": "no_todo_tasks"})
append_event(events_path, event)
return {"status": "done"}
task.status = "claimed"
budget.record(estimate_tokens)
save_tasks(tasks_path, tasks)
event = LoopEvent(run_id, "claimed", {"task": asdict(task), "estimate_tokens": estimate_tokens, "budget": asdict(budget)})
append_event(events_path, event)
return {"status": "claimed", "task": asdict(task), "budget": asdict(budget)}
def main() -> int:
parser = argparse.ArgumentParser(description="Run one bounded loop-harness cycle")
parser.add_argument("--tasks", required=True, help="JSON task list")
parser.add_argument("--events", required=True, help="JSONL event log")
parser.add_argument("--run-id", required=True)
parser.add_argument("--max-tokens", type=int, default=20_000)
parser.add_argument("--used-tokens", type=int, default=0)
parser.add_argument("--estimate-tokens", type=int, default=0)
args = parser.parse_args()
receipt = run_one_cycle(
args.tasks,
args.events,
args.run_id,
CostBudget(args.max_tokens, args.used_tokens),
args.estimate_tokens,
)
print(json.dumps(receipt, indent=2))
return 1 if receipt.get("status") == "blocked" else 0
if __name__ == "__main__":
raise SystemExit(main())