Files
2026-07-13 12:09:03 +08:00

344 lines
12 KiB
Python

"""Agent harness loop contract — deterministic state machine, hooks, pull points.
Conceptual references:
- ./docs/en.md (this lesson)
- Phase 14 lesson 01 (agent loop fundamentals)
- Phase 13 lesson 02 (tool protocols overview)
Stdlib only. Run: python3 code/main.py
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Iterable
class State(str, Enum):
IDLE = "idle"
PLANNING = "planning"
EXECUTING = "executing"
AWAITING_TOOL = "awaiting_tool"
REFLECTING = "reflecting"
DONE = "done"
HOOK_TOPICS = (
"before_plan",
"after_plan",
"before_step",
"after_step",
"before_tool_call",
"after_tool_call",
"on_error",
"on_pause",
"on_budget_exceeded",
"on_complete",
)
EVENT_TYPES = (
"session.start",
"plan.draft",
"plan.commit",
"step.start",
"step.end",
"tool.call",
"tool.result",
"tool.error",
"budget.warn",
"session.pause",
"session.complete",
)
class HookAbort(Exception):
"""Raised by a hook to cancel the in-flight turn."""
@dataclass
class Event:
type: str
payload: dict
ts: float
def to_dict(self) -> dict:
return {"type": self.type, "payload": self.payload, "ts": self.ts}
@dataclass
class Budget:
max_turns: int = 8
max_tool_calls: int = 16
max_wall_seconds: float = 30.0
turns: int = 0
tool_calls: int = 0
started_at: float = field(default_factory=time.time)
def remaining_seconds(self) -> float:
return max(0.0, self.max_wall_seconds - (time.time() - self.started_at))
def exceeded(self) -> str | None:
if self.turns >= self.max_turns:
return "turns"
if self.tool_calls >= self.max_tool_calls:
return "tool_calls"
if self.remaining_seconds() <= 0.0:
return "wall_clock"
return None
@dataclass
class Step:
id: int
description: str
requires_tool: bool
tool_name: str | None = None
tool_args: dict = field(default_factory=dict)
result: Any = None
error: str | None = None
@dataclass
class PullRequest:
"""Returned from run()/resume() when the loop yields control."""
reason: str
state: State
payload: dict
@dataclass
class SessionResult:
state: State
reason: str
steps: list[Step]
events: list[Event]
class HookRegistry:
def __init__(self) -> None:
self._subs: dict[str, list[Callable[[dict], Any]]] = {t: [] for t in HOOK_TOPICS}
def on(self, topic: str, fn: Callable[[dict], Any]) -> None:
if topic not in self._subs:
raise ValueError(f"unknown hook topic: {topic}")
self._subs[topic].append(fn)
def fire(self, topic: str, payload: dict) -> list[Any]:
results = []
for fn in self._subs[topic]:
results.append(fn(payload))
return results
Planner = Callable[[str, list[Step]], list[Step]]
def _default_planner(goal: str, history: list[Step]) -> list[Step]:
"""Deterministic stand-in planner. Returns a fixed three-step plan."""
if history:
return []
return [
Step(id=1, description=f"interpret goal: {goal}", requires_tool=False),
Step(id=2, description="fetch user record", requires_tool=True,
tool_name="db.get_user", tool_args={"id": 42}),
Step(id=3, description="summarize and respond", requires_tool=True,
tool_name="format.summary", tool_args={"style": "short"}),
]
class HarnessLoop:
"""Six-state deterministic loop with hook topics and event stream."""
def __init__(
self,
planner: Planner | None = None,
budget: Budget | None = None,
) -> None:
self.state: State = State.IDLE
self.hooks = HookRegistry()
self.budget = budget or Budget()
self._planner: Planner = planner or _default_planner
self._goal: str = ""
self._plan: list[Step] = []
self._cursor: int = 0
self._events: list[Event] = []
self._history: list[Step] = []
self._reason: str = ""
self._prev_state: State | None = None
@property
def events(self) -> list[Event]:
return list(self._events)
@property
def plan(self) -> list[Step]:
return list(self._plan)
def _emit(self, etype: str, payload: dict) -> None:
if etype not in EVENT_TYPES:
raise ValueError(f"unknown event type: {etype}")
self._events.append(Event(type=etype, payload=payload, ts=time.time()))
def _transition(self, target: State) -> None:
legal: dict[State, set[State]] = {
State.IDLE: {State.PLANNING},
State.PLANNING: {State.EXECUTING, State.IDLE, State.DONE},
State.EXECUTING: {State.AWAITING_TOOL, State.REFLECTING, State.IDLE},
State.AWAITING_TOOL: {State.REFLECTING, State.IDLE},
State.REFLECTING: {State.PLANNING, State.EXECUTING, State.DONE, State.IDLE},
State.DONE: set(),
}
if target not in legal[self.state]:
raise RuntimeError(f"illegal transition {self.state.value} -> {target.value}")
self.state = target
def _check_budget(self) -> PullRequest | None:
which = self.budget.exceeded()
if which is None:
return None
self._emit("budget.warn", {"limit": which})
self.hooks.fire("on_budget_exceeded", {"limit": which, "budget": self.budget})
self._reason = f"budget_exceeded:{which}"
self._prev_state = self.state
return self._pause(self._reason)
def _pause(self, reason: str) -> PullRequest:
self._emit("session.pause", {"reason": reason})
self.hooks.fire("on_pause", {"reason": reason})
self._transition(State.IDLE)
return PullRequest(reason=reason, state=self.state, payload={"reason": reason})
def run(self, goal: str) -> PullRequest | SessionResult:
if self.state != State.IDLE:
raise RuntimeError(f"run() requires IDLE, got {self.state.value}")
self._goal = goal
self.budget.started_at = time.time()
self._emit("session.start", {"goal": goal})
return self._step()
def resume(self, payload: dict | None = None) -> PullRequest | SessionResult:
if self.state == State.IDLE and self._reason.startswith("budget_exceeded"):
self.budget.turns = 0
self.budget.tool_calls = 0
self.budget.started_at = time.time()
self._reason = ""
prev = self._prev_state
self._prev_state = None
if not self._plan:
return self._begin_plan()
if prev == State.EXECUTING:
self.state = State.EXECUTING
else:
self.state = State.REFLECTING
return self._step()
if self.state == State.AWAITING_TOOL:
if payload is None:
raise ValueError("resume from AWAITING_TOOL requires a payload")
current = self._plan[self._cursor]
if "error" in payload:
current.error = str(payload["error"])
self._emit("tool.error", {"step": current.id, "error": current.error})
self.hooks.fire("on_error", {"step": current, "error": current.error})
else:
current.result = payload.get("result")
self._emit("tool.result", {"step": current.id, "result": current.result})
self.hooks.fire("after_tool_call", {"step": current})
self._transition(State.REFLECTING)
return self._step()
raise RuntimeError(f"resume() unsupported from state {self.state.value}")
def _begin_plan(self) -> PullRequest | SessionResult:
self._transition(State.PLANNING)
self.hooks.fire("before_plan", {"goal": self._goal, "history": list(self._history)})
draft = self._planner(self._goal, list(self._history))
self._emit("plan.draft", {"steps": [s.description for s in draft]})
self.hooks.fire("after_plan", {"steps": draft})
self._plan = draft
self._cursor = 0
self._emit("plan.commit", {"count": len(draft)})
if not draft:
return self._complete("no_plan")
self._transition(State.EXECUTING)
return self._step()
def _step(self) -> PullRequest | SessionResult:
if self.state == State.IDLE:
return self._begin_plan()
budget_hit = self._check_budget()
if budget_hit is not None:
return budget_hit
if self.state == State.REFLECTING:
self._cursor += 1
self.budget.turns += 1
if self._cursor >= len(self._plan):
return self._complete("goal_met")
self._transition(State.EXECUTING)
return self._step()
if self.state != State.EXECUTING:
raise RuntimeError(f"_step requires EXECUTING/REFLECTING, got {self.state.value}")
step = self._plan[self._cursor]
self.hooks.fire("before_step", {"step": step})
self._emit("step.start", {"step_id": step.id, "desc": step.description})
if step.requires_tool:
try:
self.hooks.fire("before_tool_call", {"step": step})
except HookAbort as exc:
step.error = f"hook_abort:{exc}"
self._emit("tool.error", {"step": step.id, "error": step.error})
self.hooks.fire("on_error", {"step": step, "error": step.error})
self._transition(State.REFLECTING)
return self._step()
self.budget.tool_calls += 1
self._emit("tool.call", {"step": step.id, "tool": step.tool_name, "args": step.tool_args})
self._transition(State.AWAITING_TOOL)
self._emit("step.end", {"step_id": step.id, "outcome": "awaiting_tool"})
self.hooks.fire("after_step", {"step": step, "outcome": "awaiting_tool"})
return PullRequest(
reason="tool_call",
state=self.state,
payload={"tool": step.tool_name, "args": step.tool_args, "step_id": step.id},
)
step.result = f"ok:{step.description}"
self._emit("step.end", {"step_id": step.id, "outcome": "ok"})
self.hooks.fire("after_step", {"step": step, "outcome": "ok"})
self._transition(State.REFLECTING)
return self._step()
def _complete(self, reason: str) -> SessionResult:
self._emit("session.complete", {"reason": reason})
self.hooks.fire("on_complete", {"reason": reason})
self._transition(State.DONE)
self._reason = reason
return SessionResult(state=self.state, reason=reason, steps=list(self._plan), events=list(self._events))
def _demo() -> None:
loop = HarnessLoop()
fired: list[str] = []
for topic in HOOK_TOPICS:
loop.hooks.on(topic, lambda payload, t=topic: fired.append(t))
out = loop.run("ship the release notes")
assert isinstance(out, PullRequest) and out.reason == "tool_call"
out = loop.resume({"result": {"id": 42, "name": "ada"}})
assert isinstance(out, PullRequest) and out.reason == "tool_call"
final = loop.resume({"result": "summary text"})
assert isinstance(final, SessionResult)
assert final.state == State.DONE
assert final.reason == "goal_met"
report = {
"events": [e.type for e in final.events],
"hooks_fired": fired,
"final_state": final.state.value,
"final_reason": final.reason,
}
print(json.dumps(report, indent=2))
if __name__ == "__main__":
_demo()