Text-to-SQL in .NET 10 (V2): Self-Healing Workflow with Microsoft Agent Framework

Moving from basic One-Shot SQL generation to a reliable Multi-Agent Workflow with on-demand schema discovery and automatic SQLite error recovery 11/04/2026

In my previous article (V1), I explained how I built a 100% local, private Text-to-SQL pipeline in .NET 10 by combining Entity Framework Core metadata (IDesignTimeModel) with local CPU inference via LLamaSharp and Qwen2.5-Coder-7B.

That first version (V1 — Direct One-Shot Pipeline) followed a very simple flow: extract the full EF Core database schema, put it into a single system prompt, ask the LLM to write a SQL query in one pass, and execute it with Dapper.

For basic aggregations, joins, and C# enum filters, V1 worked very well—answering in under 10 seconds on standard CPU cores. However, as soon as I tested more complex questions involving relative dates or string manipulation, I quickly reached a limit: SQL execution errors.

In this second article, I will show how I upgraded my POC from a simple One-Shot pipeline to a Self-Healing Multi-Agent Workflow (V2) using the new Microsoft Agent Framework—and share the exact benchmark results comparing V1 and V2 across 6 test questions.

1. Why One-Shot Text-to-SQL Fails on Complex Queries

When testing my V1 One-Shot pipeline with more realistic business questions, I noticed three main limitations:

A. SQL Dialect Mistakes on 7B Models

Open-weights coding models of 7B parameters (such as Qwen2.5-Coder-7B-Instruct) have been trained mostly on MySQL, PostgreSQL, and SQL Server codebases. Even when you ask them to write SQLite queries, they often use MySQL functions as soon as a question requires date filtering or string splitting:

  • Ask: "List customers in France created this year" → The model writes WHERE YEAR(CreatedAt) = YEAR(CURDATE()). SQLite immediately fails with: SQLite Error 1: 'no such function: CURDATE'.
  • Ask: "How many customers do we have per email domain (the part after @)?" → The model writes SELECT SUBSTRING_INDEX(Email, '@', -1). SQLite immediately fails with: SQLite Error 1: 'no such function: SUBSTRING_INDEX'.

In a One-Shot pipeline, there is no retry mechanism: when the SQL exception is thrown, the request stops immediately and the user gets an error message with no data.

B. Sending the Entire Schema Every Time

V1 sends 100% of the EF Core schema in the prompt for every question. While this is fine for a small demo with 2 tables (Customers and Invoices), a real enterprise DbContext with 60 to 100 tables will quickly exceed the model's context window—or make CPU inference much slower—even when the user's question only needs a single table.

C. Raw Data Tables Without a Natural Language Answer

Finally, V1 stops as soon as Dapper returns the rows. The user only sees a raw data table without a clear sentence summarizing the answer.

2. Introducing the Microsoft Agent Framework

To solve these issues without hardcoding SQL rules in the prompt, I wanted my system to work like a real developer: explore the schema only when needed, run the query, read the SQLite error message if it fails, fix the SQL query, and summarize the final result.

To build this cleanly in .NET 10, I used the Microsoft Agent Framework (Microsoft.Agents.AI).

Until recently, .NET developers had to choose between Semantic Kernel (focused on enterprise orchestration and plugins) and AutoGen (focused on multi-agent collaboration). The Microsoft Agent Framework brings the best of both worlds together and is now THE official Microsoft standard for building agentic AI applications in .NET.

Built directly on top of the standard Microsoft.Extensions.AI abstractions, it provides simple and clean building blocks:

  • IChatClient: The standard .NET interface to talk to any LLM (local or cloud) and use tool calling.
  • AIAgent & ChatClientAgent: Ready-to-use agent classes that combine instructions, tools, and execution loops.
  • AgentSession: Separate conversation sessions to keep each agent's context clean.
  • AIFunction & AIFunctionFactory: Standard C# methods exposed as tools that the agent can call automatically.

You can read the official documentation on Microsoft Learn: Microsoft Agent Framework Overview and check the official GitHub repository.

3. Building a Two-Agent Self-Healing Workflow

Here is the complete execution flow of the V2 workflow orchestrated with the Microsoft Agent Framework:

V2 — MULTI-AGENT WORKFLOW (MICROSOFT AGENT FRAMEWORK) User Question POST /api/chat/v2/ask AgentSqlService Agent 1: SqlExplorerAgent (Session #1) 1. ListTables() → Discovers table names 2. GetTableSchema() → Loads needed table(s) 3. ExecuteReadOnlySql(sqlQuery) On-demand EF Core metadata via AIFunction tools SQL OK SQLite Error → Terminate = true (Handoff) Agent 2: SqlSelfHealingAgent (Session #2) Clean, isolated context (no history pollution): • Failed SQL + SQLite Error Message • Target Table Schema → Calls ExecuteReadOnlySql(repairedSql) Self-Healed SQL OK Final Response • Dapper Data Table • NL Summary (FR) • Full Agent Step Trace 100% Success (6/6)

Before writing the V2 workflow, I set one important rule: to make a fair comparison between V1 and V2, both versions must use the exact same base SQL instructions—with zero hardcoded table names, column names, or SQLite function hints.

Sharing the Same Base Instructions (Fair Comparison)

In PromptConstants.cs, both V1 and V2 reuse the exact same SharedSqlBaseInstructions constant:


public static class PromptConstants
{
    /// <summary>
    /// Base SQL instructions shared between V1 (One-Shot) and V2 (Explorer Agent).
    /// Contains zero domain-specific hints: all table, column, enum, and FK metadata
    /// is discovered dynamically from EF Core.
    /// </summary>
    public const string SharedSqlBaseInstructions = @"""
        You are an expert SQLite data analyst.
        Rules:
        - Write a valid SQLite SELECT query based strictly on the provided schema.
        - Pay attention to exact column names, foreign keys, and Enum integer values documented in the schema.
        - Never use INSERT, UPDATE, DELETE, DROP, or ALTER.
        """;

    public static string GetV1SystemPrompt(string fullDatabaseSchema) => $@"""
        {SharedSqlBaseInstructions}
        - Output ONLY the raw SQL query. Do not include markdown blocks (no ```sql) or explanations.

        Database Schema:
        {fullDatabaseSchema}
        """;

    public const string V2ExplorerAgentInstructions = $@"""
        {SharedSqlBaseInstructions}

        Workflow:
        1. Call ListTables to see available tables in the database.
        2. Call GetTableSchema ONLY for the table(s) needed to answer the user's question.
        3. Call ExecuteReadOnlySql with a valid SQLite SELECT query.
        4. Once ExecuteReadOnlySql succeeds, write a concise 1-sentence summary of the results in French.
        """;
}
                

Why Use Two Agents Instead of One?

At first, I tried using a single agent: when ExecuteReadOnlySql failed, I returned the SQLite error message to the same agent and asked it to try again.

However, with a local 7B model, a single-agent retry loop suffers from context pollution (the model stays stuck on its first mistake): its conversation history still contains the previous ListTables() call, the GetTableSchema() output, and the wrong SQL query it just wrote.

By splitting the work between two specialized agents—each in its own class and running in a separate AgentSession—I reached 100% self-healing success:

  1. SqlExplorerAgent (Agent 1): Lists the tables via ListTables, inspects only the required table(s) via GetTableSchema, and runs the first ExecuteReadOnlySql query.
  2. SqlSelfHealingAgent (Agent 2): Called only if Agent 1 produces a SQLite error. It starts in a clean, separate AgentSession containing only the target table schema, the failed SQL query, and the exact SQLite error message.

Agent 1: SqlExplorerAgent & Stopping on Error

Here is how SqlExplorerAgent is created using ChatClientAgent from Microsoft.Agents.AI:


public static class SqlExplorerAgent
{
    public const string AgentName = "SqlExplorerAgent";

    public static ChatClientAgent Create(IChatClient toolEnabledClient, SqlAgentTools tools)
    {
        return new ChatClientAgent(
            toolEnabledClient,
            instructions: PromptConstants.V2ExplorerAgentInstructions,
            name: AgentName,
            description: "Primary Text-to-SQL agent that discovers EF Core schema metadata via tools and executes read-only SQLite queries.",
            tools: tools.CreateExplorerTools().ToList());
    }
}
                

Inside SqlAgentTools.ExecuteReadOnlySql, if Dapper throws a SqliteException, I save the error message and set FunctionInvokingChatClient.CurrentContext.Terminate = true. This immediately stops Agent 1's loop so the orchestrator can pass the relay to Agent 2:


[Description("Executes a read-only SELECT SQLite query against the database and returns rows as JSON.")]
public async Task<string> ExecuteReadOnlySql(
    [Description("A valid SQLite SELECT query.")] string sqlQuery)
{
    ExecutedQueries.Add(sqlQuery);
    try
    {
        var result = await _sqlExecutor.ExecuteSelectQueryAsync(sqlQuery);
        LastExecutionResult = result;
        LastExecutionError = null;

        return JsonSerializer.Serialize(new
        {
            status = "success",
            rowCount = result.Rows.Count,
            previewRows = result.Rows.Take(15)
        });
    }
    catch (Exception ex)
    {
        LastExecutionError = ex.Message;

        // Immediately stop Agent 1's loop so SqlSelfHealingAgent can take over
        if (FunctionInvokingChatClient.CurrentContext is not null)
        {
            FunctionInvokingChatClient.CurrentContext.Terminate = true;
        }

        return JsonSerializer.Serialize(new
        {
            status = "error",
            sqliteErrorMessage = ex.Message,
            failedSqlQuery = sqlQuery
        });
    }
}
                

Agent 2: SqlSelfHealingAgent (Automatic SQL Repair)

When AgentSqlService sees that SqlExplorerAgent stopped because of a SQL error, it creates SqlSelfHealingAgent and runs it in a new AgentSession:


public static class SqlSelfHealingAgent
{
    public const string AgentName = "SqlSelfHealingAgent";

    public static ChatClientAgent Create(IChatClient toolEnabledClient, SqlAgentTools tools)
    {
        return new ChatClientAgent(
            toolEnabledClient,
            instructions: PromptConstants.V2SelfHealingAgentInstructions,
            name: AgentName,
            description: "Specialized SQLite self-healing agent that diagnoses failed SQL queries and executes corrected SQLite syntax.",
            tools: tools.CreateSelfHealingTools().ToList());
    }
}
                

And here is how the handoff between the two agents is orchestrated in AgentSqlService.cs:


// Step 1: Run Agent 1 (SqlExplorerAgent)
ChatClientAgent explorerAgent = SqlExplorerAgent.Create(toolEnabledClient, agentTools);
AgentSession explorerSession = await explorerAgent.CreateSessionAsync(cancellationToken);

AgentResponse agentResponse = await explorerAgent.RunAsync(
    $"Question: {userQuestion}",
    explorerSession,
    cancellationToken: cancellationToken);

// Step 2: Hand over to Agent 2 (SqlSelfHealingAgent) if SQLite execution failed
if (agentTools.LastExecutionResult is null && !string.IsNullOrEmpty(agentTools.LastExecutionError))
{
    var failedSql = agentTools.ExecutedQueries.LastOrDefault() ?? string.Empty;
    var inspectedSchema = agentTools.GetInspectedSchemasText();

    ChatClientAgent selfHealingAgent = SqlSelfHealingAgent.Create(toolEnabledClient, agentTools);
    AgentSession healingSession = await selfHealingAgent.CreateSessionAsync(cancellationToken);

    var healingPrompt = $@"""
        Original User Question: {userQuestion}

        Database Schema:
        {inspectedSchema}

        Failed SQL Query from previous agent:
        {failedSql}

        SQLite Error Message:
        {agentTools.LastExecutionError}

        Fix the SQL query so it is valid SQLite syntax and call ExecuteReadOnlySql now.
        """;

    agentResponse = await selfHealingAgent.RunAsync(
        healingPrompt,
        healingSession,
        cancellationToken: cancellationToken);
}
                

4. Benchmark: V1 (One-Shot) vs. V2 (Agentic Workflow)

To measure the real difference between One-Shot generation and my Multi-Agent workflow, I ran the exact same 6 business questions first in V1, then in V2.

Test Environment:

  • Model: qwen2.5-coder-7b-instruct-q4_k_m.gguf (4.36 GB, 4-bit quantization)
  • Inference Engine: LLamaSharp running 100% on CPU (CPU_REPACK + Flash Attention enabled, n_ctx = 4096, Greedy Sampling Temperature = 0.0)
  • Database: SQLite with 60 customers and 350 invoices

Global Summary: Reliability vs. Response Time

Metric V1 — Direct One-Shot V2 — Multi-Agent Workflow Impact / Key Takeaway
Overall SQL Success Rate 66.7% (4 / 6) 100% (6 / 6) +33.3 pts success rate thanks to Self-Healing
Complex SQL Success (dates & strings) 0% (0 / 2 — Crash) 100% (2 / 2 Self-Healed) Automatically fixes CURDATE & SUBSTRING_INDEX errors
Avg. Response Time (Standard Q1–Q4) 10.15 s 28.16 s
(~19.2s SQL + ~9.0s summary)
4 CPU turns, includes a natural language answer
Avg. Response Time (Self-Healed Q5–Q6) 9.22 s (Failed) 36.30 s
(2 agents, 5 turns total)
Turns a blocking SQL error into a valid result + summary
EF Core Schema Sent to LLM 100% of tables (always) On-demand
(1 table on 4 of 6 queries)
50% fewer schema tokens when only 1 table is needed
Final Output for the User Raw data table only Data table + Natural Language Summary Clear, ready-to-read business answer

Step-by-Step Example of Self-Healing

Let's look at what happened on the question: "How many customers do we have per email domain (the part after @)?"

In V1, the model used MySQL's SUBSTRING_INDEX function, which immediately failed on SQLite:


-- V1 One-Shot Output (CRASH: SQLite Error 1: 'no such function: SUBSTRING_INDEX')
SELECT SUBSTRING_INDEX(Email, '@', -1) AS EmailDomain, COUNT(*) AS NumberOfCustomers
FROM Customers
GROUP BY EmailDomain
                

In V2, SqlExplorerAgent made the exact same mistake at Step 3. However, instead of returning an error to the user, SqlSelfHealingAgent took over at Step 4, read the no such function: SUBSTRING_INDEX error message, and rewrote the query using standard SQLite functions (SUBSTR and INSTR):


-- V2 SqlSelfHealingAgent Output (SUCCESS: 3 rows returned in 0ms)
SELECT SUBSTR(Email, INSTR(Email, '@') + 1) AS Domain, COUNT(*) AS NumberOfClients
FROM Customers
GROUP BY Domain
                

Finally, at Step 5, the agent wrote a clear summary in French: "Il y a 19 clients chez gmail.com, 23 chez hotmail.fr et 18 chez yahoo.fr."

5. Conclusion & Enterprise Perspectives

Moving from a single One-Shot prompt to a two-agent workflow with the Microsoft Agent Framework increased my SQL success rate from 66.7% to 100% while cutting schema tokens by 50% on single-table questions. Of course, running 4 to 5 turns on a local CPU takes ~28–36 seconds instead of ~10 seconds—which is a classic trade-off between speed and reliability.

At the same time, I am fully aware that deploying this kind of solution in a real enterprise environment requires solving several additional challenges:

  • Row-Level Security (RLS) & User Permissions: Making sure the agent can only query data that the logged-in user is allowed to see.
  • Query Limits & Database Protection: Adding strict execution timeouts, forced LIMIT clauses, and running queries on a read-only database replica.
  • Data Privacy & Sensitive Columns: Hiding sensitive fields (such as salaries or personal data) from the schema and from the LLM context.
  • Finding the Right Tables in Large Databases: When a database has 150+ tables, ListTables is no longer enough—combining agent tools with vector search on table descriptions becomes necessary.
  • Monitoring & Cost Tracking: Logging every agent step, tool call, and token usage with OpenTelemetry.

Even with these enterprise constraints in mind, this POC is a great example of what you can build today by integrating AI into existing .NET applications. Because the Microsoft Agent Framework relies on standard IChatClient and AIAgent interfaces, the exact same multi-agent code can run 100% locally with LLamaSharp, or connect to cloud platforms like Microsoft Azure AI Foundry and Amazon Bedrock by changing a single line of configuration.

👉 Explore the Full V1 & V2 Source Code on GitHub

Useful Links

Share On