Course Content
AI Agent Frameworks
4 sections · 15 lessons
AutoGPT and Goal-Stack Execution
In April 2023 a lot of people gave AutoGPT the same instruction: "Increase my net worth." The transcripts that came back are instructive. A representative one goes like this:
GOAL: Increase net worth -> Research profitable business ideas -> Research online business models -> Research dropshipping -> Research dropshipping suppliers -> Research supplier verification methods -> Research how to verify a supplier's credentials -> Research credential verification services ...Forty minutes and roughly 4 dollars of tokens later it was eight levels deep, had produced nothing, and showed no sign of stopping. The failure is not that the model was stupid. Every single decomposition step was reasonable: "research dropshipping suppliers" genuinely is a sensible sub-goal of "research dropshipping". The failure is that nothing in the loop ever decided a goal was small enough to just do.
That is the whole subject. A goal-stack agent is an easy thing to build and a hard thing to make terminate. Understanding precisely where the termination conditions go is the difference between a research tool and a token furnace.
Why a single model call is not enough
A model answering a question does one pass: read the prompt, produce an answer. That works when the task fits in one pass. It does not work when the task is "produce a competitive analysis of the UK meal-kit market with pricing, positioning and a recommendation", because that requires finding sources, reading them, extracting numbers, comparing, and writing — with each step's output feeding the next, and with the shape of later steps depending on what earlier steps found.
The goal-stack pattern makes that explicit:
Push the goal. Pop the top goal. If it is small enough to do directly, do it. Otherwise split it into sub-goals and push them. Repeat until the stack is empty.
A stack — last in, first out — gives you depth-first behaviour. You take one goal all the way down to actionable work before touching its siblings. That is usually what you want, because sub-goals of the same parent share context: having just researched dropshipping, the next sub-goal about dropshipping benefits from everything you learned.
It also means that if the depth is unbounded, you go down forever and never come back up. The transcript above is depth-first search with no depth limit, which is exactly the classic algorithmic failure it sounds like.
The decomposition problem
The hard judgement in the whole system is one binary question, asked of every goal: is this actionable, or does it need splitting? Get it wrong in one direction and you get infinite regress. Get it wrong in the other and the agent attempts "increase my net worth" in a single tool call and returns something useless.
Three signals, used together, are much more reliable than asking the model "is this actionable?" and trusting the answer.
| Signal | Rule | Catches |
|---|---|---|
| Depth | Beyond depth 3, force execution regardless | Infinite regress |
| Tool match | If exactly one available tool obviously applies, execute | Splitting things you can already do |
| Verb | "Research X" is vague; "Find X's 2024 revenue" is concrete | Goals that restate the parent |
The depth limit does the heavy lifting, and it is worth understanding the arithmetic. With branching factor b and maximum depth d, the number of goals in a fully expanded tree is
For b=3:
| Depth limit | Total goals | Model calls (≈2 per goal) | Cost at 3,000 tokens/call, example rate 3 dollars per million |
|---|---|---|---|
| 2 | 13 | 26 | 0.23 dollars |
| 3 | 40 | 80 | 0.72 dollars |
| 4 | 121 | 242 | 2.18 dollars |
| 5 | 364 | 728 | 6.55 dollars |
| 6 | 1,093 | 2,186 | 19.67 dollars |
Each extra level of depth multiplies your cost by three. That is why "just let it run a bit deeper" is never a small decision, and why a depth limit is not a nice-to-have but the single most important parameter in the system.
The core loop
Strip everything away and the engine is this:
1def run(goal: str, max_depth: int = 3):2 stack = [(goal, 0)]3 results = []4 while stack:5 current, depth = stack.pop()6 if depth >= max_depth or is_actionable(current):7 results.append(execute(current))8 else:9 for sub in reversed(decompose(current)):10 stack.append((sub, depth + 1))11 return resultsEleven lines, and two subtleties worth naming. reversed() is there because a stack pops in reverse insertion order — without it, sub-goals execute back-to-front, which matters when sub-goal 2 depends on sub-goal 1. And depth >= max_depth is checked before is_actionable, so the limit is not something the model can talk its way past.
A version that survives production
1from dataclasses import dataclass, field2from enum import Enum3import json, time, uuid45class Status(str, Enum):6 PENDING = "pending"; RUNNING = "running"7 DONE = "done"; FAILED = "failed"; ABANDONED = "abandoned"89@dataclass10class Goal:11 text: str12 depth: int = 013 parent: str | None = None14 id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])15 status: Status = Status.PENDING16 result: str = ""17 attempts: int = 01819class GoalStackAgent:20 def __init__(self, llm, tools, max_depth=3, max_goals=25,21 token_budget=400_000, wall_clock=600):22 self.llm, self.tools = llm, tools23 self.max_depth, self.max_goals = max_depth, max_goals24 self.token_budget, self.wall_clock = token_budget, wall_clock25 self.stack: list[Goal] = []26 self.completed: list[Goal] = []27 self.tokens_used = 028 self.goals_created = 02930 # ---- budgets -------------------------------------------------31 def _exhausted(self) -> str | None:32 if self.tokens_used >= self.token_budget: return "token budget"33 if self.goals_created >= self.max_goals: return "goal count"34 if time.time() - self.t0 >= self.wall_clock: return "wall clock"35 return None3637 # ---- the two judgements --------------------------------------38 def _actionable(self, g: Goal) -> bool:39 if g.depth >= self.max_depth:40 return True # forced, not negotiable41 names = ", ".join(t.name for t in self.tools)42 verdict = self._ask(43 f"Tools available: {names}\n"44 f"Goal: {g.text}\n\n"45 "Can this goal be completed with ONE OR TWO tool calls and a short "46 "write-up? Answer YES or NO, then one clause of justification."47 )48 return verdict.strip().upper().startswith("YES")4950 def _decompose(self, g: Goal) -> list[str]:51 raw = self._ask(52 f"Parent goal: {g.text}\n\n"53 "Break this into 2-4 sub-goals. Rules:\n"54 "- each sub-goal must be strictly narrower than the parent\n"55 "- each must start with a concrete verb: Find, Compare, Compute, "56 " Extract, Draft, List\n"57 "- do NOT start any sub-goal with Research, Explore or Investigate\n"58 "- together they must fully cover the parent\n"59 "Return a JSON array of strings and nothing else."60 )61 try:62 subs = json.loads(raw[raw.index("["): raw.rindex("]") + 1])63 except Exception:64 return []65 # reject sub-goals that merely restate the parent66 return [s for s in subs if s.strip().lower() != g.text.strip().lower()][:4]6768 # ---- the loop ------------------------------------------------69 def run(self, goal_text: str) -> dict:70 self.t0 = time.time()71 root = Goal(text=goal_text)72 self.stack.append(root); self.goals_created = 17374 while self.stack:75 reason = self._exhausted()76 if reason:77 for g in self.stack:78 g.status = Status.ABANDONED79 return self._report(stopped=reason)8081 g = self.stack.pop()82 g.status = Status.RUNNING8384 if self._actionable(g):85 g.result = self._execute(g)86 g.status = (Status.DONE if self._verify(g) else Status.FAILED)87 if g.status is Status.FAILED and g.attempts == 0:88 g.attempts = 189 g.status = Status.PENDING90 self.stack.append(g) # exactly one retry91 else:92 self.completed.append(g)93 else:94 subs = self._decompose(g)95 if not subs:96 g.result = self._execute(g) # cannot split -> must do97 g.status = Status.DONE98 self.completed.append(g)99 continue100 for s in reversed(subs):101 self.stack.append(102 Goal(text=s, depth=g.depth + 1, parent=g.id))103 self.goals_created += len(subs)104105 return self._report(stopped=None)106107 def _verify(self, g: Goal) -> bool:108 v = self._ask(109 f"Goal: {g.text}\nOutput: {g.result[:1500]}\n\n"110 "Does the output actually satisfy the goal? Reply YES or NO."111 )112 return v.strip().upper().startswith("YES")Every guard in that class exists because of a specific way real runs fail. Read it as a list of scars.
| Guard | Failure it prevents |
|---|---|
depth >= max_depth checked first | Infinite decomposition |
max_goals | Wide-but-shallow explosion that the depth limit misses |
token_budget, wall_clock | A run that is progressing but too expensive to finish |
| "do NOT start with Research" | Sub-goals that restate the parent in different words |
| Reject sub-goal identical to parent | A one-element decomposition that loops forever |
| Empty decomposition falls through to execute | Goals silently dropped when the model returns bad JSON |
_verify before marking DONE | "Task complete" on empty output |
attempts == 0 on retry | Infinite retry of a genuinely impossible goal |
ABANDONED status | A partial report that lies about what it covered |
Sub-goal dependencies
A pure stack assumes sub-goals are independent. They are usually not. Consider decomposing "produce a competitive analysis of UK meal-kit services":
1. List the top 5 UK meal-kit services by market share2. Extract each service's per-portion price3. Compare the pricing tiers and identify the outlier4. Draft a 400-word recommendationGoal 2 cannot start until goal 1 has produced a list. Goal 3 needs goal 2's numbers. Push these in the wrong order and the agent tries to extract prices for a set of companies it has not identified, invents five plausible brand names, and prices those.
Two mechanisms fix this, and you want both.
Ordering
reversed() when pushing preserves the model's intended order. Cheap, and it handles the common case where dependencies are simply sequential.
Passing results forward
Ordering alone is not enough — goal 2 must actually see goal 1's output. Give every goal a context bundle assembled from its completed siblings and ancestors:
1def _context_for(self, g: Goal) -> str:2 siblings = [c for c in self.completed3 if c.parent == g.parent and c.status is Status.DONE]4 ancestors = []5 pid = g.parent6 by_id = {c.id: c for c in self.completed}7 while pid and pid in by_id:8 ancestors.append(by_id[pid]); pid = by_id[pid].parent910 parts = []11 for c in ancestors[:2] + siblings[-3:]:12 parts.append(f"[{c.text}]\n{c.result[:800]}")13 return "\n\n".join(parts) if parts else "(no prior results)"The slicing matters. Passing all prior results means goal 20 carries nineteen predecessors and your input tokens grow quadratically: with 800 tokens per result and 25 goals, the naive version sends 800×225×24=240,000 tokens across the run just in context. Two ancestors plus three recent siblings caps it at about 4,000 tokens per goal, or 100,000 across the run — under half, with almost no loss of relevance because distant siblings rarely matter.
Why long-term memory is not optional here
Goal-stack runs are long. Twenty-five goals at two model calls each is fifty calls, and the useful output is scattered across all of them. Three things go wrong without a store outside the conversation:
- Rediscovery. Goal 4 searches for the UK meal-kit market size. Goal 17 searches for exactly the same thing because it has no idea goal 4 exists. In a 25-goal run, duplicate work of 20–30 per cent is typical.
- Contradiction. Goal 6 finds a market size of 1.6 billion pounds; goal 19 finds 1.9 billion from a different source. Nothing notices, and the final report contains both.
- Loss on crash. Fifty model calls is several minutes. A process restart at goal 22 throws away everything.
The minimum viable fix is a keyed store of findings plus a similarity check before executing:
1def _execute(self, g: Goal) -> str:2 prior = self.memory.search(g.text, k=3)3 if prior and prior[0].score > 0.92:4 return f"(reusing prior finding) {prior[0].text}"5 out = self._act(g, context=self._context_for(g), notes=prior)6 self.memory.save(key=g.id, text=out, tags=[g.text])7 return outA similarity threshold of 0.92 is deliberately high. Set it at 0.75 and the agent starts reusing a finding about "UK meal-kit prices" to answer "US meal-kit prices", which is a much worse failure than doing the search twice.
A run you can follow
Goal: "Produce a market analysis of UK meal-kit services with a pricing recommendation." Limits: depth 3, 25 goals, 400,000 tokens.
d0 Produce a market analysis of UK meal-kit services -> not actionabled1 List the top 5 UK services by market share -> ACTIONABLE (search) result: HelloFresh, Gousto, Mindful Chef, Simply Cook, Riverfordd1 Extract each service's per-portion price -> not actionabled2 Find HelloFresh per-portion price -> ACTIONABLE 3.99d2 Find Gousto per-portion price -> ACTIONABLE 3.49d2 Find Mindful Chef per-portion price -> ACTIONABLE 6.75d2 Find Simply Cook per-portion price -> ACTIONABLE 2.50d2 Find Riverford per-portion price -> ACTIONABLE 5.20d1 Compare pricing tiers and identify the outlier -> ACTIONABLE (calc) mean 4.386, Mindful Chef is 1.54x the meand1 Draft a 400-word recommendation -> ACTIONABLE (write)goals created: 10 model calls: 21 tokens: 118,400 elapsed: 94 sCheck the arithmetic in the comparison step, because a verification step that does not check numbers is decoration. The mean of 3.99, 3.49, 6.75, 2.50 and 5.20 is 521.93=4.386, and 6.75/4.386=1.539. Both correct.
Notice what kept the run at ten goals rather than a hundred: "Extract each service's per-portion price" decomposed into exactly five children because the parent's result told it there were five services. Decomposition informed by an earlier result is bounded; decomposition from imagination is not.
The four ways these agents fail
No depth limit
The opening transcript. Every step reasonable, the whole thing useless. A depth limit is not a fallback for when the model misjudges — it is the primary control, and _actionable is the refinement.
No total-work budget
Depth 3 with branching 3 is 40 goals. Depth 3 with branching 8 — which a model will happily produce if your prompt says "break this down" without a number — is 784−1=585 goals. The depth limit held perfectly and you still lost. Cap goal count, tokens and wall-clock time independently, because each catches a different shape of explosion.
Treating "cannot decompose" as success
If _decompose returns an empty list because the model emitted malformed JSON, the tempting handling is to mark the goal done and move on. The goal then vanishes from the report with no trace, and the final output is missing a section nobody notices. Fall through to execution, or mark it FAILED and surface it. Silence is the worst option.
No verification
Models are enthusiastic about declaring completion. An execution step that returns "I was unable to find pricing information for Riverford" is a failure, but a loop that only checks "did _execute return without raising" records it as a success and includes it in the report. The _verify call costs one extra model call per goal — roughly a 50 per cent overhead on a two-call-per-goal design — and it is worth every token, because a report built from unverified sub-results is confidently wrong rather than usefully incomplete.
Every termination guarantee in a goal-stack agent lives in your Python. Nothing you write in a prompt can promise that the recursion stops.
Two things people believe that are not true
"More autonomy means better results." The opposite is closer to true. Measured across the sort of tasks people actually give these agents, tighter constraints — fewer tools, a depth limit of 2, a mandatory verification step — produce more usable output than loose ones. Autonomy is not a quality dial; it is a variance dial, and most of the extra variance is downside.
"The agent will notice when it is going wrong." It will not, because at every individual step nothing is going wrong. "Research supplier verification methods" is a defensible sub-goal of "research dropshipping suppliers". The pathology is only visible from outside, in the aggregate: eight levels deep, zero artefacts produced. That is precisely why the guards live in your Python and not in your prompt.
When to reach for this pattern, and what to set
Goal-stack execution earns its keep when the task genuinely decomposes into a tree and you cannot draw that tree in advance — competitive analyses, literature sweeps, "find everything about X across these sources". It is the wrong tool when you already know the steps, because then you are paying a model to rediscover a structure you could have written down.
If you build one, set five numbers before you write the prompt, and treat them as the specification rather than as tuning knobs:
| Parameter | Sensible start | What raising it costs |
|---|---|---|
| Max depth | 3 | Roughly 3x tokens per level |
| Sub-goals per split | 2–4, stated explicitly | Same exponent, larger base |
| Max total goals | 25 | Linear, and it is your last defence |
| Token budget | 400,000 | Directly, in money |
| Retries per goal | 1 | Unbounded time on impossible goals |
Then log every goal with its id, parent, depth, status, token cost and result length, and look at the tree after each run. The two numbers that tell you almost everything are the ratio of DONE to FAILED leaves, and the maximum depth actually reached. If most runs hit the depth limit, your _actionable judgement is too conservative and you are paying for decomposition you do not need. If nothing ever reaches depth 2, the tree is not the right structure and a fixed pipeline would be cheaper and more reliable.