Architecture
db-agent ask "..." (cli/main.py)
|
run(question) (run.py) -> AgentResult
|
run_react_loop (agent/react_loop.py) AgentState: hop_count, retry_count, failed
| ^
| +--- Claude tool-use turns (generation/claude_client.py, model claude-sonnet-5)
v
tool dispatch (arguments validated against each tool's exact key set)
|- get_schema(table_name) one table only, 300 s TTL cache
|- execute_read_only_sql(query) AST guard -> agent_ro session
'- generate_chart(spec) pydantic-validated Vega-Lite subset
get_agent_session() (db/session.py): the only place a connection is made
agent_ro role + SET TRANSACTION READ ONLY -> Postgres 16 (Pagila)
The loop
Each iteration asks the model for one turn. A final answer ends the run. A tool call increments
hop_count, runs, and its result (or error text, verbatim) is appended to the history as a
tool_use / tool_result pair so the model can react. Limits, from agent/state.py:
| Limit | Value | Behavior when reached |
|---|---|---|
MAX_HOPS |
8 tool calls | run ends, failed=True, summary "stopped after N steps without a final answer" |
MAX_RETRIES |
3 consecutive tool errors | run ends, failed=True, summary "gave up: ..." |
| statement timeout | 3000 ms | query returns an error observation |
A successful tool call resets the consecutive-error count. Unknown tool names and wrong argument keys are error observations, not crashes. See ADR 0001.
Tools
get_schema: columns (name, type, nullability) and foreign keys for exactly one table inpublic. Foreign keys are read frompg_catalogbecauseinformation_schemahides them from a SELECT-only role. There is no whole-catalog call, which bounds prompt size structurally.execute_read_only_sql: guard first (ADR 0002), then the read-only session (ADR 0005).generate_chart: ADR 0003.
Deployment shape
docker/docker-compose.yml defines project db-action-agent with postgres (Postgres 16,
admin bootstrap role, host port 5433, init scripts in docker/init-db/) and agent (image with
the db-agent entrypoint). There is no HTTP service (ADR 0004).