diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6049de9..3f8ca30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- `AgentQLPlugin.ExecuteQueryWithDescription(sqlQuery, resultDescription)` — a
+ variant of `ExecuteQuery` where the model also supplies a short description of
+ the result set (column meanings, filters, caveats) in the same tool call. Hosts
+ that hand the raw rows to an orchestrator can skip the final prose-generation
+ turn entirely, cutting one model round-trip per question.
+- `AgentQLPlugin.LastSuccessfulResult` — per-scope capture of the last successful
+ query (`CapturedQueryResult`: SQL, rows, row count, optional description), so
+ hosts no longer need to wrap `ExecuteQuery` to harvest verified rows for charts
+ or structured replies. Failed queries never overwrite a prior capture; a
+ zero-row success is captured (it is an answer, not a failure).
+- `SelfCorrectingChatClient` recognises `ExecuteQueryWithDescription` as a
+ grounding query (new `ExecuteQueryWithDescriptionToolName` constant), so the
+ self-correction guard works identically with either execute variant.
+
+### Changed
+
+- `GetDatabaseSchema` workflow text and the self-correction reminder now refer to
+ "the query execution tool" generically instead of naming `ExecuteQuery`, since
+ hosts may expose either execute variant.
+
## [0.2.1] — 2026-06-08
### Fixed
diff --git a/src/Equibles.AgentQL.MicrosoftAI/AgentQLPlugin.cs b/src/Equibles.AgentQL.MicrosoftAI/AgentQLPlugin.cs
index af75035..49d5a13 100644
--- a/src/Equibles.AgentQL.MicrosoftAI/AgentQLPlugin.cs
+++ b/src/Equibles.AgentQL.MicrosoftAI/AgentQLPlugin.cs
@@ -16,6 +16,15 @@ public AgentQLPlugin(ISchemaProvider schemaProvider, IQueryExecutor queryExecuto
_queryExecutor = queryExecutor;
}
+ ///
+ /// The last successful query executed through this plugin instance (either
+ /// or ), or null
+ /// when no query has succeeded yet. Failed queries never overwrite a prior capture.
+ /// The plugin is registered scoped, so the capture is per resolution scope; it is
+ /// not safe for concurrent query execution within a single scope.
+ ///
+ public CapturedQueryResult LastSuccessfulResult { get; private set; }
+
[Description(
"Gets the database schema description including all tables, columns, relationships, and enum values. "
+ "Call this first to understand the database structure before writing any SQL queries."
@@ -27,7 +36,7 @@ public async Task GetDatabaseSchema()
return "## Workflow\n"
+ "1. Review the database schema below\n"
+ "2. Construct a SQL SELECT query to answer the user's question\n"
- + "3. Execute the query using the ExecuteQuery tool\n"
+ + "3. Execute the query using the query execution tool\n"
+ "4. If you cannot construct a valid query, use ReportFailure\n\n"
+ "## Query Rules\n"
+ "- Only SELECT queries are allowed\n"
@@ -46,17 +55,25 @@ public async Task ExecuteQuery(
[Description("The SQL SELECT query to execute")] string sqlQuery
)
{
- var result = await _queryExecutor.Execute(sqlQuery);
+ return await ExecuteAndCapture(sqlQuery, description: null);
+ }
- return JsonConvert.SerializeObject(
- new
- {
- result.Success,
- result.RowCount,
- result.Data,
- result.ErrorMessage,
- }
- );
+ [Description(
+ "Executes a SQL SELECT query against the database and returns the results as JSON. "
+ + "Alongside the SQL, provide a short description of the result set so the host "
+ + "application can present the rows without re-interpreting them."
+ )]
+ public async Task ExecuteQueryWithDescription(
+ [Description("The SQL SELECT query to execute")] string sqlQuery,
+ [Description(
+ "One or two sentences describing what the result rows are: what each column "
+ + "means, the filters and period applied, and any caveat (e.g. partial year, "
+ + "credit notes excluded)."
+ )]
+ string resultDescription
+ )
+ {
+ return await ExecuteAndCapture(sqlQuery, resultDescription);
}
[Description(
@@ -68,4 +85,32 @@ public string ReportFailure(
{
return JsonConvert.SerializeObject(new { Success = false, Reason = reason });
}
+
+ // Shared execution path: runs the query, records the capture on success (a query
+ // with zero rows is still a successful answer — "no data matches"), and serializes
+ // the result for the model exactly as before.
+ private async Task ExecuteAndCapture(string sqlQuery, string description)
+ {
+ var result = await _queryExecutor.Execute(sqlQuery);
+
+ if (result.Success)
+ {
+ LastSuccessfulResult = new CapturedQueryResult
+ {
+ Sql = result.ExecutedSql,
+ Data = result.Data,
+ Description = description,
+ };
+ }
+
+ return JsonConvert.SerializeObject(
+ new
+ {
+ result.Success,
+ result.RowCount,
+ result.Data,
+ result.ErrorMessage,
+ }
+ );
+ }
}
diff --git a/src/Equibles.AgentQL.MicrosoftAI/CapturedQueryResult.cs b/src/Equibles.AgentQL.MicrosoftAI/CapturedQueryResult.cs
new file mode 100644
index 0000000..a75a9bf
--- /dev/null
+++ b/src/Equibles.AgentQL.MicrosoftAI/CapturedQueryResult.cs
@@ -0,0 +1,25 @@
+namespace Equibles.AgentQL.MicrosoftAI;
+
+///
+/// Snapshot of the last successful query executed through ,
+/// exposed via so host applications can
+/// hand verified rows to an orchestrator (for charts, structured replies, …) instead of
+/// re-parsing the model's prose.
+///
+public class CapturedQueryResult
+{
+ /// The SQL statement that produced the rows.
+ public string Sql { get; init; }
+
+ /// The result rows exactly as returned to the model.
+ public List> Data { get; init; }
+
+ /// Number of rows in .
+ public int RowCount => Data?.Count ?? 0;
+
+ ///
+ /// The model-authored description of what the rows are (columns, filters, caveats).
+ /// Null when no description was supplied with the query.
+ ///
+ public string Description { get; init; }
+}
diff --git a/src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs b/src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs
index 04a3b5f..7a782e5 100644
--- a/src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs
+++ b/src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs
@@ -13,8 +13,8 @@ namespace Equibles.AgentQL.MicrosoftAI;
/// The underlying FunctionInvokingChatClient ends the tool loop the
/// moment the model stops calling tools — including when it gives up with an
/// empty message after a failed query. This client wraps that loop and inspects
-/// the terminal turn: if the model answered nothing, or stopped right after an
-/// ExecuteQuery error without retrying or calling ReportFailure,
+/// the terminal turn: if the model answered nothing, or stopped right after a
+/// query-execution error without retrying or calling ReportFailure,
/// it injects a <system-reminder> message — restating the question
/// and telling the model to read the error, fix the SQL and retry, or report a
/// failure — then re-runs the loop, up to
@@ -30,6 +30,9 @@ namespace Equibles.AgentQL.MicrosoftAI;
public class SelfCorrectingChatClient : DelegatingChatClient
{
public const string ExecuteQueryToolName = nameof(AgentQLPlugin.ExecuteQuery);
+ public const string ExecuteQueryWithDescriptionToolName = nameof(
+ AgentQLPlugin.ExecuteQueryWithDescription
+ );
public const string ReportFailureToolName = nameof(AgentQLPlugin.ReportFailure);
// Copied out of the options object so a singleton instance is never affected
@@ -96,8 +99,8 @@ public override async IAsyncEnumerable GetStreamingResponseA
///
/// A turn is unresolved when the agent stopped without a usable result: an
- /// empty answer, or a non-empty answer when every ExecuteQuery the
- /// agent ran errored (so the answer can't be grounded in data). If any query
+ /// empty answer, or a non-empty answer when every query execution (either
+ /// variant) errored (so the answer can't be grounded in data). If any query
/// succeeded the answer is trusted, even if a later refining query failed. An
/// explicit ReportFailure is a valid terminal and is always resolved.
///
@@ -134,7 +137,15 @@ IReadOnlyList messages
var executeCallIds = new HashSet(StringComparer.Ordinal);
foreach (var call in messages.SelectMany(m => m.Contents).OfType())
{
- if (string.Equals(call.Name, ExecuteQueryToolName, StringComparison.Ordinal))
+ // Both execute variants ground an answer in data; hosts expose one or the other.
+ if (
+ string.Equals(call.Name, ExecuteQueryToolName, StringComparison.Ordinal)
+ || string.Equals(
+ call.Name,
+ ExecuteQueryWithDescriptionToolName,
+ StringComparison.Ordinal
+ )
+ )
executeCallIds.Add(call.CallId);
}
@@ -186,9 +197,9 @@ private static ChatMessage BuildReminder(string question)
var reminder =
"\n"
+ "You ended your turn without answering and without reporting a failure.\n"
- + "If your last ExecuteQuery call returned an error, read its ErrorMessage, "
+ + "If your last query execution returned an error, read its ErrorMessage, "
+ "call GetDatabaseSchema again if needed to verify the exact table and column "
- + "names, fix the SQL, and call ExecuteQuery again.\n"
+ + "names, fix the SQL, and execute the query again.\n"
+ "If you have already tried several queries and none work, call ReportFailure "
+ "with a short reason.\n"
+ "Never reply with an empty message."
diff --git a/tests/Equibles.AgentQL.UnitTests/AgentQLPluginTests.cs b/tests/Equibles.AgentQL.UnitTests/AgentQLPluginTests.cs
index ba09b32..62aad4c 100644
--- a/tests/Equibles.AgentQL.UnitTests/AgentQLPluginTests.cs
+++ b/tests/Equibles.AgentQL.UnitTests/AgentQLPluginTests.cs
@@ -68,4 +68,90 @@ public void ReportFailure_ReturnsStructuredReason()
parsed["Success"]!.Value().Should().BeFalse();
parsed["Reason"]!.Value().Should().Be("no matching table");
}
+
+ [Fact]
+ public async Task ExecuteQueryWithDescription_SerializesSameJsonAsExecuteQuery()
+ {
+ var data = new List> { new() { ["Name"] = "Paris" } };
+ _queryExecutor
+ .Execute("SELECT Name FROM Destinations")
+ .Returns(QueryResult.FromSuccess(data, "SELECT Name FROM Destinations"));
+
+ var json = await _sut.ExecuteQueryWithDescription(
+ "SELECT Name FROM Destinations",
+ "Destination names"
+ );
+
+ var parsed = JObject.Parse(json);
+ parsed["Success"]!.Value().Should().BeTrue();
+ parsed["RowCount"]!.Value().Should().Be(1);
+ parsed["Data"]![0]!["Name"]!.Value().Should().Be("Paris");
+ // The description is for the host, not the model — it must not leak into the JSON.
+ json.Should().NotContain("Destination names");
+ }
+
+ [Fact]
+ public async Task ExecuteQueryWithDescription_RecordsCaptureWithDescription()
+ {
+ var data = new List> { new() { ["Total"] = 5 } };
+ _queryExecutor
+ .Execute(Arg.Any())
+ .Returns(QueryResult.FromSuccess(data, "SELECT 5"));
+
+ await _sut.ExecuteQueryWithDescription("SELECT 5", "Yearly totals, VAT excluded");
+
+ _sut.LastSuccessfulResult.Should().NotBeNull();
+ _sut.LastSuccessfulResult.Sql.Should().Be("SELECT 5");
+ _sut.LastSuccessfulResult.Description.Should().Be("Yearly totals, VAT excluded");
+ _sut.LastSuccessfulResult.RowCount.Should().Be(1);
+ _sut.LastSuccessfulResult.Data.Should().BeSameAs(data);
+ }
+
+ [Fact]
+ public async Task ExecuteQuery_RecordsCaptureWithoutDescription()
+ {
+ var data = new List> { new() { ["Name"] = "Paris" } };
+ _queryExecutor
+ .Execute(Arg.Any())
+ .Returns(QueryResult.FromSuccess(data, "SELECT Name"));
+
+ await _sut.ExecuteQuery("SELECT Name");
+
+ _sut.LastSuccessfulResult.Should().NotBeNull();
+ _sut.LastSuccessfulResult.Description.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteQuery_EmptyResultSet_StillRecordsCapture()
+ {
+ // Zero rows is a successful answer ("no data matches"), not a failure.
+ _queryExecutor
+ .Execute(Arg.Any())
+ .Returns(QueryResult.FromSuccess([], "SELECT none"));
+
+ await _sut.ExecuteQuery("SELECT none");
+
+ _sut.LastSuccessfulResult.Should().NotBeNull();
+ _sut.LastSuccessfulResult.RowCount.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task ExecuteQuery_FailedQuery_DoesNotOverwritePriorCapture()
+ {
+ var data = new List> { new() { ["Name"] = "Paris" } };
+ _queryExecutor.Execute("SELECT good").Returns(QueryResult.FromSuccess(data, "SELECT good"));
+ _queryExecutor.Execute("SELECT bad").Returns(QueryResult.FromError("syntax error"));
+
+ await _sut.ExecuteQuery("SELECT good");
+ await _sut.ExecuteQuery("SELECT bad");
+
+ _sut.LastSuccessfulResult.Should().NotBeNull();
+ _sut.LastSuccessfulResult.Sql.Should().Be("SELECT good");
+ }
+
+ [Fact]
+ public void LastSuccessfulResult_BeforeAnyQuery_IsNull()
+ {
+ _sut.LastSuccessfulResult.Should().BeNull();
+ }
}
diff --git a/tests/Equibles.AgentQL.UnitTests/Fakes/FakeQueryTools.cs b/tests/Equibles.AgentQL.UnitTests/Fakes/FakeQueryTools.cs
index 1ba8aca..6d01427 100644
--- a/tests/Equibles.AgentQL.UnitTests/Fakes/FakeQueryTools.cs
+++ b/tests/Equibles.AgentQL.UnitTests/Fakes/FakeQueryTools.cs
@@ -35,6 +35,15 @@ public string ExecuteQuery([Description("The SQL query")] string sqlQuery)
);
}
+ [Description("Executes a SQL query and describes the result rows")]
+ public string ExecuteQueryWithDescription(
+ [Description("The SQL query")] string sqlQuery,
+ [Description("What the rows are")] string resultDescription
+ )
+ {
+ return ExecuteQuery(sqlQuery);
+ }
+
[Description("Reports a failure")]
public string ReportFailure([Description("The reason")] string reason)
{
@@ -47,6 +56,7 @@ public string ReportFailure([Description("The reason")] string reason)
public IList AsTools() =>
[
AIFunctionFactory.Create(ExecuteQuery),
+ AIFunctionFactory.Create(ExecuteQueryWithDescription),
AIFunctionFactory.Create(ReportFailure),
AIFunctionFactory.Create(GetDatabaseSchema),
];
diff --git a/tests/Equibles.AgentQL.UnitTests/SelfCorrectingChatClientTests.cs b/tests/Equibles.AgentQL.UnitTests/SelfCorrectingChatClientTests.cs
index e72d872..96039a2 100644
--- a/tests/Equibles.AgentQL.UnitTests/SelfCorrectingChatClientTests.cs
+++ b/tests/Equibles.AgentQL.UnitTests/SelfCorrectingChatClientTests.cs
@@ -117,6 +117,68 @@ public async Task GetResponse_AnswersOnFirstTry_DoesNotInjectReminder()
scripted.Reminders.Should().BeEmpty();
}
+ [Fact]
+ public async Task GetResponse_DescribedQuerySucceeds_CountsAsGroundedAnswer()
+ {
+ var tools = new FakeQueryTools();
+ var scripted = new ScriptedChatClient([
+ ScriptedChatClient.ToolCall(
+ "ExecuteQueryWithDescription",
+ "c1",
+ new Dictionary
+ {
+ ["sqlQuery"] = "SELECT good",
+ ["resultDescription"] = "Booking counts per destination",
+ }
+ ),
+ ScriptedChatClient.Say("DONE"),
+ ]);
+
+ var response = await Run(
+ BuildPipeline(scripted, new AgentQLSelfCorrectionOptions()),
+ tools.AsTools()
+ );
+
+ response.Text.Should().Be("DONE");
+ scripted.Reminders.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetResponse_DescribedQueryFails_StillNudgesToRetry()
+ {
+ var tools = new FakeQueryTools();
+ var scripted = new ScriptedChatClient([
+ ScriptedChatClient.ToolCall(
+ "ExecuteQueryWithDescription",
+ "c1",
+ new Dictionary
+ {
+ ["sqlQuery"] = "SELECT bad",
+ ["resultDescription"] = "Booking counts",
+ }
+ ),
+ ScriptedChatClient.Say("I give up."),
+ ScriptedChatClient.ToolCall(
+ "ExecuteQueryWithDescription",
+ "c2",
+ new Dictionary
+ {
+ ["sqlQuery"] = "SELECT good",
+ ["resultDescription"] = "Booking counts",
+ }
+ ),
+ ScriptedChatClient.Say("DONE"),
+ ]);
+
+ var response = await Run(
+ BuildPipeline(scripted, new AgentQLSelfCorrectionOptions()),
+ tools.AsTools()
+ );
+
+ response.Text.Should().Be("DONE");
+ scripted.Reminders.Should().HaveCount(1);
+ }
+
[Fact]
public async Task GetResponse_AnswerGroundedInEarlierSuccess_NotNudgedByLaterFailedQuery()
{