Agentic AI

Planning And Self Correction

When Planning Fails

Planning fixes the wandering problem and introduces its own failures. All five are recognisable and all five are detectable in code.

JrCodex·8 min read

Jr Codex Agentic AI Notes

Level: Advanced Prerequisites: Chapter 3: Reflection & Self-Correction Time to complete: ~20 minutes


Table of Contents

  1. The Five Pathologies
  2. Loops
  3. Thrash
  4. Over-Decomposition
  5. Premature Completion
  6. The Bounding Layer
  7. Summary & Next Steps

1. The Five Pathologies

Planning fixes the wandering problem and introduces its own failures. All five are recognisable and all five are detectable in code.

The Catalogue
─────────────────────────────────────────
  LOOP            repeats the same action, expecting
                  a different result

  THRASH          re-plans repeatedly without
                  executing anything

  OVER-           decomposes forever; planning
  DECOMPOSITION   consumes the whole budget

  PREMATURE       declares success without doing the
  COMPLETION      work

  GOAL DRIFT      ends up solving a different,
                  usually easier, problem
─────────────────────────────────────────
The Common Property
─────────────────────────────────────────
  Every one is invisible to the agent itself.

  From inside the loop, each step looks reasonable —
  the agent is doing exactly what its context
  suggests. Nothing in the reasoning core detects
  "I have been here before."

  So detection must live in the ORCHESTRATOR, in
  ordinary deterministic code. This is why Module 1
  put the executor outside the model.
─────────────────────────────────────────

2. Loops

What It Looks Like
─────────────────────────────────────────
  Step 4  search("competitor pricing")   → 0 results
  Step 5  search("competitor pricing")   → 0 results
  Step 6  search("competitor pricing ")  → 0 results
  Step 7  search("Competitor Pricing")   → 0 results

  The agent believes it is trying variations. It is
  making cosmetic changes to a query that is not the
  problem.
─────────────────────────────────────────
import hashlib, json
 
class LoopDetector:
    def __init__(self, exact_limit=2, similar_limit=3):
        self.calls, self.exact_limit, self.similar_limit = [], exact_limit, similar_limit
 
    def _key(self, name, args): 
        return hashlib.sha1(f"{name}:{json.dumps(args, sort_keys=True)}".encode()).hexdigest()
 
    def check(self, name, args, normalise=lambda s: s.lower().strip()):
        key = self._key(name, args)
        exact = sum(1 for c in self.calls if c["key"] == key)
        if exact >= self.exact_limit:
            return f"You already called {name} with these exact arguments {exact} times."
 
        # SIMILAR calls — the cosmetic-variation case above
        norm = self._key(name, {k: normalise(str(v)) for k, v in args.items()})
        similar = sum(1 for c in self.calls if c["norm"] == norm)
        if similar >= self.similar_limit:
            return (f"You have called {name} {similar} times with near-identical "
                    f"arguments and it has not worked. The ARGUMENTS are not the "
                    f"problem. Try a different tool or report that the data is "
                    f"unavailable.")
 
        self.calls.append({"key": key, "norm": norm, "name": name})
        return None
Two Details That Matter
─────────────────────────────────────────
  DETECT NEAR-DUPLICATES, not just exact ones.
  Exact-match detection misses the common case,
  where the agent changes capitalisation or adds a
  space and believes it tried something new.

  THE INTERVENTION IS A MESSAGE, not an exception.
  Telling the agent "the arguments are not the
  problem — try a different tool or report
  unavailability" gives it a way out. Raising an
  error just ends the run.
─────────────────────────────────────────

3. Thrash

What It Looks Like
─────────────────────────────────────────
  plan v1 ──► step 1 fails ──► re-plan
  plan v2 ──► step 1 fails ──► re-plan
  plan v3 ──► step 1 fails ──► re-plan

  Each plan is different and plausible. Zero work
  has been completed. The budget is gone.
─────────────────────────────────────────
class ReplanGuard:
    def __init__(self, max_replans=2, min_progress=1):
        self.replans, self.max, self.min_progress = 0, max_replans, min_progress
        self.completed_at_last_replan = 0
 
    def allow(self, plan) -> tuple[bool, str]:
        done = sum(1 for s in plan.steps if s.status == "done")
 
        if self.replans >= self.max:
            return False, "replan limit reached"
 
        # The key check: did we actually ACHIEVE anything since the last re-plan?
        if self.replans > 0 and done - self.completed_at_last_replan < self.min_progress:
            return False, ("re-planning without progress — the problem is not the "
                           "plan shape")
 
        self.replans += 1
        self.completed_at_last_replan = done
        return True, ""
The Insight
─────────────────────────────────────────
  Repeated re-planning with no completed steps
  between attempts means the plan is NOT what is
  broken.

  Something more fundamental is wrong — a missing
  tool, a permission, a premise that does not hold.
  Generating a fourth plan cannot fix any of those.

  The correct response is to STOP and escalate with
  what was learned, not to try again.
─────────────────────────────────────────

4. Over-Decomposition

What It Looks Like
─────────────────────────────────────────
  Goal
   └─ Step 1
       └─ Sub-step 1.1
           └─ Sub-sub-step 1.1.1
               └─ ...

  Every level looks like reasonable decomposition.
  The budget is spent planning, and nothing has been
  executed.
─────────────────────────────────────────
def make_subplan(client, step, depth, max_depth=2, min_leaf_steps=2):
    if depth >= max_depth:
        return None                                  # HARD depth cap — execute it as-is
 
    sub = decompose(client, step)
    if sub is None or len(sub.steps) < min_leaf_steps:
        return None                                  # decomposing into ONE step is a no-op
    if sum(estimate_steps(s) for s in sub.steps) >= estimate_steps(step):
        return None                                  # the plan is not SMALLER than the task
    return sub
Three Independent Stops
─────────────────────────────────────────
  DEPTH CAP        two levels is almost always
                   enough (Chapter 1's two-level
                   structure)

  TRIVIAL SPLIT    decomposing into a single
                   sub-step accomplished nothing;
                   just run it

  NO REDUCTION     if the sub-steps together are not
                   simpler than the parent, the
                   decomposition is not helping
─────────────────────────────────────────

5. Premature Completion

The most dangerous pathology, because it produces a confident, wrong, successful-looking result.

What It Looks Like
─────────────────────────────────────────
  Task: "Compare all three competitors' pricing."

  Agent: researches A thoroughly, fails to find B's
         pricing, skips C entirely, and returns a
         polished comparison of "the market" that
         reads as complete.

  Nothing in the output signals that two thirds of
  the work never happened.
─────────────────────────────────────────
def completion_check(plan, output) -> Check:
    """Verify against the PLAN, never against the agent's claim."""
    incomplete = [s for s in plan.steps if s.status not in ("done", "skipped")]
    if incomplete:
        return Check(False, f"{len(incomplete)} steps not completed: "
                            f"{[s.id for s in incomplete]}")
 
    skipped = [s for s in plan.steps if s.status == "skipped"]
    if skipped and not all(s.result for s in skipped):     # skips need a stated REASON
        return Check(False, "steps were skipped without justification")
 
    missing = [s.id for s in plan.steps
               if s.status == "done" and not references_result(output, s)]
    if missing:                                            # done, but absent from the output
        return Check(False, f"completed work missing from the output: {missing}")
 
    return Check(True, "")
Why This Works
─────────────────────────────────────────
  It is Chapter 3's verification hierarchy applied
  to completion: the plan is an EXTERNAL, structured
  record of what was supposed to happen.

  Checking output against the plan is a level-1
  deterministic check. Asking the agent "did you
  finish?" is level 4.

  Having made the plan a data structure in
  Chapter 1, you get this check almost for free —
  which is a large part of why it was worth doing.
─────────────────────────────────────────

6. The Bounding Layer

All five detectors belong in one place, around the loop.

class Bounds:
    def __init__(self, max_steps=30, max_cost_cents=200, max_seconds=300,
                 max_replans=2, max_depth=2):
        self.limits = dict(steps=max_steps, cost=max_cost_cents,
                           seconds=max_seconds, replans=max_replans, depth=max_depth)
        self.used = dict(steps=0, cost=0.0, seconds=0.0, replans=0, depth=0)
        self.loops = LoopDetector()
 
    def before_step(self, name, args):
        for k in ("steps", "cost", "seconds"):
            if self.used[k] >= self.limits[k]:
                raise BudgetExhausted(f"{k} limit reached ({self.used[k]})")
        return self.loops.check(name, args)          # a NUDGE, not an exception
 
    def record(self, cost_cents, elapsed):
        self.used["steps"] += 1
        self.used["cost"] += cost_cents
        self.used["seconds"] += elapsed
Two Kinds of Bound
─────────────────────────────────────────
  HARD STOPS       steps, cost, wall-clock.
                   Raise, end the run, report what
                   was achieved. Non-negotiable —
                   these are what stand between a
                   confused agent and an unbounded
                   bill.

  SOFT NUDGES      loop and thrash detection.
                   Inject a message telling the
                   agent what is happening and what
                   to do instead. It can usually
                   recover.

  Use both. A system with only hard stops kills runs
  that could have recovered; one with only nudges
  never terminates.
─────────────────────────────────────────
Always Report Partial Results
─────────────────────────────────────────
  When a bound trips, return what WAS accomplished —
  the completed steps and their results — not a bare
  failure.

  Two thirds of a competitor analysis plus an honest
  "I could not find B's pricing" is genuinely
  useful. "Budget exhausted" is not.
─────────────────────────────────────────

7. Summary & Next Steps

Key Takeaways

  • The five planning pathologies — loops, thrash, over-decomposition, premature completion, goal drift — are all invisible from inside the agent, so detection must live in deterministic orchestrator code.
  • Loop detection must catch near-duplicates, and should intervene with a message offering a way out rather than an exception.
  • Repeated re-planning with no completed steps in between means the plan is not what is broken; escalate instead of generating another one.
  • Check completion against the plan rather than the agent's claim — a level-1 deterministic check you get almost free from having made the plan a data structure.

Concept Check

  1. Why is exact-match loop detection insufficient, and what does the agent believe it is doing?
  2. What does three consecutive re-plans with zero completed steps actually indicate, and why is a fourth plan the wrong response?
  3. Why is premature completion described as the most dangerous of the five?

Module 4 Complete — What's Next

You have now built, by hand, most of what an agent framework provides: a loop, tools, memory, plans, reflection and bounds. Module 5 examines the frameworks themselves — what they give you, what they cost, and when writing it yourself was the right call after all.

Next Module

Module 5: Agent Frameworks


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index