An autonomous, self-correcting SQL generation agent built with LangGraph, SQLGlot AST, and PostgreSQL (pgvector).
Moving beyond brittle, prompt-only "Text-to-SQL" wrappers, this architecture enforces deterministic syntax safety, dynamic graph schema discovery, parallelized map-reduce query generation, and a persistent semantic memory loop that automatically distills lessons from execution failures.
Naive text-to-SQL agents fail in enterprise environments because they treat query generation as a pure text-completion problem. This project implements a compiler-like multi-stage cognitive loop that guarantees execution safety.
Below is an engineering evaluation comparing a standard, prompt-only agent against this architecture under heavy analytical workloads (e.g., multi-table joins, partitioned tables):
| Evaluation Metric | Standard Text-to-SQL Agent | This Self-Healing AST Agent | How It's Achieved / Validated |
|---|---|---|---|
| Complex Join Success Rate (6+ Tables) | < 20% (Frequent failure) | 95%+ (Deterministic) | SQLGlot AST assembly deterministically stitches sub-queries and auto-injects foreign keys. |
| Average Context Token Cost | ~15,000+ tokens (High bloat) | ~2,000 tokens (~85% savings) | BFS Schema discovery & surgical column pruning keep prompt payloads extremely light. |
| SQL Syntax Errors | High (Mismatched tables/keys) | 0% at execution | Pre-compilation AST validation prevents malformed queries from hitting the database. |
| Error Recovery Rate | 0% (Crashes on DB exception) | 90%+ (Self-correcting) | LangGraph healing loop catches failures, auto-corrects SQL, and caches lessons via vector store. |
To guarantee the validity and replicability of these benchmark metrics, evaluations are conducted using a transparent, standardized testing framework:
- Database Schema: The standard classic Pagila DVD Rental relational schema (comprising 15 highly interconnected tables with deep foreign key constraints).
- Test Suites: A structured evaluation set of 50 functional natural language queries divided into three complexity tiers:
- Simple (Tier 1): Single-table selections, projections, and direct filters (e.g., "List the first 10 actors").
- Medium (Tier 2): Queries requiring 2–4 joins with standard primary/foreign key mappings (e.g., "Show action films rented by customer Mary Smith").
- Complex / Marathon (Tier 3): High-dimensional analytical requests requiring 6+ table joins, aggregation functions (
SUM,AVG), sliding-window logic, and nested sorting rules.
- Comparison Baseline: A single-shot "Naive" Text-to-SQL agent with the entire database schema dumped into its system prompt.
- LLM Engine: Groq platform running
llama3-70b-8192(representing typical open-weights commercial speeds). - Semantic Embeddings: Google Generative AI
models/gemini-embedding-2for 1536-dimensional cosine-similarity lookup indexes. - Observability: 100% of inputs, tokens, and trace paths are mapped and recorded in real-time via the LangSmith API.
- Context Token Cost: Exact token tracking logged dynamically via the API provider's usage tokens per transaction.
- Execution Validation: Generated SQL is executed directly against an active PostgreSQL instance. A query is marked as a Success if and only if it executes without throwing an engine exception AND returns the exact, mathematically correct records for the target question.
- Self-Healing Limit: Debugging loops are capped at a maximum of 3 retries. Distilled lessons are only synthesized and written to the pgvector store on successful recovery.
The agent executes queries through a stateful workflow, branching based on prompt complexity and correcting itself through feedback loops.
graph TD
classDef core fill:#2d3748,stroke:#4a5568,stroke-width:2px,color:#fff;
classDef decision fill:#2b6cb0,stroke:#4299e1,stroke-width:2px,color:#fff;
classDef worker fill:#c05621,stroke:#ed8936,stroke-width:2px,color:#fff;
classDef db fill:#276749,stroke:#48bb78,stroke-width:2px,color:#fff;
Start((User Query)) --> Guardian[Guardian Node<br/>Intent & Security]:::core
Guardian -->|DENY / CLARIFY| End((Stop / Ask))
Guardian -->|SQL| Classifier{Classifier Node<br/>Complexity}:::decision
Classifier -->|SIMPLE| GenSQL[Generate SQL Node]:::core
Classifier -->|COMPLEX| Anchor[Anchor Selector<br/>BFS Traversal]:::core
Anchor --> Pruner[Column Pruner<br/>Schema Reduction]:::core
Pruner --> Decomposer[Decomposer Node<br/>Join Plan & Sub-tasks]:::core
Decomposer -->|Send| Worker1[Worker Node 1]:::worker
Decomposer -->|Send| Worker2[Worker Node N]:::worker
Worker1 --> Assembler[Assembler Node<br/>AST Stitching]:::core
Worker2 --> Assembler
GenSQL --> ExecSQL{Execute SQL<br/>PostgreSQL}:::decision
Assembler --> ExecSQL
ExecSQL -->|Error < 3 Retries| Heal[Heal SQL Node]:::worker
Heal --> ExecSQL
Heal -.->|Async Task| Distill[Distill Lesson<br/>to PgVector]:::db
ExecSQL -->|Success| Format[Format Response]:::core
Format --> Output((Final Answer))
The benchmark and test scripts target the classic Pagila schema, which serves as a realistic corporate transactional database:
erDiagram
ACTOR ||--o{ FILM_ACTOR : "plays_in (actor_id)"
FILM ||--o{ FILM_ACTOR : "features (film_id)"
CATEGORY ||--o{ FILM_CATEGORY : "groups (category_id)"
FILM ||--o{ FILM_CATEGORY : "categorized_as (film_id)"
LANGUAGE ||--o{ FILM : "spoken_in (language_id)"
FILM ||--o{ INVENTORY : "stocked_as (film_id)"
STORE ||--o{ INVENTORY : "holds (store_id)"
INVENTORY ||--o{ RENTAL : "rented_out (inventory_id)"
CUSTOMER ||--o{ RENTAL : "makes (customer_id)"
STAFF ||--o{ RENTAL : "processes (staff_id)"
RENTAL ||--o{ PAYMENT : "generates (rental_id)"
CUSTOMER ||--o{ PAYMENT : "pays (customer_id)"
STAFF ||--o{ PAYMENT : "collects (staff_id)"
STORE ||--o{ CUSTOMER : "registers (store_id)"
STORE ||--o{ STAFF : "employs (store_id)"
STAFF ||--|| STORE : "manages (manager_staff_id)"
ADDRESS ||--o{ CUSTOMER : "lives_at (address_id)"
ADDRESS ||--o{ STAFF : "based_at (address_id)"
ADDRESS ||--o{ STORE : "located_at (address_id)"
CITY ||--o{ ADDRESS : "contains (city_id)"
COUNTRY ||--o{ CITY : "contains (country_id)"
- Avoids context-window bloat by querying database schema metadata dynamically instead of dumping the entire database description into the prompt.
- Executes BFS (Breadth-First Search) over the database's foreign key network to discover intermediary "bridge" tables, ensuring accurate join paths.
- Performs column-level pruning to extract only relevant columns, protected by a deterministic guard that forces foreign key columns to remain in the schema so joins never fail.
- Decomposes complex prompts into atomic "logic islands" using a Manager node.
- Parallelizes generation by spawning isolated Worker nodes to generate optimized SQL snippets for each sub-task.
- Establishes structured join plans mapping task relationships, preventing LLM confusion on heavy multi-join analytical queries.
- Eliminates fragile string concatenation in favor of SQLGlot AST (Abstract Syntax Tree) compilation.
- Auto-injects missing join keys dynamically into
SELECTandGROUP BYclauses of sub-queries, guaranteeing clean schemas and syntactical compatibility. - Enforces unique table aliasing (
{island_name}_{column_name}) across all sub-queries and CTE joins to eliminate namespace collisions in PostgreSQL.
- Captures execution exceptions directly from the database and routes them back to the state graph.
- Applies self-healing prompts containing error codes, schema contexts, and query history to fix errors on the fly.
- Executes async lesson distillation after a successful recovery, packaging the solution into a "Golden Standard Lesson" stored in a PgVector memory database for semantic retrieval during subsequent runs.
When a user asks a complex multi-join question, the Manager (Decomposer) breaks it down, and the AST Assembler deterministically stitches the components together:
graph TD
classDef logic fill:#2b6cb0,stroke:#4299e1,stroke-width:2px,color:#fff;
classDef inject fill:#c05621,stroke:#ed8936,stroke-width:2px,color:#fff;
Q["User: 'Top Action films in Canada'"] --> Decomposer:::logic
Decomposer --> T1["Task 1: Filter Action Films<br>(category, film_category)"]
Decomposer --> T2["Task 2: Filter Canadian Users<br>(country, city, address, customer)"]
Decomposer --> T3["Task 3: Calculate Revenue<br>(rental, payment)"]
T1 --> AST["SQLGlot AST Assembler"]:::inject
T2 --> AST
T3 --> AST
AST -.->|Auto-Inject Key| K1["film_id"]
AST -.->|Auto-Inject Key| K2["customer_id"]
AST --> CTE["Final Compiled CTE Query"]:::logic
Naive Text-to-SQL agents catastrophically collapse under heavy joins due to hallucinated relationship keys, schema token bloat, or column naming collisions. This section provides a concrete, step-by-step trace of how our Divide & Conquer (Map-Reduce) AST Architecture successfully processes an enterprise-grade benchmark.
"List the top 3 actors whose films have generated the most revenue from customers living in cities that start with the letter 'A', but only for 'Sci-Fi' films."
sequenceDiagram
autonumber
actor User
participant Graph as LangGraph Engine
participant Decomposer as Manager (DecomposerNode)
participant Worker as Parallel Worker Nodes
participant Assembler as Compiler (AssemblerNode)
participant DB as PostgreSQL Database
User->>Graph: Submit "Join Marathon" Query
Graph->>Graph: ClassifierNode identifies complexity as COMPLEX
Graph->>Graph: Schema Discovery & Column Pruner reduce context size by ~85%
Graph->>Decomposer: Send pruned schemas & anchor tables
Decomposer->>Decomposer: Analyze foreign key path & create parallel plan
Decomposer-->>Worker: Dispatch Sub-Tasks in Parallel
Note over Worker: Worker 1 (Sci-Fi Filtering)<br/>Worker 2 (City 'A' Filtering)<br/>Worker 3 (Revenue Aggregation)
Worker-->>Assembler: Return isolated SQL snippets
Assembler->>Assembler: SQLGlot AST stitches snippets & auto-injects missing keys
Assembler->>DB: Execute unified, deterministic CTE query
DB-->>Graph: Return structured results
Graph->>User: Render formatted markdown response
- Intent Analysis: The
ClassifierNodetriggers theCOMPLEXworkflow path due to multiple analytical criteria (category, city prefix, aggregation). - BFS Schema Graph Traversal: The
AnchorSelectorNodediscovers the 11-table relationship path needed to connectactortocityvia customer payments:actor↔film_actor↔film↔film_category↔categoryfilm↔inventory↔rental↔payment↔customer↔address↔city
- Surgical Column Pruning: The
ColumnPrunerNodestrips out unnecessary fields (like addresses, updates, descriptions), keeping only search filters and foreign keys. This dynamically compresses the prompt context from ~15,000 to ~2,000 tokens (~85% savings).
The DecomposerNode processes the skeletal schema and output requirements, generating a structured, dependency-aware plan:
{
"complexity_score": 9,
"sub_tasks": [
{
"task_id": "t1",
"description": "Filter films belonging to the 'Sci-Fi' category.",
"tables": ["film", "film_category", "category"],
"required_columns": ["film_id"]
},
{
"task_id": "t2",
"description": "Filter customers living in cities starting with the letter 'A'.",
"tables": ["customer", "address", "city"],
"required_columns": ["customer_id"]
},
{
"task_id": "t3",
"description": "Calculate film-level rental revenue per actor by joining rentals, inventory, and payments.",
"tables": ["actor", "film_actor", "inventory", "rental", "payment"],
"required_columns": ["actor_id", "first_name", "last_name", "film_id", "customer_id", "amount"]
}
],
"join_plan": {
"base_task": "t3",
"steps": [
{
"left": "t1",
"right": "t3",
"on": "t1.t1_film_id = t3.t3_film_id",
"join_type": "INNER"
},
{
"left": "t2",
"right": "t3",
"on": "t2.t2_customer_id = t3.t3_customer_id",
"join_type": "INNER"
}
],
"final_select": [
"t3_first_name AS first_name",
"t3_last_name AS last_name",
"SUM(t3_amount) AS total_revenue"
],
"group_by": [
"t3_first_name",
"t3_last_name"
],
"order_by": "total_revenue DESC",
"limit": 3
}
}Three parallel worker threads generate optimized, independent SQL queries. They focus solely on their given subset of tables, preventing LLM attention dilution:
```sql
-- [Worker 1 Snippet (t1)] Sci-Fi Film Filter
SELECT
f.film_id,
f.title
FROM film f
JOIN film_category fc ON f.film_id = fc.film_id
JOIN category c ON fc.category_id = c.category_id
WHERE c.name = 'Sci-Fi'
```
<!-- slide -->
```sql
-- [Worker 2 Snippet (t2)] Customers in 'A' Cities
SELECT
cu.customer_id,
cu.first_name,
cu.last_name
FROM customer cu
JOIN address a ON cu.address_id = a.address_id
JOIN city ci ON a.city_id = ci.city_id
WHERE ci.city LIKE 'A%'
```
<!-- slide -->
```sql
-- [Worker 3 Snippet (t3)] Actor Payments & Mappings
SELECT
act.actor_id,
act.first_name,
act.last_name,
p.amount,
fa.film_id,
r.customer_id
FROM actor act
JOIN film_actor fa ON act.actor_id = fa.actor_id
JOIN inventory i ON fa.film_id = i.film_id
JOIN rental r ON i.inventory_id = r.inventory_id
JOIN payment p ON r.rental_id = p.rental_id
```
When the snippets arrive at the AssemblerNode, the SQLAssembler performs AST manipulation instead of simple string concatenation:
- Unique Table Aliasing: To prevent duplicate column names and namespace clashes in the database, the AST automatically prefixes every projected column in the CTE with its corresponding task ID (
{island_id}_{column_name}).- Example:
first_name→t2_first_name,amount→t3_amount.
- Example:
- Missing Join Key Injection: The assembler parses the
join_planand discovers thatt1joins witht3onfilm_id, andt2joins witht3oncustomer_id. The AST engine checkst3and ensures thatfilm_idandcustomer_idare explicitly projected insidet3. If a worker had omitted them, the AST would surgically inject them. - GROUP BY Safety Preservation: If the assembler injects a key into an aggregate query (e.g.
t3), it automatically appends the injected key to the query'sGROUP BYclause. This prevents classicPostgreSQLerrors where non-aggregated columns must appear in the group list.
Here is the final, syntactically flawless query generated by our AST engine. Observe how the sub-queries are nested into clean Common Table Expressions (CTEs), uniquely aliased, and joined deterministically:
WITH t1 AS (
SELECT
f.film_id AS t1_film_id,
f.title AS t1_title
FROM film AS f
JOIN film_category AS fc
ON f.film_id = fc.film_id
JOIN category AS c
ON fc.category_id = c.category_id
WHERE
c.name = 'Sci-Fi'
), t2 AS (
SELECT
cu.customer_id AS t2_customer_id,
cu.first_name AS t2_first_name,
cu.last_name AS t2_last_name
FROM customer AS cu
JOIN address AS a
ON cu.address_id = a.address_id
JOIN city AS ci
ON a.city_id = ci.city_id
WHERE
ci.city LIKE 'A%'
), t3 AS (
SELECT
act.actor_id AS t3_actor_id,
act.first_name AS t3_first_name,
act.last_name AS t3_last_name,
p.amount AS t3_amount,
fa.film_id AS t3_film_id,
r.customer_id AS t3_customer_id
FROM actor AS act
JOIN film_actor AS fa
ON act.actor_id = fa.actor_id
JOIN inventory AS i
ON fa.film_id = i.film_id
JOIN rental AS r
ON i.inventory_id = r.inventory_id
JOIN payment AS p
ON r.rental_id = p.rental_id
)
SELECT
t3.t3_first_name AS first_name,
t3.t3_last_name AS last_name,
SUM(t3.t3_amount) AS total_revenue
FROM t3 AS t3
JOIN t1 AS t1
ON t1.t1_film_id = t3.t3_film_id
JOIN t2 AS t2
ON t2.t2_customer_id = t3.t3_customer_id
GROUP BY
t3.t3_first_name,
t3.t3_last_name
ORDER BY
total_revenue DESC
LIMIT 3;AI APIs (e.g. Groq, llama) sometimes fail to enforce strict structured schemas. The base node implementation contains a robust manual parser fallback that recovers JSON block structures from LLM outputs using regular expressions and validates them with Pydantic:
# From src/workflow/nodes/base.py
def robust_invoke(chain, input, schema_class, max_retries=2):
try:
return chain.invoke(input)
except Exception as e:
logger.warning(f"Structured output failed. Attempting robust fallback...")
# Manual Fallback: Inject instructions, match JSON block, parse & validate with Pydantic
fallback_prompt = f"{prompt_text}\n### OUTPUT INSTRUCTIONS:\nYou MUST output ONLY a valid JSON object matching this schema..."
for attempt in range(max_retries):
res = raw_llm.invoke(fallback_prompt)
json_match = re.search(r"```json\s*(.*?)\s*```", res.content, re.DOTALL)
json_str = json_match.group(1) if json_match else res.content
return schema_class(**json.loads(json_str))Our compiler-like service uses SQLGlot AST to manipulate select objects, append group-by clauses, and safely build CTEs without losing database integrity:
# From src/services/sql_assembler.py
for island_id, sql in islands.items():
island_ast = parse_one(sql, read=self.dialect)
# Inject missing keys into SELECT and GROUP BY to satisfy PostgreSQL rules
if isinstance(island_ast, exp.Select):
existing_cols = {c.alias_or_name for c in island_ast.expressions}
for key in required_keys.get(island_id, []):
if key not in existing_cols:
island_ast.select(key, copy=False)
if island_ast.args.get("group"):
island_ast.group_by(key, copy=False)- Python 3.9+
- PostgreSQL 15+ database instance (with
pgvectorextension enabled)
git clone https://github.com/prthm2910/self-healing-sql-agent.git
cd self-healing-sql-agent
pip install -r requirements.txtCreate a .env file at the root directory:
GOOGLE_API_KEY="your_google_embeddings_api_key"
GROQ_API_KEY="your_groq_llm_api_key"
DATABASE_URL="postgresql://postgres:password@localhost:5432/pagila"Initialize the Pagila sample database schema and configure the pgvector-backed semantic memory store:
python scripts/setup_db.py
python scripts/setup_store_properly.pyValidate that the Map-Reduce schema pruner and AST assembler can successfully process the "Join Marathon" benchmark:
python scripts/stress_test_dac.pystreamlit run src/ui/app.pyTo prepare this platform for enterprise-scale deployment, the following phases are currently under development:
- Multi-Dialect AST Transpilation: Extending
SQLAssemblerto transpile queries dynamically to BigQuery, Snowflake, and Redshift using SQLGlot's core dialect libraries. - Rust-Accelerated AST Parsing: Transitioning compute-heavy SQL AST manipulation, namespace aliasing, and key injections to a custom Rust parser backend via PyO3 to achieve near-zero latency overhead.
- Vectorless RAG Schema Indexing: Moving away from standard vector search databases for schema retrieval. Designing a deterministic "Vectorless RAG" hierarchy utilizing structural index lookup trees (e.g., PageIndex) to guarantee absolute schema precision.
Built for the future of robust, deterministic Agentic AI Engineering.