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() {