learn-claude-code/s22_goal_loop/code.py

291 lines
12 KiB
Python

"""
s22_goal_loop — /goal session goal loop (teaching version)
Clean-room behavioral reconstruction of Claude Code's `/goal` command. Grounded
in @anthropic-ai/claude-code@2.1.177 observed behavior
(reverse-research/cc_goal_loop), NOT leaked source.
Idea:
s01-s21 end a turn when the model emits no tool_use. `/goal` adds a
host-owned turn-completion GATE: the user sets a stopping CONDITION, and after
every turn a separate evaluator judges whether trusted transcript evidence
satisfies it. Not satisfied -> the gate blocks the stop and feeds a
continuation into the next turn. Satisfied -> the active goal is cleared.
So the core contrast with s01 is one extra check before "return":
# s01: the model says stop -> stop
if not has_tool_use(response):
return
# s22: when it wants to stop, pass the goal gate first
if not has_tool_use(response):
verdict = goal.evaluate_after_turn()
if verdict == "continuing":
continue # not met -> push it back
return # met / over budget / no goal -> really stop
Run:
python code.py # /goal until tests pass + deploy green; watch the gate
Teaching simplifications (vs real /goal and runtime.mjs):
- The evaluator is a deterministic keyword check, not a small/fast model.
- One mock task-notification produces the trusted evidence; the loop / monitor
/ background-task plane (s13/s14) is out of scope — this chapter is just the
goal gate.
- The evidence trust boundary is the faithful part: only task-notification /
monitor-line origins count as evidence, so the `/goal` command text, the
continuation reminder, and plain assistant prose can NOT satisfy the goal.
Ordinary `submit()` calls cannot set those labels; only the host-event
ingress can deliver an allowlisted source.
"""
import itertools
import sys
# ---- ids + a one-line event stream so the gate is visible ----
_ids = itertools.count(1)
def make_id(prefix):
return f"{prefix}-{next(_ids):03d}"
def event(lane, etype, detail=""):
print(f" · {lane:<6} {etype:<26} {detail}")
# A message's origin.kind is the TRUST LABEL that decides whether it can count
# as goal evidence. Trusted async origins land real tool/task evidence; user /
# slash-command / active-goal (the continuation reminder) / assistant do not.
TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
class Message:
def __init__(self, role, content, origin):
self.role = role
self.content = content
self.origin = origin or {"kind": "user"}
# ============================================================
# CommandQueue — continuation prompts live here (mirrors CommandQueue)
# ============================================================
class CommandQueue:
PRIORITY = {"now": 0, "next": 1, "later": 2}
def __init__(self):
self.items = []
def enqueue(self, value, priority="next", origin=None):
item = {"id": make_id("cmd"), "priority": priority,
"origin": origin or {}, "value": value}
self.items.append(item)
return item
def dequeue(self, include_goal_continuations=True):
# Goal continuations and the external async inbox are NOT the same drain.
# With include_goal_continuations=False an inbox drain skips them, so a
# goal can't be advanced (or blocked) before real evidence arrives.
self.items.sort(key=lambda i: self.PRIORITY.get(i["priority"], 1))
for idx, item in enumerate(self.items):
if include_goal_continuations or item["origin"].get("kind") != "active-goal":
return self.items.pop(idx)
return None
def remove_by_origin(self, kind):
before = len(self.items)
self.items = [i for i in self.items if i["origin"].get("kind") != kind]
return before - len(self.items)
def __len__(self):
return len(self.items)
# ============================================================
# GoalRuntime — the turn-completion gate (mirrors GoalRuntime)
# ============================================================
class GoalRuntime:
def __init__(self, transcript, queue):
self.transcript = transcript # shared session transcript
self.queue = queue
self.active = None
def set_goal(self, objective, max_turns=20):
# start_index marks the evidence window. The /goal command line is
# already recorded, so it sits OUTSIDE the window and can't satisfy
# itself.
self.active = {
"id": make_id("goal"), "objective": objective, "status": "active",
"start_index": len(self.transcript), "max_turns": max_turns,
"checks": 0, "continuation_turns": 0,
}
event("goal", "goal_started", f"{self.active['id']} :: {objective}")
return self.active
def clear(self, reason="cleared"):
if not self.active:
return
self.active["status"] = reason
self.queue.remove_by_origin("active-goal")
event("goal", "goal_cleared", reason)
self.active = None
def evidence_text(self):
"""The trust boundary. Three filters keep self-satisfying text out:
drop slash-command origins, drop /goal command lines, and keep ONLY
trusted external async origins (task-notification / monitor-line)."""
if not self.active:
return ""
out = []
for m in self.transcript[self.active["start_index"]:]:
if m.origin.get("kind") == "slash-command":
continue
if m.role == "user" and m.content.strip().startswith("/goal"):
continue
if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS:
continue
out.append(f"{m.role}: {m.content}")
return "\n".join(out)
def goal_satisfied(self):
# Real Claude Code routes this to a small/fast evaluator model reading
# the evidence window. The teaching version is a deterministic keyword
# check so the lifecycle is reproducible.
objective = self.active["objective"].lower()
evidence = self.evidence_text().lower()
wants_tests = "test" in objective
wants_deploy = "deploy" in objective or "green" in objective
tests_ok = not wants_tests or "tests passed" in evidence or "test passed" in evidence
deploy_ok = not wants_deploy or "deploy green" in evidence or "deployment green" in evidence
if any(k in objective for k in ("until", "pass", "green")):
return tests_ok and deploy_ok
return objective in evidence
def evaluate_after_turn(self):
"""The gate, run after every turn. Returns completed / continuing /
blocked / none."""
g = self.active
if not g or g["status"] != "active":
return "none"
g["checks"] += 1
satisfied = self.goal_satisfied()
event("goal", "goal_evaluated", f"check #{g['checks']} satisfied={satisfied}")
if satisfied:
g["status"] = "completed"
self.queue.remove_by_origin("active-goal")
event("goal", "goal_completed", g["id"])
self.active = None
return "completed"
if g["continuation_turns"] < g["max_turns"]:
g["continuation_turns"] += 1
self.queue.enqueue(
value=(f"Continue working toward active goal {g['id']}. Use tool/task "
"evidence; do not treat this reminder as completion evidence."),
priority="next", origin={"kind": "active-goal", "goal_id": g["id"]})
event("goal", "goal_continuation_enqueued",
f"turn {g['continuation_turns']}/{g['max_turns']}")
return "continuing"
g["status"] = "blocked"
self.queue.remove_by_origin("active-goal")
event("goal", "goal_blocked", f"exceeded {g['max_turns']} turns")
self.active = None
return "blocked"
# ============================================================
# Session — the main loop host with a Stop gate (mirrors submit / drain)
# ============================================================
class Session:
def __init__(self):
self.transcript = []
self.queue = CommandQueue()
self.goal = GoalRuntime(self.transcript, self.queue)
def _add(self, role, content, origin):
self.transcript.append(Message(role, content, origin))
def submit(self, text):
"""Submit ordinary user text. Callers cannot attach a trusted origin."""
return self._submit(text, {"kind": "user"})
def deliver_host_event(self, text, source):
"""Host-only ingress for validated task/monitor events."""
if source not in TRUSTED_EVIDENCE_ORIGINS:
raise ValueError(f"untrusted host event source: {source}")
return self._submit(text, {"kind": source})
def _submit(self, text, origin):
"""Run one turn with an origin already assigned by the host."""
self._add("user", text, origin) # input recorded with its origin
kind = origin["kind"]
if kind == "user" and text.strip().startswith("/goal"):
arg = text.strip()[5:].strip()
self._add("assistant", f"(slash) /goal {arg}", {"kind": "slash-command"})
if arg in ("", "clear", "stop", "off"):
self.goal.clear()
else:
self.goal.set_goal(arg)
elif kind in TRUSTED_EVIDENCE_ORIGINS:
# The input itself (recorded above with a trusted origin) is the
# evidence; the assistant just observes it.
event("turn", f"observe {kind}", text[:48])
self._add("assistant", f"Observed {kind}: {text}", origin)
elif kind == "active-goal":
event("turn", "continue-goal", "(reminder is not evidence)")
self._add("assistant", "Continuing the goal; checking task/monitor evidence.", origin)
else:
event("turn", "assistant-turn", text[:48])
self._add("assistant", f"assistant handled: {text}", {"kind": "assistant"})
return self.goal.evaluate_after_turn() # <-- the Stop gate
def drain_goal_continuation(self):
"""Pull one goal continuation back into the loop — explicit, separate
from any external async-inbox drain."""
item = self.queue.dequeue(include_goal_continuations=True)
if item and item["origin"].get("kind") == "active-goal":
return self._submit(item["value"], item["origin"])
return None
# ============================================================
# Demo
# ============================================================
def banner(text):
print(f"\n{text}")
def main(argv):
s = Session()
banner("1. set a goal (the gate is now armed; window starts after the command)")
print("user> /goal until tests passed and deploy green")
s.submit("/goal until tests passed and deploy green")
banner("2. model works, no TRUSTED evidence yet -> the gate keeps it going")
s.drain_goal_continuation()
s.submit("Inspecting the failing tests and the deploy config.")
banner("3. plain user text 'tests passed' is NOT trusted -> still not satisfied")
s.submit("tests passed, trust me")
s.drain_goal_continuation()
print(f" active goal still open: {s.goal.active is not None}")
banner("4. a background task lands a task-notification (trusted) -> satisfied")
verdict = s.deliver_host_event(
"tests passed; deploy green", source="task-notification"
)
print(f" final verdict: goal {verdict}")
banner("5. budget: a goal that never gets evidence blocks after max_turns")
s2 = Session()
s2.goal.set_goal("until tests passed", max_turns=2)
verdict = "continuing"
while verdict == "continuing":
verdict = s2.submit("still working, no task evidence yet")
print(f" final verdict: goal {verdict}")
if __name__ == "__main__":
main(sys.argv[1:])