Agent Patterns
PATTERN 06/GOAL DECOMPOSITION

Planning

Also known as: task decomposition, plan-then-execute, todo list, checklist, goal tracking.

A goal gets stated once, at the top, and then every pass pushes it further back. By step nine the loop is working from what the last few messages say, and "done" quietly stops meaning the whole task — it starts meaning the part still in view.

Problem

INCIDENT-4637SEV-2open

Agent reports a ten-file migration complete after three files

An agent was asked to migrate every route handler in src/routes from callback-style errors to a typed Result<T, E> return. It migrated three, got pulled into an unrelated lint warning it noticed on the way, ran the build — green — and closed the task as complete. Seven handlers were still on the old pattern, and nothing in the repository disagreed.

The loop running it had no representation of the goal at all. The scope lived in the first user message and nowhere else, and nothing counted anything:

TSPY
src/run-agent-improvised.tsCopy
const runAgent = async (client: Anthropic, task: string) => {
  // The entire goal exists in exactly one place — the first user message.
  // "Migrate every handler in src/routes from callback errors to Result<T, E>."
  const messages: Message[] = [
    { role: 'system', content: SYSTEM_PROMPT },
    { role: 'user', content: task },
  ];

  let response: Response | null = null;

  let steps = 0;
  const MAX_STEPS = 24;
  while (steps < MAX_STEPS) {
    steps++;
    const message = await client.messages.create({
      max_tokens: 4096,
      model: 'claude-haiku-4-5',
      messages,
    });

    messages.push({ role: 'assistant', content: message.content });
    response = extractResponse(message);

    // Nothing in this loop knows how many files the task named, or how many
    // of them have been touched. "Done" is whatever the model says it is.
    if (response?.finalAnswer === true) {
      console.log(response.content);
      break;
    }

    if (response?.finalAnswer === false && response.content.startsWith('edit_file:')) {
      const path = response.content.replace('edit_file:', '');
      messages.push({ role: 'tool', content: editFile(path) });
    }

    if (response?.finalAnswer === false && response.content.startsWith('build')) {
      // Green whether ten handlers were migrated or three: the old pattern is
      // still valid TypeScript, so nothing about it fails a build.
      messages.push({ role: 'tool', content: runBuild() });
    }
  }
};

The build is the part that makes this silent. Seven unmigrated handlers compile exactly as well as they did before anyone asked for a refactor — nothing forces the new pattern globally, so a green build is evidence about syntax, not about coverage:

BASHCopy
$ npm run build

  ✓ compiled successfully in 3.1s
  0 errors, 0 warnings

$ rg -l 'cb\(err' src/routes | wc -l
  7

Watch the scope drain out of the transcript one pass at a time:

migrate_handlers — no plan

Every individual statement in that transcript is true. Three handlers really were migrated, the warning really is gone, the build really is green. What was never checked is not whether a step succeeded — it is how many steps there were, and that number was last mentioned eleven messages ago, by the person who asked. A sharper prompt doesn't fix this: "migrate all of them" is already what the task said.

Solution

Same model, same repository, same detour. What changes is that the goal gets written down as something countable before the first edit — enumerated against the directory rather than remembered from a message:

TSPY
src/task-plan.tsCopy
export type PlanStep = {
  id: string;
  what: string; // stated so "done" is decidable from outside the model
  dependsOn: string[]; // what actually blocks it — not the order it was written in
  done: boolean;
};

// The scope is enumerated once, up front, against the real directory — not
// re-derived at step nine from a transcript the goal is thirty messages back in.
export function buildPlan(handlers: string[]): PlanStep[] {
  const migrations = handlers.map((file, i) => ({
    id: `S${i + 2}`,
    what: `Migrate ${file} to Result<T, E>`,
    dependsOn: ['S1'],
    done: false,
  }));

  return [
    { id: 'S1', what: 'Add Result<T, E> and ok/err helpers to src/result.ts', dependsOn: [], done: false },
    ...migrations,
    {
      id: `S${handlers.length + 2}`,
      what: 'Delete the callback error helper in src/errors.ts',
      dependsOn: migrations.map((s) => s.id), // deletable only once nothing imports it
      done: false,
    },
  ];
}

export const remaining = (plan: PlanStep[]): PlanStep[] => plan.filter((s) => !s.done);

// The next step to hand the model is the next *unblocked* one, which is not
// always the next one on the list.
export const ready = (plan: PlanStep[]): PlanStep[] =>
  remaining(plan).filter((s) => s.dependsOn.every((id) => plan.find((p) => p.id === id)?.done === true));

export const check = (plan: PlanStep[], id: string): PlanStep[] =>
  plan.map((s) => (s.id === id ? { ...s, done: true } : s));

export const renderPlan = (plan: PlanStep[]): string =>
  plan.map((s) => `- [${s.done ? 'x' : ' '}] ${s.id} ${s.what}`).join('\n');

remaining is what makes the plan load-bearing; dependsOn is what keeps it honest about order. Deleting src/errors.ts is not the last step because it was written last — it is the last step because it fails while any handler still imports it. Here is the artifact as it stood at the exact moment the improvised run declared victory:

MARKDOWNCopy
- [x] S1  Add Result<T, E> and ok/err helpers to src/result.ts
- [x] S2  Migrate src/routes/checkout.ts to Result<T, E>
- [x] S3  Migrate src/routes/refunds.ts to Result<T, E>
- [x] S4  Migrate src/routes/webhooks.ts to Result<T, E>
- [ ] S5  Migrate src/routes/invoices.ts to Result<T, E>
- [ ] S6  Migrate src/routes/payouts.ts to Result<T, E>
- [ ] S7  Migrate src/routes/disputes.ts to Result<T, E>
- [ ] S8  Migrate src/routes/customers.ts to Result<T, E>
- [ ] S9  Migrate src/routes/subscriptions.ts to Result<T, E>
- [ ] S10 Migrate src/routes/transfers.ts to Result<T, E>
- [ ] S11 Migrate src/routes/balances.ts to Result<T, E>
- [ ] S12 Delete the callback error helper in src/errors.ts   (blocked by S2–S11)

Wiring it into the loop is two edits: re-state the plan every pass, and refuse to terminate while anything is unchecked —

TSPY
src/run-agent-planned.tsCopy
const runAgent = async (client: Anthropic, task: string) => {
  // Twelve steps, enumerated from the directory before the first edit.
  let plan = buildPlan(await listFiles('src/routes')); // 10 handlers

  const messages: Message[] = [
    { role: 'system', content: SYSTEM_PROMPT },
    { role: 'user', content: task },
  ];

  let response: Response | null = null;

  let steps = 0;
  const MAX_STEPS = 40;
  while (steps < MAX_STEPS) {
    steps++;

    // Re-stated every pass, so what is left is never further back than the
    // last message. The original ask decays into the transcript; this doesn't.
    messages.push({
      role: 'user',
      content: `${renderPlan(plan)}\n\nNext unblocked: ${ready(plan)[0]?.id ?? 'none'}`,
    });

    const message = await client.messages.create({
      max_tokens: 4096,
      model: 'claude-haiku-4-5',
      messages,
    });

    messages.push({ role: 'assistant', content: message.content });
    response = extractResponse(message);

    if (response?.finalAnswer === true) {
      const left = remaining(plan);
      if (left.length === 0) {
        console.log(response.content);
        break;
      }
      // Termination is a property of the plan, not of how finished the
      // transcript feels. Unchecked items are handed back by name.
      messages.push({
        role: 'tool',
        content: `Not done — ${left.length} of ${plan.length} steps unchecked: ${left.map((s) => s.id).join(', ')}`,
      });
      continue;
    }

    if (response?.finalAnswer === false && response.content.startsWith('edit_file:')) {
      const path = response.content.replace('edit_file:', '');
      messages.push({ role: 'tool', content: editFile(path) });
    }

    if (response?.finalAnswer === false && response.content.startsWith('check:')) {
      plan = check(plan, response.content.replace('check:', ''));
      messages.push({ role: 'tool', content: `${remaining(plan).length} steps remaining` });
    }
  }
};
migrate_handlers — tracked against a plan

The detour still happened. The difference is that it cost one pass instead of the other seven files. Same model, same handler, both versions of src/routes/subscriptions.ts it actually left behind:

✗ never opened, never noticed
TSCopy
// src/routes/subscriptions.ts — after the agent reported "migration complete"
export function cancelSubscription(id: string, cb: (err: Error | null, out?: Subscription) => void) {
  db.query(CANCEL_SQL, [id], (err, rows) => {
    if (err) return cb(err); // still the old pattern — file 8 of 10, never opened
    cb(null, toSubscription(rows[0]));
  });
}

Planning is the decision to turn a goal into a list before the first step, so that finishing becomes a property of the list instead of an impression left by the transcript. Nothing about the model changed between the two runs above. What changed is that in the second one, the scope of the task was still readable at step nine.

Structure

Three parts, one plan. Hover or tap one.

ONE GOALDecomposeSequenceTrackorderschecks offre-read every pass
Track

The part that gives the plan teeth: the loop re-reads it every pass and will not terminate while an item is unchecked. Without tracking, a plan is a paragraph the model wrote once and then drifted away from — the checklist has to outrank the model’s sense that the work feels finished.

hover or click a part

Applicability

Plan whenever the goal has more parts than the loop can be trusted to still be holding at the end.

A plan is not free. It costs a pass to write, it has to be re-stated every iteration, and it has to be rewritten whenever the work turns out differently than it looked from the outside — on a task where every discovery invalidates the last plan, that rewriting costs more than the plan ever saves. The question is whether the scope is knowable up front and large enough to lose track of.

SignalVerdict
The goal names many items — files, endpoints, migrations — that all have to be covered✓ use it
Skipping one step breaks nothing loudly — the build stays green either way✓ use it
The task runs long enough that the original ask is far behind the working end of the transcript✓ use it
The task is a single step with one obvious definition of done− skip it
The work is genuinely exploratory — the right steps are not knowable until partway in− skip it

Planning and verification fail differently: verification catches a step that claims to have worked and didn't, planning catches a step nobody ever got to. And a plan is in-session by construction — what survives past the last message belongs to memory. Magent keeps its plan in a checklist the human can read and edit mid-run — github.com/palamim/magent.

← All patterns