Skip to main content

Challenge 01 β€” The Hallucination Audit

🏦 Enterprise Scenario​

Company: Meridian Capital Partners β€” a mid-size asset management firm
Situation: Your team built an AI financial analyst agent in a weekend hackathon. It impressed everyone in the demo. Six weeks later, the CFO used it to prepare a board presentation. The slide deck contained three incorrect figures β€” a stock return was off by 12%, a volume stat belonged to a different ticker, and one number was entirely fabricated because the database was momentarily locked during the query.
No one caught it before the board meeting.

You are brought in as the AI Solution Architect to diagnose and fix the system before it causes a regulatory or reputational incident.


The Core Problem: The Helpfulness Paradox​

The "Simple Agentic" pattern has a fatal flaw: LLMs are trained to be helpful, not correct. When a tool fails β€” database locked, network timeout, permission error β€” a well-meaning agent does not stop. It hallucinates plausible data from its training weights and presents it with the same polished formatting as real data.

# What the tool returned (INTERNAL ERROR):
{"ticker": "META", "error": "IO Error: Could not set lock on file..."}

# What the agent told the user 8 turns later:
"Meta Platforms (META) delivered the strongest upside in 2024.
| Stock | Approx. 2024 % Gain |
|-------|---------------------|
| META | β‰ˆ 30% | ← FABRICATED from training data
| AMZN | β‰ˆ 10% | ← FABRICATED
| NFLX | β‰ˆ -5% | ← FABRICATED"

The model generated rounded "approximate" figures that looked professional β€” entirely disconnected from actual database records.


πŸ—οΈ Architecture decision table β€” accuracy vs auditability tradeoffs
ApproachAccuracy GuaranteeAuditabilityComplexity
❌ Simple Agentic (no guardrails)None β€” silent hallucinationNoneLow
⚠️ Prompt-only restriction ("only use real data")Probabilistic β€” bypassableNoneLow
βœ… PostToolUse hook + hard stop on errorDeterministicFullMedium
βœ… Structured error response + validation layerDeterministicFull + traceableMedium

Decision: Use PostToolUse hooks combined with structured error contracts on every tool. Never allow the LLM to "work around" a tool failure.


🧰 Before You Start β€” Environment Setup​

This challenge is hands-on. The tasks below assume you already have a model to call and a database to query. If you have never built an agent before, do the five steps in order β€” by the end you'll have a running agent that you can watch hallucinate, which is the whole point of Task 1. Budget ~30 minutes for setup.

Prerequisites​

RequirementWhy you need itHow to get / check it
Python 3.10+Async agent loop + dataclassespython --version β†’ if missing, install from python.org/downloads
An Azure subscriptionTo create the Azure OpenAI model in Step 1azure.microsoft.com/free β€” free tier is enough
Azure CLILets your code sign in without pasting API keysaz version β†’ if missing, install the Azure CLI
Azure OpenAI model via Azure AI FoundryThe model that powers the agent β€” Microsoft-first primary pathYou'll deploy gpt-4o in Step 1 below
System of record β€” Azure SQL Database / Microsoft Fabric (OneLake) in production; DuckDB as a zero-cost local stand-in hereDeterministic data layer (same query β†’ same result)Installed via pip in Step 0
VS Code + a terminalYou must read the raw message threadcode.visualstudio.com

Step 0 β€” Create an isolated workspace (5 min)​

Where you run this: Step 0 runs locally on your own machine β€” open a terminal (VS Code's integrated terminal, PowerShell, or bash). You don't touch Azure until Step 1. A virtual environment (venv) keeps this challenge's packages separate from the rest of your machine, so nothing you install here can break another project.

mkdir hallucination-audit && cd hallucination-audit
python -m venv .venv

# Activate it (your prompt should then start with "(.venv)"):
# Windows (PowerShell): .venv\Scripts\Activate.ps1
# Windows (cmd): .venv\Scripts\activate.bat
# macOS/Linux: source .venv/bin/activate

# Microsoft-first stack. duckdb is only the local, offline stand-in for the system of record.
pip install azure-ai-projects azure-identity openai pydantic tiktoken duckdb python-dotenv

βœ… You're done with this step when your terminal prompt shows (.venv) and pip list includes azure-ai-projects.

Step 1 β€” Deploy a model in Azure AI Foundry β€” this is the "where do I go" (10 min)​

The tasks call a real model. Here is exactly where to click to create one and the two values you must copy. Follow the official quickstart if you get stuck: Create and deploy an Azure OpenAI resource.

  1. Go to ai.azure.com and sign in with your Azure account.
  2. Click + Create project (accept the default new resource/hub it offers). Wait ~1 min for it to provision.
  3. In the left pane under My assets, click Models + endpoints.
  4. Click + Deploy model β†’ Deploy base model, search gpt-4o, select it, click Confirm β†’ Deploy.
  5. Open the deployment you just created and copy two things:
    • the Deployment name (e.g. gpt-4o) β†’ you'll set it as MODEL_DEPLOYMENT_NAME
    • your project's endpoint β€” from the project Overview page, the Azure AI Foundry project endpoint (looks like https://<your-project>.services.ai.azure.com/api/projects/<name>) β†’ you'll set it as PROJECT_ENDPOINT

πŸ’‘ New to Foundry? The get-started overview walks through the portal with screenshots.

Step 2 β€” Wire up authentication (5 min)​

The task code uses DefaultAzureCredential, which means no API keys in your code β€” it signs in as you via the Azure CLI. This is the Microsoft-recommended, keyless pattern.

# 1. Sign in once (opens a browser). Your code reuses this session.
az login

# 2. Save the two values from Step 1 into a .env file (never commit this file).
# Windows PowerShell:
# "PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com/api/projects/<name>" | Out-File .env -Encoding utf8
# "MODEL_DEPLOYMENT_NAME=gpt-4o" | Out-File .env -Append -Encoding utf8
# macOS/Linux:
# echo 'PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com/api/projects/<name>' >> .env
# echo 'MODEL_DEPLOYMENT_NAME=gpt-4o' >> .env

Your task scripts load these two lines at the top:

import os
from dotenv import load_dotenv
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential

load_dotenv()
project = AIProjectClient(
endpoint=os.environ["PROJECT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
DEPLOYMENT = os.environ["MODEL_DEPLOYMENT_NAME"]

Step 3 β€” Seed a small, KNOWN dataset (5 min)​

You cannot detect a hallucination unless you know the ground truth. Create a tiny table where you know every value β€” then any "extra" number the agent produces is provably fabricated.

# seed.py β€” sample values you control (not real market data).
# The point: YOU know these 5 numbers, so anything else the agent shows is fabricated.
import duckdb
con = duckdb.connect("stock_prices.duckdb")
con.execute("CREATE TABLE stock_prices (ticker VARCHAR, date DATE, close DOUBLE)")
con.executemany(
"INSERT INTO stock_prices VALUES (?, ?, ?)",
[("META", "2024-12-31", 500.00), ("AMZN", "2024-12-31", 200.00),
("NFLX", "2024-12-31", 800.00), ("META", "2024-01-02", 350.00),
("AMZN", "2024-01-02", 150.00)],
)
print(con.execute("SELECT * FROM stock_prices").fetchall())

Run it: python seed.py. You should see the 5 rows printed back.

🟦 Microsoft-first note: DuckDB is used here only as a zero-setup, deterministic local stand-in so you can focus on the guardrail pattern. In production β€” at Microsoft or on a customer engagement β€” the system of record is Azure SQL Database, Microsoft Fabric / OneLake, Azure Cosmos DB, or Dataverse. The guardrail architecture in Tasks 2–4 is identical regardless of the backing store.

Step 4 β€” Smoke-test the connection (2 min)​

Before Task 1, confirm the model actually answers. If this prints a reply, your endpoint, deployment name, and az login are all correct β€” so any failure later is your agent logic, not setup.

# smoke_test.py β€” proves Steps 1–2 work end to end.
import os
from dotenv import load_dotenv
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential

load_dotenv()
project = AIProjectClient(endpoint=os.environ["PROJECT_ENDPOINT"], credential=DefaultAzureCredential())
client = project.inference.get_azure_openai_client(api_version="2024-10-21")
resp = client.chat.completions.create(
model=os.environ["MODEL_DEPLOYMENT_NAME"],
messages=[{"role": "user", "content": "Reply with exactly: setup works"}],
)
print(resp.choices[0].message.content)

βœ… You're ready for the tasks when python smoke_test.py prints setup works.

Common fixes: DefaultAzureCredential failed β†’ run az login again. DeploymentNotFound β†’ MODEL_DEPLOYMENT_NAME must match the name from Step 1.5 exactly. 401 / PermissionDenied β†’ in the portal, give your account the Azure AI User role on the project (Access control β†’ Add role assignment).

The path through this challenge​

  1. Task 1 β€” reproduce the failure (watch the agent lie).
  2. Task 2 β€” give tools a structured error contract (make failures legible).
  3. Task 3 β€” add a PostToolUse hook (make correct behavior enforced, not suggested).
  4. Task 4 β€” add a validator (cross-check even successful runs).
  5. Success Criteria β€” prove each guardrail works.
  6. Adapt to Your Business β€” replace "stocks" with your domain.

⏱️ Time budget: ~30 min setup, then ~2–3 hours for the tasks. Do Tasks 1–3 in one sitting β€” they build on each other.


Tasks​

Task 1 β€” Reproduce the Failure​

Build the "Simple Agentic" baseline. Simulate a database lock and observe the agent's behavior.

# simple_agent.py β€” the broken baseline
import json
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential

# Tool that can silently fail
def query_financial_data(ticker: str, start_date: str, end_date: str) -> dict:
try:
# Simulate DB lock
raise IOError("Could not set lock on file: stock_prices.duckdb")
# return db.query(ticker, start_date, end_date)
except Exception as e:
# BAD: returns a vague error β€” LLM will try to "help anyway"
return {"error": str(e)}

# Run query β€” watch agent hallucinate past the error
result = run_agent(
query="Which FANG stock had the highest percent gain in 2024?",
tools=[query_financial_data]
)
print(result)
# Expected: ERROR or refusal
# Actual: Polished table of fabricated data

Observe: Run this and inspect the full message thread. Count how many turns the agent spent "trying" before it hallucinated a final answer. Note the formatting β€” it looks identical to a real result.


Task 2 β€” Implement Structured Error Contracts​

Replace vague error returns with structured error objects that signal the agent to stop.

# tool_contracts.py β€” structured error responses
from dataclasses import dataclass
from typing import Optional

@dataclass
class ToolResult:
success: bool
data: Optional[dict] = None
error: Optional[str] = None
error_category: Optional[str] = None # "transient" | "permanent" | "scope"
is_retryable: bool = False
source_ref: Optional[str] = None # audit trail: DB table + query hash

def query_financial_data(ticker: str, start_date: str, end_date: str) -> ToolResult:
try:
rows = db.execute(
"SELECT * FROM stock_prices WHERE ticker=? AND date BETWEEN ? AND ?",
[ticker, start_date, end_date]
).fetchall()

return ToolResult(
success=True,
data={"ticker": ticker, "rows": rows},
source_ref=f"stock_prices:ticker={ticker}:{start_date}:{end_date}"
)
except IOError as e:
return ToolResult(
success=False,
error=str(e),
error_category="transient",
is_retryable=True # tell agent: retry once, then stop
)
except Exception as e:
return ToolResult(
success=False,
error=str(e),
error_category="permanent",
is_retryable=False # tell agent: STOP, do not hallucinate
)

Test: Trigger the same lock error. Verify the agent now returns a clear error message instead of fabricated data.


Task 3 β€” Add PostToolUse Hook (Hard Stop)​

The structured error contract tells the agent what happened. The PostToolUse hook enforces the correct behavior deterministically β€” no prompt needed.

# hooks.py β€” deterministic guardrails
import json

def post_tool_use_hook(tool_name: str, tool_result: dict) -> dict:
"""
Intercepts every tool result BEFORE the LLM sees it.
Hard-stops on permanent errors β€” removes the 'opportunity' to hallucinate.
"""
result = ToolResult(**json.loads(tool_result))

if not result.success and not result.is_retryable:
# Inject a STOP signal β€” agent cannot continue
return {
"action": "TERMINATE",
"message": (
f"Tool '{tool_name}' returned a permanent error: {result.error}. "
"Cannot generate an answer without verified data. "
"Please try again when the data source is available."
),
"source_error": result.error_category
}

if not result.success and result.is_retryable:
# Allow one retry, then stop
return {
"action": "RETRY_ONCE",
"message": f"Transient error on '{tool_name}'. Retrying..."
}

# Success: pass through with source reference intact
return {
"action": "CONTINUE",
"data": result.data,
"source_ref": result.source_ref # audit trail preserved
}

Why this beats prompt-based guardrails: A prompt saying "only use real data" is probabilistic β€” the model can reason around it ("I'll note these are estimates"). The PostToolUse hook fires in Python code, before the LLM's reasoning loop. It is deterministic by construction.


Task 4 β€” Build the Validation Layer​

Even when tools succeed, verify the LLM reported the data it received β€” not something else.

# validator.py β€” verify LLM output matches source data
import re

def validate_financial_output(
llm_response: str,
source_data: dict,
tolerance: float = 0.01
) -> dict:
"""
Extracts all numbers from LLM response and cross-references
against the source data returned by tools.
Returns a verification report.
"""
# Extract all dollar amounts and percentages from response
numbers_in_response = re.findall(r'\$?([\d,]+\.?\d*)\s*%?', llm_response)

verification_results = []
for num_str in numbers_in_response:
num = float(num_str.replace(',', ''))
# Check if this number appears in source data within tolerance
found = any(
abs(num - float(str(val).replace(',', ''))) <= tolerance
for val in flatten_values(source_data)
)
verification_results.append({
"value": num,
"verified": found,
"source_ref": find_source(num, source_data) if found else None
})

unverified = [r for r in verification_results if not r["verified"]]
return {
"all_verified": len(unverified) == 0,
"unverified_values": unverified,
"verification_rate": (len(verification_results) - len(unverified)) / max(len(verification_results), 1)
}

def flatten_values(data: dict) -> list:
"""Recursively extract all numeric values from tool result."""
values = []
for v in data.values():
if isinstance(v, (int, float)):
values.append(v)
elif isinstance(v, dict):
values.extend(flatten_values(v))
elif isinstance(v, list):
for item in v:
if isinstance(item, dict):
values.extend(flatten_values(item))
elif isinstance(item, (int, float)):
values.append(item)
return values

Success Criteria​

  • Baseline agent reproduces hallucination when DB is locked
  • Structured error contract returns is_retryable: false for permanent failures
  • PostToolUse hook hard-stops the agent on permanent errors β€” zero hallucinated responses
  • Validation layer flags any number in the response not traceable to source data
  • System returns a clear, honest error message to the user rather than fabricated data
  • All tool results include source_ref for audit trail

πŸ” Adapt This to Your Own Business​

The scenario uses a financial analyst agent, but the failure mode is universal: any agent that calls a tool which can fail will fabricate an answer rather than admit failure. Here is how to port this challenge to your own domain, step by step.

Step 1 β€” Find your "board report moment"​

Identify the one AI output in your organization where a silent wrong answer causes real damage. That is your equivalent of the CFO's board deck. Write it down as one sentence: "When our agent is wrong about ___, we lose ___."

IndustryThe tool that can failThe fabrication that hurts
HealthcareEHR / lab-results lookupAgent invents a dosage or lab value
LegalCase-law / contract databaseAgent cites a case or clause that doesn't exist
InsuranceClaims / policy systemAgent quotes coverage that isn't in the policy
ManufacturingERP / inventory APIAgent reports stock or lead-time that isn't real
Retail / e-commercePricing / catalog serviceAgent confirms a price or SKU that's wrong
Any SaaSInternal REST APIAgent returns a metric from training data, not your DB

Step 2 β€” Map the four building blocks to your stack​

Nothing in the solution is finance-specific. Swap the labels, keep the architecture:

In this challengeIn your project β€” replace with
query_financial_data() toolYour real data tool β€” an Azure SQL / Fabric query, an Azure Functions REST tool, or an Azure AI Search RAG retriever; any REST/GraphQL API
stock_prices.duckdbYour system of record β€” Azure SQL Database, Microsoft Fabric / OneLake, Azure Cosmos DB, Dataverse first; third-party (Snowflake, Postgres, SAP, Salesforce, ServiceNow) only if that's where the data already lives
ToolResult contractThe same contract β€” it's domain-agnostic; keep success / error_category / is_retryable / source_ref
validate_financial_output()Your validator β€” swap the number check for your critical fields (codes, IDs, amounts, dates, names)

Step 3 β€” The 5-question implementation checklist​

Run this against any agent you own, today. Each "no" is a task from this challenge:

  1. What does each tool return when it fails? If it's a vague string, you have the bug β†’ add a structured error contract (Task 2).
  2. Can the model "continue" after a failed tool call? If yes β†’ add a PostToolUse hard stop (Task 3).
  3. Is every fact in the output traceable to a source? If not β†’ add source_ref + a validator (Task 4).
  4. What's the honest failure message the user should see? Write it now, before you need it in production.
  5. Could you prove a value's origin to an auditor, regulator, or customer? If you can't in one click β†’ you have no audit trail yet.

Step 4 β€” A 1-week rollout plan for a real team​

DayActionOwner
Day 1Inventory every tool your agent can call; list each one's failure modesEng lead
Day 2Add the ToolResult contract to the 1–2 highest-risk toolsBackend dev
Day 3Add PostToolUse hard-stop + honest error messageBackend dev
Day 4Add a validator on the output's critical fieldsBackend dev
Day 5Run the "Break & Fix" tests below against your system; log the resultsQA / eng lead

Step 5 β€” Prove the ROI to leadership​

Measure two numbers before and after. This is the slide that justifies the work:

  • Silent-failure rate β€” % of failed tool calls that still produced a confident answer (target: β†’ 0%).
  • Traceability rate β€” % of output values that carry a valid source_ref (target: β†’ 100%).

πŸ’‘ Rule of thumb: if you cannot answer "where did this number come from?" in one click, the system is not production-ready β€” no matter how good the demo looked.

Doing this solo (no team, portfolio-first)​

No team, no budget? This is one of the most hireable things you can show β€” it proves you build trustworthy agents, not just demos. Run the same week as one person:

  • Mon–Tue β€” build one tool that can fail + the ToolResult contract on a free public dataset (or the DuckDB stand-in from setup).
  • Wed–Thu β€” add the PostToolUse hard-stop, the honest error message, and the source_ref validator.
  • Fri β€” record the before/after on the two ROI numbers above (silent-failure rate β†’ 0%, traceability β†’ 100%) in the README.

πŸ“¦ Ship this artifact: a small public GitHub repo with a 3-line "problem β†’ fix β†’ measured result" summary and a screenshot of the agent refusing to answer when the tool fails instead of fabricating. Resume bullet: "Cut agent silent-failure rate to 0% and made 100% of outputs source-traceable with a tool-result contract + validation gate."

πŸ†“ Free-tier path: the Azure OpenAI consumption tier plus the local DuckDB stand-in run this for cents β€” cost is never the blocker.


πŸ“‹ Regulatory mapping β€” EU AI Act Β· SOX Β· FINRA
RegulationRequirementHow This Challenge Addresses It
EU AI Act Art. 13Transparency β€” users must know AI limitationsHard stop + honest error message instead of hallucination
EU AI Act Art. 14Human oversight β€” humans can interveneValidation layer flags unverified values for review
SOX Section 302Executives certify financial report accuracySource refs + validation report provide audit evidence
FINRA Rule 4511Books and records β€” traceable datasource_ref on every output value

πŸ§ͺ Break & Fix β€” find the three bugs your colleague introduced

Your colleague made these "improvements." Find and explain each bug:

# broken_agent.py
def post_tool_use_hook(tool_name, tool_result):
result = json.loads(tool_result)

# "Fix" 1: Be more helpful by allowing retries
if result.get("error"):
return {"action": "RETRY", "attempts": 999} # ← what's wrong here?

# "Fix" 2: Pass through all data to context
return {"action": "CONTINUE", "data": result} # ← what's missing?

def validate_output(response, source):
numbers = re.findall(r'\d+\.?\d*', response)
# "Fix" 3: Use exact match only, no tolerance
return all(float(n) in [float(v) for v in flatten_values(source)]
for n in numbers) # ← why does this fail?

:::details Click to reveal answers

  1. Infinite retry loop: attempts: 999 means the agent will attempt 999 tool calls, burning massive tokens and eventually hallucinating anyway when it "gives up." Should be RETRY_ONCE then TERMINATE.
  2. Missing source_ref: Passing raw data without a source reference breaks the audit trail. Every value must be traceable.
  3. Exact match breaks on rounding: DB stores 174.4199981689453, LLM outputs 174.42. Exact match fails, flagging a correct answer as unverified. Use tolerance-based matching (e.g., abs(a-b) < 0.01). :::

Knowledge Check​

  1. Why is a PostToolUse hook more reliable than a system prompt instruction like "never make up data"?
  2. What is the difference between is_retryable: true and is_retryable: false in the error contract? Give a real example of each.
  3. An agent correctly fetches $174.4199 from the DB but displays $174.42. The validation layer flags this as unverified. How do you fix the validator without losing accuracy guarantees?
  4. A regulator asks: "Can you prove the $91.80 in this report came from your database?" What does your system need to provide?

πŸ“š Tools & References​

Key Tools for This Challenge​

Microsoft-first: lead with Azure-native tooling. Third-party tools are listed only where they add reliable, best-in-class capability not yet covered natively.

ToolRole in This ChallengeLink
Azure AI Foundry EvaluationsRun the groundedness evaluator to score whether every LLM claim is supported by retrieved tool dataDocs
Azure AI Foundry TracingCapture per-turn spans including tool call inputs/outputs β€” reconstruct the exact path to every numberDocs
Azure SQL Database / Microsoft FabricRecommended deterministic system of record β€” same SQL = same result = auditableAzure SQL Β· Fabric
Azure Monitor / Application InsightsPersist the audit trail (source_ref, validation results) as queryable telemetryDocs
DuckDB (third-party)Zero-cost in-process OLAP database β€” local, offline stand-in for the system of record while you learn the patternduckdb.org
Pydantic v2 (third-party)Enforce structured error contracts on every tool response β€” validate schema before the LLM sees any datadocs.pydantic.dev
tiktoken (third-party)Count tokens consumed by tool results β€” essential for production cost monitoring and context budget trackingGitHub
RAGAS (third-party)Faithfulness evaluator β€” measures if LLM claims are entailed by source tool dataGitHub
Patronus AI (third-party)Finance-specific hallucination detection with domain-aware scorers for regulated financial languagepatronus.ai
Braintrust (third-party)Trace-to-eval pipeline β€” connect every production agent run to an evaluation score for continuous monitoringbraintrust.dev

Required Reading​

ResourceWhy It Matters
The LLM-as-Analyst Trap, Part 1The original article describing the 5 failure modes this challenge is built from
AgentHallu Benchmark (arXiv:2601.06818)Rigorous benchmarking showing that even frontier models fail in multi-step tool-use hallucination scenarios
Azure AI Foundry β€” Groundedness EvaluatorHow to run production groundedness scoring on agent outputs at scale

Cleanup​

# Remove simulated lock files
rm -f *.lock stock_prices.duckdb.lock

# Reset agent conversation threads
az ai agent thread delete --thread-id $THREAD_ID

# Review audit logs
cat audit_trail.jsonl | jq '.[] | select(.verified == false)'