Verification: Course Correction
The meta-agent pattern that catches hallucinated success. The key insight.
Evidence source: Amp Code v0.0.1769212917 (course correction system, Gemini observer)
The Problem: Hallucinated Success
Without verification, agents confidently claim "Done!" while having:
- Missed explicit user requirements
- Introduced logical errors
- Broken existing functionality
- Left incomplete implementations
This is hallucinated success - the agent believes it succeeded when it didn't.
The Solution: Course Correction
Amp's Insight: Use a separate model (Gemini) to watch the main agent (Claude) and intervene when things go off track.
COURSE CORRECTION ARCHITECTURE
+------------------------------------------------------------------------------+
| Main Thread |
| |
| User: "Update Button with loading state and add tests" |
| | |
| v |
| +-----------+ |
| | Claude | --> [read_file] --> [edit_file] --> [edit_file] |
| | Opus 4.5 | --> [read_file] --> "Done! Added loading state." |
| +-----------+ |
| | |
| +---> end_turn event (5+ tools AND file edits) |
| | |
| v |
| +---------------------------------------------+ |
| | Course Correction Assessment | |
| | | |
| | 1. shouldRunCourseCorrection() -> true | |
| | 2. Format thread for assessment | |
| | 3. Call Gemini 3 Pro Preview | |
| | 4. Force course_correct tool call | |
| | | |
| +---------------------------------------------+ |
| | |
| v |
| +-----------+ |
| | Gemini 3 | --> course_correct({ |
| | Pro Prev | needsCorrection: true, |
| +-----------+ message: "I asked you to also add tests" |
| | }) |
| | |
| v |
| [Message injected as user message with source.type = "course-correction"] |
| | |
| v |
| +-----------+ |
| | Claude | --> Receives correction, continues work |
| | Opus 4.5 | --> [create_file] tests... --> "Done with tests!" |
| +-----------+ |
| |
+------------------------------------------------------------------------------+
When Course Correction Triggers
Course correction fires only on end_turn events (single-phase), not during tool execution.
Prerequisites (ALL must be met)
| Condition | Threshold | Why |
|---|---|---|
| Tool calls | >= 5 | Agent has done meaningful work |
| File edits | At least one | Only monitor when changes made |
| Not free mode | true | Avoid costs for free tier |
| Not already corrected | true | Prevent infinite loops |
Implementation
def should_run_course_correction(thread: Thread) -> bool:
"""Check if course correction should run."""
# Find last user message with text content
last_user_index = -1
for i in range(len(thread.messages) - 1, -1, -1):
msg = thread.messages[i]
if msg.role == "user":
if any(c.type == "text" for c in msg.content):
last_user_index = i
break
# Loop prevention: don't run if last user message was a correction
last_user_msg = thread.messages[last_user_index]
if last_user_msg.source and last_user_msg.source.type == "course-correction":
return False
# Count tool uses and track file edits since last user message
tool_count = 0
has_file_edits = False
for i in range(last_user_index + 1, len(thread.messages)):
msg = thread.messages[i]
if msg.role != "assistant":
continue
for content in msg.content:
if content.type == "tool_use":
tool_count += 1
if content.name in ("edit_file", "create_file"):
has_file_edits = True
# Trigger: 5+ tools AND at least one file edit
return tool_count >= 5 and has_file_edits
The Observer Prompt
The observer is told it's a separate system watching the conversation, not the agent itself.
Full Verbatim Prompt
# Your Role
You are an EXTERNAL OBSERVER watching a conversation between a human user and an AI coding agent. You are NOT the agent. You are NOT the assistant. You are a separate system that monitors the agent's work.
Your job: Intervene only when the agent clearly goes off track. When you intervene, write as if you are the user speaking to the agent.
# What You're Watching
The conversation contains:
- "User" messages: The human's requests, plus tool results from commands the agent ran
- "Assistant" messages: The AI agent's responses and tool calls (this is the agent you are monitoring, NOT you)
- "Course Correction" messages: Your previous interventions
- "<discovered_guidance_files>" blocks: Instructions from AGENTS.md files that the agent was told to follow
The first user message contains the original task. Tool results show actual command output - use these to verify if the agent's claims match reality.
**Guidance files**: When you see <discovered_guidance_files> blocks, these contain project-specific instructions the agent received. If the agent clearly violates these instructions (e.g., guidance says "never use @ts-ignore" but agent adds @ts-ignore), that's a valid reason to intervene.
**IMPORTANT**: "Course Correction" messages are YOUR previous interventions. Do NOT intervene again for the same issue - the agent already received your feedback. Only intervene for NEW problems.
# Critical Rules
**NEVER intervene to do or suggest anything the user didn't explicitly ask for.**
**NEVER run git operations (commit, push, pull, etc.) - even if it seems like a logical next step.**
**NEVER suggest git operations - these are NEVER appropriate to mention.**
# When to Stay Silent (needsCorrection: false)
Stay silent in ALL of these cases:
- Agent is still working (even if slowly or taking detours)
- Agent is exploring, debugging, or investigating
- Agent made a minor suboptimal choice but is still heading toward the goal
- Agent is mid-task and hasn't claimed completion yet
- You're unsure whether intervention is needed
- Agent completed exactly what was asked (even if you think more could be done)
**Default to silence. When in doubt, do not intervene.**
# When to Intervene (needsCorrection: true)
Intervene ONLY for clear, obvious problems with what the user EXPLICITLY requested:
1. Agent claimed "done" but skipped something the user EXPLICITLY asked for in their message
2. Agent is doing something completely different from what was requested
3. Agent says it succeeded but the output clearly shows failure
4. Agent made a mistake or oversight that is clearly visible from the code or tool output (e.g., syntax error, missed import, logic error, broke existing functionality)
5. Agent forgot to handle something obvious from the code context (e.g., didn't update related tests, missed an obvious edge case visible in the code)
# How to Write the Message
Write like the user would - short, casual, direct. Reference your original request.
Good examples:
- "I asked you to also run the tests"
- "wait, I wanted the TypeScript version, not JavaScript"
- "that's not what I meant - I want X, not Y"
Bad examples (too formal/robotic):
- "Please ensure you complete the testing phase before marking complete"
- "I notice you have deviated from the original specification"
- "The task requirements indicate that..."
# Examples
## Stay Silent - Agent completed the task
- User: "Refactor the auth module to use JWT"
- Agent: reads auth implementation, updates 12 files, fixes type errors
- Agent: "Done! Refactored auth to use JWT."
-> needsCorrection: false (do NOT suggest tests, commits, or next steps)
## Stay Silent - Agent is still working
- User: "Add pagination to the users API"
- Agent: reads endpoint, searches for patterns, encounters errors, keeps trying
-> needsCorrection: false (let the agent work, even if it's slow or taking detours)
## Intervene - Missed explicit requirement
- User: "Update the Button component to support a 'loading' state and add tests"
- Agent: adds loading prop and spinner, updates types
- Agent: "Done! The Button now supports a loading state."
-> needsCorrection: true
-> message: "I asked you to also add tests for the loading state"
## Intervene - Explained instead of doing
- User: "Fix the memory leak in the WebSocket handler"
- Agent: "I've analyzed the code. The issue is that event listeners aren't being cleaned up. Here's what you should change..."
-> needsCorrection: true
-> message: "I asked you to fix it, not explain it - please actually make the changes"
## Intervene - Logical error in implementation
- User: "Add a function to calculate the average of an array"
- Agent: implements calculateAverage that returns sum / array.length
- Agent: "Done! Added the calculateAverage function."
-> needsCorrection: true
-> message: "this will throw a division by zero error when the array is empty - you need to handle that case"
# Remember
Every unnecessary intervention degrades the agent's performance and wastes tokens.
Only intervene when a real user would actually speak up.
NEVER intervene to suggest or run something the user didn't ask for.
The course_correct Tool
The observer must respond via tool call - this ensures structured output.
Tool Schema
COURSE_CORRECT_TOOL = {
"name": "course_correct",
"description": "Report whether the agent needs course correction and provide guidance if so.",
"input_schema": {
"type": "object",
"properties": {
"needsCorrection": {
"type": "boolean",
"description": "True if intervention is required, false otherwise."
},
"message": {
"type": "string",
"nullable": True,
"description": "Guidance message if needsCorrection is true, otherwise null."
}
},
"required": ["needsCorrection"],
"additionalProperties": False
}
}
What Happens When Correction Triggers
- The correction message is extracted from the tool call
- A new user message is created with
source.type = "course-correction" - The message is injected into the thread
- The main agent receives it and continues work
# Apply correction to thread
if result.needs_correction and result.message:
correction_message = {
"role": "user",
"content": result.message,
"source": {
"type": "course-correction"
}
}
thread.messages.append(correction_message)
# Notify UI
emit_thread_delta({
"type": "course-correction",
"message": result.message,
"usage": result.usage
})
Why Gemini 3 Pro Preview?
The observer uses a different model and provider than the main agent.
| Consideration | Value | Why |
|---|---|---|
| Provider | Google (VertexAI) | Independent from Anthropic Claude |
| Model | Gemini 3 Pro Preview | Large context, good reasoning |
| Context Window | 2M tokens | Can observe entire long conversations |
| Cost | $1.25/$5 per MTok | Cheaper than Claude for monitoring |
| Temperature | 0.1 | Very deterministic decisions |
Assessment Parameters
ASSESSMENT_OPTIONS = {
"temperature": 0.1, # Very low - deterministic
"max_output_tokens": 1024, # Short responses only
"tool_config": {
"function_calling_config": {
"mode": "ANY" # Forces tool use response
}
},
"thinking_config": {
"thinking_level": "LOW" # Minimal reasoning overhead
}
}
Loop Prevention
Without prevention, course correction could loop infinitely. Multiple mechanisms prevent this.
1. skipCourseCorrection Flag
# Set when starting inference after specific events
run_inference(skip_course_correction=True)
# Events that skip course correction:
# - user:message (fresh user input)
# - user:message-queue:dequeue (queued message)
# - course-correction (CRITICAL: prevents checking right after correction)
2. Source Type Check
# In should_run_course_correction():
last_user_msg = thread.messages[last_user_index]
if last_user_msg.source and last_user_msg.source.type == "course-correction":
return False # Don't run if last "user" message was a correction
3. Prompt Instruction
The observer prompt explicitly states:
IMPORTANT: "Course Correction" messages are YOUR previous interventions. Do NOT intervene again for the same issue - the agent already received your feedback. Only intervene for NEW problems.
Loop Prevention Flow
[Agent completes turn]
|
v
[Course correction detected] --> needsCorrection: true
|
v
[Correction injected as user message]
|
+-- source.type = "course-correction"
|
v
[runInference({skipCourseCorrection: true})]
|
v
[Agent responds to correction]
|
+-- checkPendingCourseCorrection() SKIPPED
|
v
[Agent completes another turn]
|
v
[shouldRunCourseCorrection()] --> checks last user message
|
+-- if source.type === "course-correction" --> return false
|
v
[No second check runs immediately]
Complete Implementation
class CourseCorrector:
"""Meta-agent that monitors main agent for mistakes."""
def __init__(self, gemini_client, config):
self.client = gemini_client
self.config = config
self.pending = None # {abort_controller, promise}
async def check_on_end_turn(self, thread: Thread):
"""Called when main agent completes a turn."""
# Check prerequisites
if not self._should_run(thread):
return
# Create abort controller
abort = AbortController()
promise = self._run_assessment(thread, abort.signal)
self.pending = {"abort": abort, "promise": promise}
try:
result = await promise
# Clean up
if self.pending and self.pending["abort"] == abort:
self.pending = None
# Handle aborted or no correction needed
if abort.signal.aborted:
return
if not result.needs_correction or not result.message:
# Track usage even if no correction
if result.usage:
emit_thread_delta({
"type": "course-correction:usage-only",
"usage": result.usage
})
return
# Apply the correction
emit_thread_delta({
"type": "course-correction",
"message": result.message,
"usage": result.usage
})
except AbortError:
pass # Expected on cancellation
except Exception as e:
logger.error("Course correction failed", error=e)
def _should_run(self, thread: Thread) -> bool:
"""Check all prerequisites."""
# Find last user message
last_user_index = -1
for i in range(len(thread.messages) - 1, -1, -1):
if thread.messages[i].role == "user":
if any(c.type == "text" for c in thread.messages[i].content):
last_user_index = i
break
# Loop prevention
last_user = thread.messages[last_user_index]
if last_user.source and last_user.source.type == "course-correction":
return False
# Count tools and edits
tool_count = 0
has_edits = False
for i in range(last_user_index + 1, len(thread.messages)):
msg = thread.messages[i]
if msg.role != "assistant":
continue
for c in msg.content:
if c.type == "tool_use":
tool_count += 1
if c.name in ("edit_file", "create_file"):
has_edits = True
return tool_count >= 5 and has_edits
async def _run_assessment(self, thread: Thread, signal) -> dict:
"""Call Gemini to assess the thread."""
# Format thread for assessment
formatted = self._format_thread(thread)
# Call Gemini with forced tool use
response = await self.client.generate(
model="gemini-3-pro-preview",
messages=[{"role": "user", "content": formatted}],
tools=[COURSE_CORRECT_TOOL],
system=OBSERVER_PROMPT,
temperature=0.1,
max_tokens=1024,
tool_config={"function_calling_config": {"mode": "ANY"}},
signal=signal
)
# Extract tool call
tool_call = response.tool_calls[0]
if tool_call.name != "course_correct":
raise ValueError("Expected course_correct tool call")
return {
"needs_correction": tool_call.args["needsCorrection"],
"message": tool_call.args.get("message"),
"usage": response.usage
}
def _format_thread(self, thread: Thread) -> str:
"""Format thread for observer."""
parts = []
for msg in thread.messages:
role = msg.role
if msg.source and msg.source.type == "course-correction":
role = "Course Correction"
content_text = ""
for c in msg.content:
if c.type == "text":
content_text += c.text
elif c.type == "tool_use":
content_text += f"\n[Tool: {c.name}({json.dumps(c.input)[:200]}...)]"
elif c.type == "tool_result":
result_preview = str(c.content)[:500]
content_text += f"\n[Result: {result_preview}...]"
parts.append(f"**{role}**: {content_text}")
return "\n\n".join(parts)
def abort(self):
"""Cancel pending assessment."""
if self.pending:
self.pending["abort"].abort()
self.pending = None
UI Rendering
Course correction messages display distinctively:
+------------------------------------------------------------+
| Amp inserted this message to steer the agent back on track. | <- Dimmed header
| |
| "I asked you to also add tests for the loading state" | <- Message
| |
+------------------------------------------------------------+
^ Orange border
Key Design Principles
| Principle | Implementation |
|---|---|
| Conservative intervention | "Default to silence. When in doubt, do not intervene." |
| User perspective | "Write like the user would - short, casual, direct" |
| Scope limitation | "NEVER suggest git operations" |
| Loop prevention | Multiple mechanisms prevent infinite correction loops |
| Cost awareness | "Every unnecessary intervention degrades performance" |
Traditional Verification Still Matters
Course correction catches high-level mistakes. You still need:
1. Build/Type Checks
def verify_build(command: str = "npm run build") -> bool:
result = execute_bash(command, timeout=300)
return result.exit_code == 0
2. Test Verification
def verify_tests(command: str = "npm test") -> bool:
result = execute_bash(command, timeout=300)
return result.exit_code == 0
3. Read-Back Verification
def verify_edit_applied(path: str, expected: str) -> bool:
"""Verify file contains expected content."""
actual = read_file(path)
return expected in actual
Implementation Checklist
Building course correction? Ensure:
Trigger Conditions
- Count tools since last user message (>= 5)
- Check for file edits (edit_file, create_file)
- Skip free mode users
- Check loop prevention
Observer Setup
- Use separate model (Gemini recommended)
- Force tool call mode (ANY)
- Low temperature (0.1)
- Full observer prompt
Loop Prevention
- skipCourseCorrection flag on events
- Source type check in shouldRun
- Prompt instruction to not repeat
Message Injection
- source.type = "course-correction"
- Emit thread delta
- Track usage separately
What's Next
Course correction catches mistakes. But how does the agent remember project-specific rules?
→ 09-memory.md - AGENTS.md and project memory