agent_loop_demo · how it works

Agent Loop Anatomy

A local model answers questions about retail sales by calling tools on an MCP server. Those tools reach the data through a semantic layer and guarded SQL. The user's identity travels with every call, and each layer checks it again. This page follows real requests through the system, one hop at a time.

Gemma 4 E2B via Ollama LangGraph agent loop MCP Streamable HTTP Cube semantic layer Postgres 16 + RLS Phoenix OTel traces Chainlit chat UI
01 · the request path

Follow a request

Pick a scenario and step through it with Next or the arrow keys. The top diagram shows which services a step touches; the bottom one shows where the LangGraph state machine is. Click any box to read what that component does.

The system. Dashed boxes are the Docker services. Solid arrows carry requests; dashed arrows carry trace spans to Phoenix.
The agent loop (src/agent/graph.py). The agent ⇄ tools cycle repeats until the model answers or hits the step limit (8). Calls to run_sql pause in approve_sql until a person approves, edits or rejects them.
02 · authorization

Who sees what

Two independent questions decide every answer. Which rows? All regions, or only the user's own (row-level security). What shape? Raw detail, masked detail, or only aggregates (scope). Both come from one file, config/policies.yaml. The agent, the MCP server and Cube all read it, and Postgres mirrors it with policies.

Full answer Rows limited to own region Answer with PII masked or small groups hidden Refused
03 · defense in depth

Layers of defense

No single check is trusted alone. Pick something that could go wrong; the list shows the layers in the order a request crosses them, which one stops it, and which would catch it if that one failed. Every one of these is covered by a test in tests/.

04 · observability and evals

Measuring it

An agent fails quietly. It picks the wrong tool, sums a column badly, or returns a number nobody can trace. Three instruments make that visible.

Traces (Phoenix, :6006)

The agent and the MCP server both export OpenTelemetry spans. The trace id rides along on every HTTP call, so one chat turn is one trace: graph nodes, each model call with its prompt and tool choice, and each tool span tagged with user and role. Use it to answer "why was that slow?" and "what exactly did the model see?".

Audit log (audit.tool_calls)

One row per tool call, written by a role that can only INSERT: who, which tool, the exact query, row count, latency and ok / denied / error. Traces are for debugging and get discarded after a while; the audit log is the compliance record.

Evals (evals/run_evals.py)

23 golden questions with reference answers computed straight from the database. Each run scores tool choice, answer accuracy, latency and the count that must stay at zero: data leaks. The script exits non-zero on any leak, so it can gate CI.

First full run · gemma4:e2b, thinking on
0data leaks
100%tool selection
83%pass rate (19 of 23)
28smedian latency (p95 57s)

All four failures had one cause. A date range with no granularity was split into 12 monthly rows, and the 2B model couldn't add them up. After the fix, a range returns one total and all six affected cases pass in 13–18 s. The evals found this; reading the code had not.

05 · operating it

Running it

PortServiceWhat to do there
8000agent/ui chat, /chat API, /healthz
8001mcp-serverMCP endpoint /mcp (needs a token)
4000cubesemantic layer REST API
5432postgresretail and agent_state databases
6006phoenixtraces, project default
11434ollama (host)model server, outside Docker for GPU access

Demo logins: alice, bob, carol, dave, erin; the password for all is demo. What a real deployment changes is in docs/path-to-production.md.

06 · vocabulary

Terms

MCP (Model Context Protocol)
A standard way to expose tools, readable resources and prompt templates to any AI client. Here the agent is the client and mcp-server is the server; Claude Desktop or the MCP Inspector could connect to the same server.
Semantic layer
Named business metrics (sales.revenue) and dimensions (regions.name) defined once, with the joins and SQL behind them. The model picks names instead of writing joins, which a small model does far more reliably.
Row-level security (RLS)
Postgres policies that filter rows per session. The MCP server sets app.row_scope and app.region_id inside each transaction; if they are missing, the policy returns nothing.
JWT
A signed token carrying sub, role and region_id. Minted per turn, valid 15 minutes, verified by the MCP server and again by Cube. Text in a prompt can't change it.
Checkpointer
LangGraph saves the state after every node to Postgres. That gives conversation memory, and it lets a paused SQL approval resume later, on any replica.
Interrupt
A node calls interrupt(value) to pause the graph and hand value to the UI. The run resumes with Command(resume=decision).
Grounding check
The output guardrail pulls every figure above 100 from the answer and looks for it, allowing for rounding, among the numbers the tools returned this turn. Figures it can't find are flagged under the answer.
Holt-Winters
Exponential smoothing with a trend and a seasonal cycle. It's fast, explainable, and shipped with a backtest error (MAPE) so the user can see how much to trust it.