PLANNER(7) NPCAgent Manual PLANNER(7)

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.

npcagent/planner/stage1.pythe forced-tool call
  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.

Two-stage planning and the validation gate A goal is routed either to the live Stage 1 planner or to the deterministic offline planner, producing a HighLevelPlan which then passes through three gated validation passes before a run is created; Stage 2 expands each phase later. goal: str NPCAGENT_FORCE_OFFLINE_PLANNER set, or ANTHROPIC_API_KEY unset? yes no HeuristicStage1Planner model = "heuristic-offline" · no network fixed 5 phases: scope · design · implement · verify · handoff defined in plugins/surfaces/cli/goals.py:419 Stage1Planner messages.create · forced tool submit_plan STAGE1_PLAN_SCHEMA, additionalProperties:false re-validated locally after extraction system prompt ordered for prompt caching PlanGenerationError → fall back to the heuristic planner HighLevelPlan (frozen dataclass) goal · phases: 5–9 PhaseOutline · model · created_at success_criteria: 2–5 strings PhaseOutline: id · name · agent_role · intent · success_criteria[2–4] PlanValidator.validate(plan) — gated, fail-fast between passes pass 1 · JSON schema — "schema validation failed at $.phases[2].intent: …" pass 2 · semantic — agent_role ∈ 5 roles · duplicate phase ids · @tool references pass 3 · sandbox dry-run — injected callable; passed as None on the real goal path raise PlanRejected(plan, validation) rich panel of Phase / Reason rows · exit 1 Project.create() → worktree → WorkflowExecutor Stage 2 expands each phase as it activates
Figure 8 — planning and the gate. The offline fallback is unconditional in two directions: it is chosen up front when no key is configured, and it is also the catch for a 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.

Failure is contained

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:

schema validation failed at $.phases[0].agent_role: 'devloper' is not of type 'string'

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 a frozenset so 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 @token appearing in a phase's intent or in any success criterion is treated as a tool reference and looked up in the live ToolRegistry. 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

Not implemented

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.

CapabilityCard, a frozen dataclass. tags is a tuple so the card stays hashable.
FieldTool cardPlugin cardSkill card
idspec.name<kind>:<id>skill folder name
kindtoolthe PluginKind valueskill
namedisplay name
descriptionroughly one sentence
when_to_useCall when <desc>usage hintLoad this skill when working on: …
exampleargs synthesised from the JSON schemausage hintload_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.

### save_artifact [tool] save_artifact: Write a file into the run output directory. When: Call when Write a file into the run output directory. Example: {"filename": "", "content": ""} Tags: builtin

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.

The two-ledger stall detector and replan loop Bus events feed a handler that updates the mutable progress ledger, which is persisted alongside the frozen task ledger. A monitor wakes every thirty seconds, records no-progress, and if the run is stalled either replans or escalates. RECORDING DETECTING EventBus every run event ledger event handler predicate: event.root_id == run_id ProgressLedger (mutable) current_phase_id · step_count stall_counter · last_progress_at latest_artifacts: dict agent_assignments: dict events_log: list[str] — plain strings TaskLedger (frozen) goal · facts · assumptions plan_summary · success_criteria written once; survives every replan LedgerStore mkstemp → write → fsync → os.replace task_ledger _<run>.json progress_ ledger_ <run>.json _ledger_stall_monitor wakes every 30.0 s record_no_progress() bumps stall_counter only is_stalled? stall_counter ≥ 3 OR now − last_progress_at > 300 s no — save the ledger and sleep again yes replan_count < max_replans (3)? counted per run, not per phase escalate(reason="replan_limit_exceeded") run parks in needs_user no yes _build_plan(goal + "\n\nCurrent progress context…") last 10 artifacts + last 10 event lines are appended validator.validate(new_plan) pass → phase_outlines[:] = new phases · detector.reset() fail → escalate(reason="replan_validation_failed")
Figure 9 — the replan loop. Note what 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:

npcagent/plugins/surfaces/cli/goals.pylines 624–633
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:

planner raised PlanGenerationError: Anthropic response had no tool_use block for submit_plan validator: phase 'design': agent_role 'architect' is not a valid role (allowed: [...]) phase count 3 outside expected band [5, 9] no phase agent_role matched any expected keyword ['researcher']; got roles ['developer', 'reviewer']
Inert flag

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.

See also

npcagent 0.1.0 Apache-2.0 PLANNER(7)