pi-htn · architecture deep-dive
HTN decomposition is symbolic · LLM inference is 3 bounded artifacts
The shape of the system
Inference is split into a single planning call and a trusted symbolic loop
Two phases, two cost profiles
fix-red-test · the YAML decomposes like this
select / sequence nodesThe planner picks branches deterministically
{eq:[k,v]}, {has:k}, {not:…}. No model is asked "which branch?"{set:{guard_tried:true}} or bind a tool output via $result.<path>.Exactly where tokens are spent
authoring prompt · hoisted decision call · in-loop arg-fill (mode B)
The whole inference surface
select.prompt. Fills tool args; mode B.// 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.
Authoring quality gate
validateDomain(): for every synthetic world state, findPlan must yield a non-empty plan. Zero operators execute.1000·passed − failures + richness. The validator is the hard gate; score only ranks among passers.// 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
select branchinput as a predicatefailure_class · k8s: action+image · fix-test: approach+line③ In-loop arg-fill
promptreport rung writing a one-sentence note// 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.
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.
smallModel.ts · LlamaSmallModel
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.the shared reliability recipe
| Domain | ② Hoisted call (pre-plan) | Ladder rungs (select) | ③ In-loop call | Terminal 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.pi-htn · reliability is structural, not prompted
The HTN owns structure, ordering & backtracking — and
always lands on a rung that is guaranteed to converge.
Recap
selectEverything between is a trusted TypeScript loop over a frozen task tree.