planner — plan, validate, expand, replan
Two stages
Planning is split in two because the two halves have different information. Stage 1 sees only the goal and the capability catalog, and produces a coarse outline of five to nine phases. Stage 2 runs later — once per phase, at the moment that phase becomes active — and can therefore see the artefacts earlier phases actually produced.
Both stages use the same mechanism: a single Anthropic message with one tool defined and
tool_choice pinned to it, so the model cannot reply with prose. The tool's
input_schema is then re-validated locally with jsonschema after
extraction — the same schema enforced twice, on both sides of the wire.
response = await client.messages.create( model=self._model, # _DEFAULT_MODEL = "claude-opus-4-7" max_tokens=self._max_tokens, # _DEFAULT_MAX_TOKENS = 4096 tools=[tool], tool_choice={"type": "tool", "name": _PLAN_TOOL_NAME}, # "submit_plan" system=system_prompt, messages=[{"role": "user", "content": goal}], )
Stage 2 is identical in shape with
_DETAIL_TOOL_NAME = "submit_phase_detail". The anthropic
import is deliberately lazy, inside _ensure_client(), so importing the
module never touches ANTHROPIC_API_KEY. The model and token constants above
are repository constants, quoted verbatim.
PlanGenerationError from the live planner. A run
therefore always gets a plan. What it does not always get is a good one — which
is precisely what the validation gate is for.Stage 2 receives one extra input the first stage cannot have:
current_artifacts, a dict of name → snippet from earlier phases, truncated at
2000 characters per entry. Its output is a PhaseDetail carrying
steps (each with id, description, an optional
tool_name and skill_id, and an expected_output), a
transitions map, and one to four success criteria. Those steps are appended
to <task_plan_dir>/plan.md via an atomic writer and are also injected
into the agent's prompt as a ## Phase Steps block.
A Stage 2 failure never fails the run. _build_stage2_expander catches
PlanExpansionError, logs
stage2_expander.live_failed_falling_back_to_heuristic, and calls
HeuristicStage2Planner instead, which emits a stable
plan → execute → verify triple. The executor wraps the whole expander in a
second try/except and continues with no per-phase detail at all if that also fails.
Validation gate
The three passes are gated: pass N only runs if pass N−1 produced zero errors. Within a pass, every error is collected before returning, so a single run of the validator tells you everything wrong at that level rather than only the first thing.
Pass 1 — schema
_plan_to_dict() strips goal, model and
created_at, leaving the wire shape — phases and
success_criteria — and validates it against
STAGE1_PLAN_SCHEMA. Errors are formatted with the JSON path when there is
one:
Pass 2 — semantic references
Three independent checks, all accumulated.
- Agent role membership
- Roles must be in
DEFAULT_VALID_AGENT_ROLES:developer,reviewer,researcher,planner,checkpoint_reviewer. The constructor snapshots the set to afrozensetso a caller cannot mutate it mid-flight; passing an empty set disables the check.phase 'design': agent_role 'architect' is not a valid role (allowed: ['checkpoint_reviewer', 'developer', 'planner', 'researcher', 'reviewer']) - Duplicate phase ids
- Counted with
collections.Counter.duplicate phase id 'implement': appears 2 times - Tool references
- Any
@tokenappearing in a phase'sintentor in any success criterion is treated as a tool reference and looked up in the liveToolRegistry. The regex is@([A-Za-z_][A-Za-z0-9_]*); plan-level criteria are attributed to the synthetic phase id<plan>.phase 'verify': references unknown tool 'run_pytest_suite' (not registered in tool registry)
This third check is the one that earns the gate its place in the pipeline. The module
docstring gives the motivating case directly: a model that writes
@find_file_xyz when the registry only holds @find_file. Catching
it here costs a dictionary lookup. Catching it after a tmux session and a provider CLI
have started costs a whole run.
Pass 3 — sandbox dry-run
Pass 3 is a hook, not a feature. The shipped
default_sandbox_dry_run only emits an error for a phase whose
intent is exactly the string "FAIL", and its
tool_registry argument is accepted and explicitly discarded
(_ = tool_registry). On the production goal path
goals.py passes sandbox_dry_run=None, with the comment
"Pass 3 (sandbox dry-run) is deferred to T100/T101 so we leave it disabled here". Two of
three passes are real.
Capability cards
The planner is never handed raw tool schemas. It is handed cards: a fixed, compact summary of each tool, plugin and skill, budgeted at roughly 400 characters — about 100 tokens — apiece. The whole catalog is budgeted at 5000 characters.
| Field | Tool card | Plugin card | Skill card |
|---|---|---|---|
id | spec.name | <kind>:<id> | skill folder name |
kind | tool | the PluginKind value | skill |
name | display name | ||
description | roughly one sentence | ||
when_to_use | Call when <desc> | usage hint | Load this skill when working on: … |
example | args synthesised from the JSON schema | usage hint | load_skill('<id>') |
tags | (provider_id,) | (kind, *capabilities) | frontmatter tags |
Rendering is deterministic and truncation is explicit — when the catalog exceeds its
budget the oldest cards are dropped one at a time and a
<truncated/> marker is appended, so the planner can see that it is not
being shown everything.
Selection can be embedding-driven — CapabilityCatalog(embedder=…) ranks by
cosine similarity of the goal against name + description + tags. Without an
embedder it falls back to keyword overlap counting. Either way the sort key is
(-score, card.id), so ties break deterministically and two runs of the same
goal see the same catalog. No embedder is wired on the default path.
The same progressive-disclosure idea governs skills. SkillIndex renders a
400-character metadata card per skill under the header "These skills are available;
load them via load_skill(<id>)", within a 5000-character budget.
When the budget is exceeded, candidate cards are dropped oldest-first —
active skills are never truncated.
Ledgers and replanning
Two ledgers per run, following the Magentic-One split. One is frozen and records the contract the run is held to; the other is mutable and records what has actually happened. A background task compares them and decides whether the run has stopped making progress.
record_no_progress does not do: it bumps the counter but leaves
last_progress_at alone. The source comment explains why — refreshing the
timestamp there "would mask a real silent stall", since the two conditions in
is_stalled are meant to catch different failures. The counter catches an
agent that is busy but going nowhere; the timestamp catches one that has gone quiet
entirely.Persistence is atomic and deliberately unforgiving. LedgerStore writes via
mkstemp in the destination directory, then flush,
os.fsync and os.replace. On read, a missing file returns
None, but corrupt JSON or a missing required key raises — the
docstring states that "silent return on corrupt persistence would hide bugs".
The monitor is also careful about its own shutdown. _stop_ledger_monitor
sets the stop event, waits five seconds, cancels, waits five more, and if the task is
still unresponsive logs ledger_monitor.cancel_unresponsive rather than
hanging the process — a replan in flight when the run ends must not block cleanup.
Workflow selection
Which of the four catalog workflows a goal gets is decided by substring matching, before the plan is even consulted. It is worth reading in full because it is far simpler than the surrounding machinery would suggest:
624 def _select_workflow_id(goal: str, plan: HighLevelPlan) -> str: 625 del plan 626 goal_l = goal.lower() 627 if any(token in goal_l for token in ("roguelike", "game", "phaser")): 628 return "full-expedition" 629 if any(token in goal_l for token in ("marketing", "site", "saas", "landing")): 630 return "blueprint-build" 631 if any(token in goal_l for token in ("bug", "fix", "debug", "regression")): 632 return "bug-hunt" 633 return "quick-fix"
The plan argument is taken and immediately deleted — the
planner's output has no influence on workflow choice. The token lists are literal and
unordered, so a goal mentioning a "site" gets blueprint-build whether or
not it is a web task, and one mentioning a "game" gets the five-phase
full-expedition. Because fix is a substring test, a goal
containing "prefix" or "fixture" routes to bug-hunt. Pass
--workflow to bypass the heuristic entirely.
Evaluation harness
planner/eval.py runs a set of golden goals through Stage 1 and the validator
and reports a pass rate. A goal passes when the planner does not raise, the validator is
clean, the phase count falls inside a band (default 5–9), and at least one phase's
agent_role matches an expected keyword by case-insensitive substring — so
developer matches backend_developer.
Failure reasons are strings, and the harness accumulates them all:
EvalConfig.judge_enabled is plumbed through but never read, and
EvalResult.judged_score is unconditionally None. The source
says the flag "is reserved for a follow-up wiring". There is no LLM-judge backend; the
harness measures structural validity only.
Golden goals live in tests/planner_eval/golden_goals.json and the harness is
driven by tests/planner_eval/test_planner_eval.py, so it runs as part of the
ordinary pytest invocation rather than as a separate benchmark step.