Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 56 additions & 11 deletions src/Equibles.AgentQL.MicrosoftAI/AgentQLPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public AgentQLPlugin(ISchemaProvider schemaProvider, IQueryExecutor queryExecuto
_queryExecutor = queryExecutor;
}

/// <summary>
/// The last successful query executed through this plugin instance (either
/// <see cref="ExecuteQuery"/> or <see cref="ExecuteQueryWithDescription"/>), 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.
/// </summary>
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."
Expand All @@ -27,7 +36,7 @@ public async Task<string> 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"
Expand All @@ -46,17 +55,25 @@ public async Task<string> 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<string> 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(
Expand All @@ -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<string> 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,
}
);
}
}
25 changes: 25 additions & 0 deletions src/Equibles.AgentQL.MicrosoftAI/CapturedQueryResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Equibles.AgentQL.MicrosoftAI;

/// <summary>
/// Snapshot of the last successful query executed through <see cref="AgentQLPlugin"/>,
/// exposed via <see cref="AgentQLPlugin.LastSuccessfulResult"/> so host applications can
/// hand verified rows to an orchestrator (for charts, structured replies, …) instead of
/// re-parsing the model's prose.
/// </summary>
public class CapturedQueryResult
{
/// <summary>The SQL statement that produced the rows.</summary>
public string Sql { get; init; }

/// <summary>The result rows exactly as returned to the model.</summary>
public List<Dictionary<string, object>> Data { get; init; }

/// <summary>Number of rows in <see cref="Data"/>.</summary>
public int RowCount => Data?.Count ?? 0;

/// <summary>
/// The model-authored description of what the rows are (columns, filters, caveats).
/// Null when no description was supplied with the query.
/// </summary>
public string Description { get; init; }
}
25 changes: 18 additions & 7 deletions src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
/// The underlying <c>FunctionInvokingChatClient</c> 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
/// <c>ExecuteQuery</c> error without retrying or calling <c>ReportFailure</c>,
/// the terminal turn: if the model answered nothing, or stopped right after a
/// query-execution error without retrying or calling <c>ReportFailure</c>,
/// it injects a <c>&lt;system-reminder&gt;</c> 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
Expand All @@ -30,6 +30,9 @@
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
Expand Down Expand Up @@ -96,8 +99,8 @@

/// <summary>
/// A turn is unresolved when the agent stopped without a usable result: an
/// empty answer, or a non-empty answer when every <c>ExecuteQuery</c> 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 <c>ReportFailure</c> is a valid terminal and is always resolved.
/// </summary>
Expand Down Expand Up @@ -132,11 +135,19 @@
)
{
var executeCallIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var call in messages.SelectMany(m => m.Contents).OfType<FunctionCallContent>())
{
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);
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

if (executeCallIds.Count == 0)
return (false, false);
Expand Down Expand Up @@ -186,9 +197,9 @@
var reminder =
"<system-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."
Expand Down
86 changes: 86 additions & 0 deletions tests/Equibles.AgentQL.UnitTests/AgentQLPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,90 @@ public void ReportFailure_ReturnsStructuredReason()
parsed["Success"]!.Value<bool>().Should().BeFalse();
parsed["Reason"]!.Value<string>().Should().Be("no matching table");
}

[Fact]
public async Task ExecuteQueryWithDescription_SerializesSameJsonAsExecuteQuery()
{
var data = new List<Dictionary<string, object>> { 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<bool>().Should().BeTrue();
parsed["RowCount"]!.Value<int>().Should().Be(1);
parsed["Data"]![0]!["Name"]!.Value<string>().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<Dictionary<string, object>> { new() { ["Total"] = 5 } };
_queryExecutor
.Execute(Arg.Any<string>())
.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<Dictionary<string, object>> { new() { ["Name"] = "Paris" } };
_queryExecutor
.Execute(Arg.Any<string>())
.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<string>())
.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<Dictionary<string, object>> { 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();
}
}
10 changes: 10 additions & 0 deletions tests/Equibles.AgentQL.UnitTests/Fakes/FakeQueryTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -47,6 +56,7 @@ public string ReportFailure([Description("The reason")] string reason)
public IList<AITool> AsTools() =>
[
AIFunctionFactory.Create(ExecuteQuery),
AIFunctionFactory.Create(ExecuteQueryWithDescription),
AIFunctionFactory.Create(ReportFailure),
AIFunctionFactory.Create(GetDatabaseSchema),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>
{
["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<string, object>
{
["sqlQuery"] = "SELECT bad",
["resultDescription"] = "Booking counts",
}
),
ScriptedChatClient.Say("I give up."),
ScriptedChatClient.ToolCall(
"ExecuteQueryWithDescription",
"c2",
new Dictionary<string, object>
{
["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()
{
Expand Down
Loading