Part 2: Teaching an AI Agent to Reason — Skills, Phases & Dead-End Triggers
How structured Chain-of-Thought stops an LLM from jumping to wrong conclusions
The Problem With Unconstrained LLMs
In Part 1, I built an agent that extracts trace data and reasons about test failures. It worked — sometimes. Other times it produced reports like:
"The test timed out waiting for the element to appear. This is likely a timing issue. Consider increasing the timeout or adding a waitForTimeout()."
This is the LLM equivalent of a doctor saying "you're sick because you have symptoms." It describes the failure without explaining the cause. And the suggested fix — waitForTimeout() — makes the test slower and still flaky.
The problem isn't intelligence. The LLM can find the root cause. The problem is that without constraints, it takes shortcuts. It pattern-matches "timeout → timing issue" instead of investigating further. It needs a structured methodology that forces it through the full diagnostic process.
The 7-Phase Reasoning Chain
I encoded our team's debugging methodology as a skill file — a structured document that defines exactly how the agent must reason, in what order, and what evidence it must gather at each step.
If you're familiar with prompt engineering, this is a form of Chain-of-Thought (CoT) — but constrained. Vanilla CoT ("let's think step by step") lets the model reason freely, which means it can skip evidence, jump to conclusions, or fixate on the wrong signal. The 7-phase chain forces a specific investigation order, requires evidence at each gate, and uses dead-end triggers to block known-bad reasoning paths. Think of it as CoT with guardrails — the model reasons freely within each phase, but can't skip ahead or conclude prematurely.
| Vanilla CoT | 7-Phase Reasoning Chain | |
|---|---|---|
| Structure | "Let's think step by step" (freeform) | Mandatory sequential phases with defined outputs |
| Direction | Model decides what to think about | Phases dictate what to investigate and in what order |
| Guardrails | None — model can jump to conclusions | Dead-End Triggers block known-bad inferences |
| Evidence | Optional | Mandatory — must cite [file]:[line] |
| Termination | Model decides when it's done | Can't produce a report until all phases complete |
Phase 1: Data Acquisition
Run both analysis scripts. Catalogue what they found:
Was there an error event? What's the message?
What does the page snapshot show at failure time?
Did any network requests fail? Any GraphQL errors in 200 responses?
This is mechanical — just gathering evidence before reasoning begins.
Phase 2: What Did the Test Expect?
Open the test file at the line that failed (from the stack trace). Quote the exact assertion:
await expect(page.getByRole('table', { name: 'Statements' })).toBeVisible();
This establishes the "expected" half of the equation.
Phase 3: What Was the Page Showing?
Read the accessibility snapshot captured at failure time. What elements are actually present? Is there a loading spinner? An error message? An empty state? Nothing at all?
This establishes the "actual" half.
Phase 4: Did Network Calls Succeed?
This is the critical gate. Check not just HTTP status codes, but response bodies. A 200 with { errors: [...] } is a failure. A 200 with { data: null } might be a failure depending on the component's logic.
Phase 5: What App Code Path Was Triggered?
Open the component that renders the expected element. Trace the logic: under what conditions does it render vs not render? Compare those conditions to what the trace actually showed (the API response, the component props, the feature flags).
Phase 6: Single Root Cause Statement
One sentence in the form: "The test expected X, but got Y, because Z."
The test expected getByRole('table') to be visible, but the component
renders null when data is undefined, because the getInformation query
returned a GraphQL error (permission denied) that the mock didn't intercept.
Phase 7: Minimal Fix
The smallest change that resolves the conflict. Not a refactor. Not a "consider also doing..." suggestion. One specific change with a file path and line number.
Why Sequential Phases Matter
The order is intentional. Each phase builds on the previous:
You can't reason about "why the component didn't render" (Phase 5) until you know "what was the API response" (Phase 4)
You can't assess "did the network fail" (Phase 4) until you know "what element was expected" (Phase 2) — because that tells you which API the component depends on
You can't skip to a fix (Phase 7) without the root cause statement (Phase 6)
Forcing the LLM through this sequence prevents premature conclusions. It can't say "timing issue" at Phase 6 if Phase 4 already established that the API returned an error.
Dead-End Triggers: The Anti-Misdiagnosis System
Even with phases, the agent can still reach wrong conclusions if it misinterprets evidence. Dead-End Triggers are pre-programmed rules that fire when the agent is about to make a known-bad inference:
DE-1: "Timing Issue" Without Network Failure
If the agent is about to conclude "timing issue" or "race condition" but Phase 4 showed no network failures and no slow responses → STOP. The element isn't slow; it's not going to appear. Re-examine why the component's render condition isn't met.
DE-2: Status Code Tunnel Vision
If the agent concludes "API succeeded" based solely on HTTP 200 → STOP. Check the response body for GraphQL
errorsarray. Re-run Phase 4.
DE-3: Missing Element ≠ Slow Element
If the page snapshot shows the element simply isn't in the DOM (not loading, not hidden — absent) AND no network failure occurred → STOP. The component never attempted to render this element. Check conditional rendering logic, feature flags, or permission checks.
DE-4: Mock Mismatch
If the test uses
page.route()to mock an API, but the snapshot shows unexpected data (or no data) → STOP. Check if the mock's URL pattern / operationName actually matches what the app sends. A mock that doesn't match is invisible — the request falls through to the real endpoint.
DE-5: "Add a Wait" Fix
If the proposed fix involves
waitForTimeout(),setTimeout, or increasing a timeout → REJECT. This is never the correct fix for a Playwright test. Find the actual cause and fix the condition, not the symptom.
How Dead-End Triggers Work in Practice
Here's a real example. Without DE-1, the agent would produce:
"The element didn't appear within 30 seconds. This suggests a timing issue with the component rendering. The test should wait for the API response to complete before asserting."
With DE-1, the agent hits the trigger at Phase 6:
Phase 4 found: all network requests returned 200, no errors in bodies
Phase 3 found: element is not in the DOM at all
DE-1 fires: "timing issue" conclusion blocked
The agent is forced to continue to Phase 5, where it opens the component code:
if (!featureFlags.showStatements) return null;
Root cause: the feature flag showStatements is false in the test environment. The element will never appear regardless of how long you wait.
Separation of Concerns: Prompt vs Skill vs Steering
The reasoning system is split across multiple files, each with a distinct role:
| File | Contains | Changes When |
|---|---|---|
| System prompt | Identity, security rules | Rarely (security updates) |
| Skill file | The 7-phase procedure + dead-end triggers | When methodology improves |
| Steering file | Hard "never do this" constraints | When new anti-patterns emerge |
| Environment context | CI runtime facts (what's mocked, URLs) | When infra changes |
This separation means:
Adding a new dead-end trigger doesn't touch security config
Updating environment URLs doesn't touch the reasoning procedure
Each file can be reviewed and evolved by the person who owns that concern
Evidence-Based Reasoning: The Hallucination Guard
Every conclusion the agent makes must cite [file]:[line]. This is enforced in the skill file:
"You must quote the specific file and line number for every factual claim. If you cannot find the code that supports a conclusion, the conclusion is speculative — say so."
This prevents the agent from:
"The feature flag is probably off" → Must find the actual flag check in code
"The component likely renders null" → Must show the return statement
"The mock probably doesn't match" → Must show the mock's route pattern vs the actual request
If the agent can't find evidence, it says "I cannot determine the root cause from the available evidence" rather than guessing. An honest "I don't know" is more useful than a confident wrong answer.
Results
With structured reasoning:
Misdiagnosis rate dropped significantly — "timing issue" conclusions went from ~40% to near-zero
Reports became actionable — every report now points to a specific file:line to change
New team members learn from the agent — reading RCA reports teaches the debugging methodology itself
What's Next
The reasoning system works, but the agent processes untrusted data. Trace files could contain anything — including text designed to trick the LLM into executing commands. In Part 3, I'll cover how I secured the agent against prompt injection from its own input.