pi-htn · architecture deep-dive

How pi-htn
decomposes a task
& where the model thinks

HTN decomposition is symbolic · LLM inference is 3 bounded artifacts

pr-doctorfix-red-testk8s-medic
01

The shape of the system

Author once, replay many

Inference is split into a single planning call and a trusted symbolic loop

Two phases, two cost profiles

Decomposition is data, not a chat loop

  • Author — a big model reads a session trace once and emits a YAML HTN domain. Validated offline, then frozen.
  • Run — a trusted TypeScript runtime decomposes the YAML with a symbolic planner. No LLM is in the decomposition.
  • Small models only fill narrow gaps: one classification before planning, and per-operator argument filling inside the loop.
  • Validated by Tree-Planner (arXiv:2310.08582): 1 plan-sample + N grounded executions cut tokens ~92%.
AUTHOR big model · 1 call RUN symbolic planner + small-model gap-fills

fix-red-test · the YAML decomposes like this

A domain is a task tree of select / sequence nodes

root · sequence fix-red-test repair · select ¬fixed → first valid rung m-guard approach=guard ∧ ¬guard_tried · MODEL m-dedupe ¬dedupe_tried deterministic fix m-revert ¬revert_tried guaranteed green report · operator fixed ∧ ¬reported each rung: apply → verify (throws on red → replan) the *_tried guard excludes the just-tried rung
worldState: fixed: false approach: guard guard_tried: false conditions: {not:{eq:[fixed,true]}}

The planner picks branches deterministically

World state + a tiny predicate DSL

  • World state is a flat bag of predicates seeded from the domain & plan input.
  • Branch guards use 3 ops: {eq:[k,v]}, {has:k}, {not:…}. No model is asked "which branch?"
  • Effects mutate state after each step: {set:{guard_tried:true}} or bind a tool output via $result.<path>.
  • Because branching is pure data, the same domain is provably validated offline before it ever runs.
02

Exactly where tokens are spent

Three LLM inference artifacts

authoring prompt · hoisted decision call · in-loop arg-fill (mode B)

The whole inference surface

One author call, two run-time calls

  • ① Author prompt — big model, build-time. Trace → reusable YAML domain. Best-of-N, symbolic validator is the hard gate.
  • ② Hoisted decision — small model, once before planning. The single guess that gates a select.
  • ③ In-loop arg-fill — small model, per operator that carries a prompt. Fills tool args; mode B.
① AUTHOR big · build-time ② HOIST small · pre-plan ③ ARG-FILL small · in-loop
① author.ts · buildAuthorPrompt()
// big model · runs ONCE per /htn author. Output is frozen YAML.
You are authoring a reusable HTN domain named "fix-red-test".
Below is the tool-call trace of a successful session. Generalize it.

Trace:
  1. read({"path":"appendWikiStatus.ts"})
  2. edit({"path":"...","guard":"..."})
  3. shell({"cmd":"vitest run idempotency"})   ...

Output ONLY a fenced ```yaml block in this schema:
  domain / worldState / root: {type: select|sequence, tasks}
Each primitive: {name, conditions?, operator:{tool,prompt?,exec?}, effects?}
Identify which steps are conditional; express branch predicates as conditions.
Use $result.<field> in effects. Do not invent tools not in the trace.

The instruction is "generalize, don't replay." The model must lift conditional structure & branch predicates out of a linear trace — not echo it.

N candidates validateDomain() offline · pure planning best PASSING

Authoring quality gate

Best-of-N, but correctness is symbolic

  • The big model returns N candidate YAMLs (temperature>0 for diversity).
  • Each is parsed and run through validateDomain(): for every synthetic world state, findPlan must yield a non-empty plan. Zero operators execute.
  • Score = 1000·passed − failures + richness. The validator is the hard gate; score only ranks among passers.
  • A domain that can't decompose is never saved — reliability is enforced before run-time.
② pr-doctor/run.ts · the hoisted classify call
// small model · ONE call, BEFORE executeDomain() plans the tree.
const classifyPrompt = `A CI job failed. Test output:
---
${testOut.slice(0, 1200)}
---
Classify the failure. Output JSON only, one field "failure_class",
one of: flaky, lint, test. Example: {"failure_class":"flaky"}`;

const reply = await model.complete({ prompt: classifyPrompt, worldState:{} });
// → hoisted into plan input so a select can branch on it
executeDomain(yaml, { input: { pr, failure_class: reply.failure_class } });

Why hoist? The executor plans the whole domain up-front, so a mid-plan $result can't gate a select. The model's one planning-relevant guess must exist before decomposition.

② Hoisted decision

Shapes the plan

  • Runs before planning, in the demo runner
  • Output gates a select branch
  • Hoisted into input as a predicate
  • pr-doctor: failure_class · k8s: action+image · fix-test: approach+line

③ In-loop arg-fill

Fills the operator

  • Runs during the execute loop
  • Fires only when an operator has a prompt
  • Output becomes the tool's arguments (mode B)
  • e.g. the report rung writing a one-sentence note
② the same hoist, two more domains
// k8s-medic · propose a concrete remediation image
`A Kubernetes deployment is failing. Diagnosis: pods are in state
"${reason}" and the current image is "${image}". Propose a corrected,
valid, publicly pullable image. Output JSON only:
  {"action":"set-image", "image":"<image:tag>"}`

// fix-red-test · emit the actual one-line code patch (4B model)
`The function must be idempotent... Emit ONE TypeScript guard statement
for the top of the body that returns systemPrompt unchanged when the
footer is present. Output JSON only:
  {"approach":"guard", "line":"<one statement>"}`

The model does the narrow creative work — classify a failure, name a pullable image, write one guard line — and nothing else. Strategy & ordering belong to the tree.

③ executor.ts · per-operator arg-fill (mode B)
for (const compiledTask of plan) {
  const binding = findBinding(yaml, compiledTask.Name);
  let args = {};
  if (binding.prompt) {                         // only operators with a prompt
    const prompt = renderTemplate(binding.prompt, ctx.WorldState);
    args = await opts.smallModel.complete({ prompt, worldState: ctx.WorldState });
  }
  const result = await opts.tools.invoke(tool, resolveArgs(args, ws), ws);
  compiledTask.applyEffects(ctx);                // literal effects FIRST
  applyResultRefs(yaml, name, result, ctx);      // THEN $result.* refs
}

The prompt is data in the YAML. e.g. k8s-medic's report rung: "...healed via {{last_strategy}}. Write a single-sentence incident note. Output JSON only: {note}"{{…}} rendered from world state at exec time.

response_format: {type:"json_object"} temperature: 0 max_tokens: 1024 chat_template_kwargs: enable_thinking:false

smallModel.ts · LlamaSmallModel

A disciplined JSON oracle

  • OpenAI-compatible client for any llama.cpp / vLLM server; defaults to the shared Zosma devserver so inference stays off the laptop.
  • json_object mode + temperature 0 → deterministic, parseable replies. A tolerant parser strips fences as backup.
  • enable_thinking:false stops reasoning models (qwopus-4b-coder) emitting 18k+ tokens of reasoning_content before the JSON ever lands.
  • Every artifact returns a small JSON object — never prose, never code to exec.

the shared reliability recipe

Model proposes · the ladder backtracks to guaranteed-green

rung 1 model patch verify throws→replan rung 2 deterministic fix verify throws→replan terminal rung guaranteed green red verify → executor replans, *_tried excludes the rung structural backtracking — not a retry loop. circuit breaker trips after maxReplans (5). revert / restore / canonical template

The three configured domains, by inference artifact

Domain② Hoisted call (pre-plan)Ladder rungs (select)③ In-loop callTerminal safety net
pr-doctor
heal red CI
classify → failure_class ∈ {flaky,lint,test} m-rerun → m-lint → m-revert report: one-sentence PR comment revert breaking commit
fix-red-test
heal a RED test
patch → approach + line (4B writes the guard) m-guard → m-dedupe → m-revert report: one-sentence PR comment rewrite fn from canonical template
k8s-medic
heal a deployment
remediate → action + image (pullable tag) m-apply → m-restore report: one-sentence incident note restore last-known-good image
The small model does the narrow creative work.
The HTN owns structure, ordering & backtracking — and
always lands on a rung that is guaranteed to converge.
pi-htn · reliability is structural, not prompted

Recap

Decomposition is symbolic. Inference is three bounded artifacts.

  • ① Author prompt — big model, once, trace → validated YAML
  • ② Hoisted decision — small model, once, gates a select
  • ③ In-loop arg-fill — small model, per prompted operator (mode B)

Everything between is a trusted TypeScript loop over a frozen task tree.