Originally published on tamiz.pro.
You prompt your agent to orchestrate a multi-step workflow. It generates a beautifully reasoned plan. Then it fails on step three. Or eight. Or quietly produces wrong output that no one notices until it's too late.
This isn't a prompt engineering problem. It's an architecture problem β one rooted in the fundamental mismatch between probabilistic language models and deterministic software systems. Understanding why agents fail to execute their own plans is the prerequisite to building ones that don't.
This article dissects the technical failure modes, traces them through the agent stack, and explores architectural patterns that close the gap between LLM-grade reasoning and production-grade execution reliability.
Before diagnosing the failure modes, we need to be precise about what's actually happening when an agent "executes a plan."
A modern agentic system has two conceptual layers that are conflated in practice:
The LLM lives entirely in the reasoning layer. When we say an agent "executes its plan," what's really happening is that the LLM generates text that a software harness interprets as instructions. The reliability of the whole system is bounded by whichever of these two layers is weaker.
In practice, the execution layer is where things collapse. And they collapse in predictable, categorizable ways.
An LLM-generated plan is a sequence of natural language instructions. When you say "the agent will execute this plan," you're implicitly assuming the plan is executable by something other than another LLM.
But here's the problem: the plan is opaque.
plan = [
{"step": 1, "action": "query user database", "params": "active users last 30 days"},
{"step": 2, "action": "aggregate metrics", "params": "count by region"},
{"step": 3, "action": "format report", "params": "PDF with charts"},
{"step": 4, "action": "send to stakeholder", "params": "weekly digest list"}
]
Step 3 says "format report" with a param of "PDF with charts." That's not a program. That's a description of intent. The execution engine needs to:
Every one of these decisions is a potential point of failure. The LLM that generated the plan doesn't actually understand any of this β it's predicting the next reasonable word in a sequence. The execution engine that does need to understand it is a separate system, often hand-written, and almost always incomplete.
The fix: Plans must be expressed in a formal intermediate representation (IR), not natural language. This means the LLM generates structured output that maps to concrete, typed actions:
// Formal plan representation
interface ExecutablePlan {
steps: ExecutionStep[];
constraints: PlanConstraints;
validation: ValidationRules[];
}
interface ExecutionStep {
id: string;
action: ActionType; // Enum, not string
inputs: TypedSchema; // JSON Schema validated
dependencies: string[]; // DAG, not implicit ordering
retryPolicy: RetryConfig;
timeout: Duration;
}
enum ActionType {
DATABASE_QUERY,
API_CALL,
FILE_WRITE,
EMAIL_SEND,
// ... exhaustively enumerated
}
The LLM fills in parameters, but the shape of execution is constrained by the type system. This eliminates the semantic gap between "what the plan says" and "what the code does."
Even when the plan is well-formed, a silent killer is state drift β the world changes between when the plan is generated and when a step is executed.
Consider this sequence:
T=0s : Agent plans β "fetch user X's data from API endpoint /v2/users"
T=5s : API endpoint /v2/users is deprecated; /v3/users is now live
T=6s : Agent executes step 1 β 404 error, plan halts
Or worse β a silently wrong result:
T=0s : Agent plans β "calculate total revenue from transactions table"
T=5s : A deployment changes the schema; a new `currency` column appears
T=6s : Agent executes β queries sum(revenue) but gets mixed currencies
T=7s : Agent reports $1.2M revenue to stakeholders β wrong by 3x
The LLM has no awareness of these state transitions. It generates a plan based on its training data and whatever context you provided. It doesn't know the schema changed yesterday. It doesn't know the API version you meant.
The fix: Agents need a state awareness layer that validates plan assumptions against current reality before execution:
class PlanValidator:
def __init__(self, state_probe: StateProbe):
self.probe = state_probe
def validate_plan(self, plan: ExecutablePlan) -> ValidationReport:
for step in plan.steps:
schema = self.probe.get_schema(step.inputs)
if not schema.matches(step.params.schema):
raise SchemaMismatchError(step.id, schema, step.params.schema)
if step.action == ActionType.API_CALL:
health = self.probe.check_endpoint(step.params.url)
if health.status != "healthy":
self.flag_risk(step.id, f"Endpoint degraded: {health.status}")
return ValidationReport(completed=True, warnings=self.warnings)
This turns plan execution from a blind leap into a verified execution. The agent still generates plans naturally, but they're validated against the live system state before any side effect occurs.
LLMs are excellent at generating plans. They're terrible at handling deviations from plans β because error recovery requires situational awareness that the planning process doesn't carry forward.
When an agent encounters an unexpected error, it has several options:
The LLM, operating in a stateless request-response loop, has no memory of the original plan's intent beyond what's in the context window. It doesn't know which steps are on the critical path. It doesn't know whether a failure is transient or permanent. It makes a best-guess decision based on whatever context happened to be in the prompt.
The fix: Implement structured error handling as a first-class component of the agent architecture:
class ErrorRecoveryEngine:
"""
Separates error handling logic from the LLM's planning logic.
Uses deterministic rules and bounded LLM calls for recovery.
"""
RECOVERY_STRATEGIES = {
"timeout": ["retry_with_backoff", "skip_with_log", "abort"],
"validation_error": ["retry_with_corrected_params", "ask_for_clarification", "abort"],
"auth_failure": ["retry_with_refreshed_token", "abort"],
"dependency_unavailable": ["retry_after_delay", "use_fallback", "abort"],
}
def handle(self, step: ExecutionStep, error: ExecutionError, context: PlanContext) -> RecoveryDecision:
strategy_type = self._classify_error(error)
strategies = self.RECOVERY_STRATEGIES.get(strategy_type, ["abort"])
viable = [s for s in strategies if self._is_viable(step, s, context)]
if len(viable) > 1:
decision = self._llm_select_recovery(step, error, viable, context)
else:
decision = RecoveryDecision(action=viable[0])
return decision
def _is_viable(self, step: ExecutionStep, strategy: str, ctx: PlanContext) -> bool:
"""Deterministic checks β no LLM involved."""
if strategy == "retry_with_backoff" and step.retry_count >= step.retry_policy.max_attempts:
return False
if strategy == "use_fallback" and not step.has_fallback:
return False
if strategy == "skip_with_log" and step.is_critical_path:
return False
return True
The key insight: don't ask the LLM to solve everything. Use deterministic logic for structural decisions (can we retry? have we exhausted retries? is this critical?) and reserve LLM calls for genuinely ambiguous situations where contextual judgment is needed. Each LLM call should be bounded β few tokens, focused question, short response.
Your agent's context window is its short-term memory. And it's a bad one.
At any given point, the context window contains:
As the plan executes, this window grows. At some point, it hits the token limit. What gets truncated? Usually, it's the oldest messages β which often includes the original plan and the reasoning that produced it.
So the agent is now executing step five, but it can no longer see why it chose the approach in step one. It's making decisions in a vacuum, optimizing for local correctness rather than global coherence.
This is the amnesic agent problem: the agent literally cannot remember its own rationale as it progresses through a long plan.
The fix: Explicit external memory management β the plan and its rationale must be persisted outside the context window:
class AgentMemory:
"""
External memory store decoupled from the LLM context window.
Persists plan state, rationale, and execution history.
"""
def __init__(self, store: KVStore):
self.store = store
def save_plan_context(self, run_id: str, context: PlanContext):
"""Persist full context for retrieval at any step."""
self.store.set(f"plan:{run_id}", context, ttl=3600)
def retrieve_relevant_context(self, run_id: str, current_step: int, query: str) -> ContextSnapshot:
"""
Retrieve only the context relevant to the current decision point.
Uses embedding similarity to avoid everything.
"""
plan = self.store.get(f"plan:{run_id}")
window = range(max(0, current_step - 2), min(len(plan.steps), current_step + 1))
relevant_steps = [plan.steps[i] for i in window]
return ContextSnapshot(
original_intent=plan.intent,
relevant_steps=relevant_steps,
recent_outcomes=plan.executed_steps[-3:],
current_query=query
)
This gives you two benefits: first, the agent always has access to its original intent; second, you can control what gets loaded into the context window at each step, keeping it lean and focused.
Most agent frameworks treat tool use as a simple function-calling interface:
@tool(description="Search the knowledge base")
def search_knowledge_base(query: str) -> str:
results = kb.search(query)
return format_results(results)
The LLM sees the tool name and description and decides when to call it. But the description is always a natural language approximation of what the tool actually does. There's a semantic gap between "search the knowledge base" and the actual Elasticsearch query being constructed, the pagination logic, the relevance scoring, the error handling.
When the LLM calls a tool with slightly wrong parameters, the result is wrong. When it calls the tool at the wrong time, the plan derails. When the tool returns an error the LLM doesn't recognize, the agent loops or hallucinates a fix.
The fix: Formal tool contracts β every tool must declare its preconditions, postconditions, and error semantics:
interface ToolContract {
name: string;
description: string;
// What must be true before calling
preconditions: precondition[];
// What the caller can expect after successful execution
postconditions: postcondition[];
// Exhaustive error taxonomy with recovery guidance
errors: ErrorCode[];
// Max latency the caller should expect
latencySLA: Duration;
// Whether results are cached (idempotent?)
caching: CachingPolicy;
}
interface ErrorCode {
code: string;
message: string;
recovery: RecoveryStrategy; // "retry", "abort", "ask_user", etc.
hint: string; // What the LLM should try differently
}
This transforms tools from black boxes into verifiable components. The agent's execution engine can check preconditions before calling, interpret errors against a known taxonomy, and apply deterministic recovery strategies rather than hoping the LLM figures it out.
Perhaps the most subtle failure mode: agents don't learn from execution.
A plan is generated, executed, and if it fails, the LLM is shown the error and asked to continue. But the LLM doesn't retain anything from this failure. The next time a similar plan is generated, it makes the same mistake. The system has no memory of what went wrong, no model of its own failure modes, no way to improve.
This is especially damaging because LLMs have a well-documented sycophancy problem β they tend to double down on their initial reasoning rather than self-correct when presented with contradictory evidence. When an agent fails at step 3 and you feed the error back, the LLM might acknowledge the error but then proceed to make the same type of error at step 4.
The fix: Build execution feedback into the planning pipeline as a first-class loop, not an afterthought:
class ExecutionFeedbackLoop:
"""
Captures execution outcomes and feeds them back to improve planning.
Operates at two levels: online (per-session) and offline (cross-session).
"""
def __init__(self, session_store: SessionStore, improvement_engine: ImprovementEngine):
self.session = session_store
self.improve = improvement_engine
def process_outcome(self, plan: ExecutablePlan, outcome: ExecutionOutcome):
adjusted_plan = self._adjust_plan(plan, outcome)
self.improve.record_f failure_pattern(outcome)
return adjusted_plan
def _adjust_plan(self, plan: ExecutablePlan, outcome: ExecutionOutcome) -> ExecutablePlan:
"""
Apply deterministic adjustments based on execution results.
Not an LLM call β just rule-based plan modification.
"""
adjusted = copy.deepcopy(plan)
if outcome.step_failed:
failed_step = outcome.failed_step
failed_step.error_handling = self._infer_error_handler(failed_step, outcome)
for dependent in adjusted._find_dependents(failed_step.id):
adjusted._add_dependency(dependent.id, failed_step.id)
return adjusted
The key distinction: the online loop makes adjustments to the current plan using deterministic rules (add retry, add error handling, add dependencies). The offline loop accumulates failure patterns across sessions and uses them to improve the planner itself β perhaps by fine-tuning, perhaps by improving system prompts, perhaps by building a failure-mode database that the planner consults.
All of these failure modes share a common root: the LLM is being asked to do something it's architecturally unsuited for β bridging the gap between high-level intent and low-level execution.
The solution is an executive layer β a software component that sits between the LLM (the planner) and the execution environment (the tools, APIs, databases). Its responsibilities are:
interface ExecutiveLayer {
/** Receive a plan from the LLM, validate, and execute */
execute(plan: LLMGeneratedPlan): AsyncGenerator<ExecutionEvent>;
/** Check if the current system state supports the plan's assumptions */
validateAssumptions(plan: LLMGeneratedPlan): ValidationResult;
/** Recover from a failure without restarting the entire plan */
recover(step: FailedStep, error: ExecutionError): RecoveryPlan;
/** Log the full execution trace for debugging and improvement */
trace(): ExecutionTrace;
}
This layer is not an LLM. It's deterministic software. It doesn't need to be smart β it needs to be correct. The LLM provides the creativity and adaptation. The executive layer provides the reliability and accountability.
If you're building AI agents today, you're almost certainly under-instrumented. The gap between "the agent planned this" and "the agent executed this correctly" is where your bugs live. Here's where to focus:
Immediate fixes (this week):
Medium-term (this sprint):
Longer-term (this quarter):
Q: Should I use a smaller model for the execution layer?
A: No β the executive layer shouldn't use an LLM at all. It should be deterministic code. If you find yourself needing an LLM inside your execution loop, you've likely pushed responsibility onto the model that should belong to your software architecture. The LLM belongs in planning and in genuinely ambiguous error resolution, not in the hot path of execution.
Q: How do I handle plans that legitimately need LLM judgment during execution?
A: Limit these to bounded, isolated calls with clear input/output contracts. When the execution engine encounters an ambiguous error state, it should collect all relevant context, make a single focused LLM call (not a free-form conversation), and treat the response as a recommendation β not a command. The executive layer should always be able to override or reject the LLM's suggestion.
Q: Isn't all of this over-engineering? Why can't the LLM just do it?
A: Because the LLM can't. Not reliably. Not repeatedly. Not at scale. The same properties that make LLMs flexible β probabilistic token generation, context window limitations, lack of persistent state, no native understanding of side effects β are the same properties that make them unreliable as execution engines. You could keep pushing prompt engineering further, but you'll hit a wall. The wall is the difference between saying something and doing something. Closing that gap requires engineering, not prompting.