120 lines
3.8 KiB
Python
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())
|