diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7f3eabce..227ce36d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/docs/design/agent-mode.md b/docs/design/agent-mode.md
index 172c13e1..b64e9f1d 100644
--- a/docs/design/agent-mode.md
+++ b/docs/design/agent-mode.md
@@ -1,6 +1,6 @@
# Linq2GraphQL Agent Mode Design
-**Status:** in progress. Plan steps 1-3 (the groundwork, SDL input and SDL output) are implemented. The AI package is being built in slices: (1) package skeleton and scoped schema, (2) renderer, (3) validator, (4) the four tools, (5) the `AsAgentTools` adapter, (6) sample and regression harness. Slices 1 to 3 are merged; slice 4 is in review. See [Plan](#plan-and-risks).
+**Status:** in progress. Plan steps 1-3 (the groundwork, SDL input and SDL output) are implemented. The AI package is being built in slices: (1) package skeleton and scoped schema, (2) renderer, (3) validator, (4) the four tools, (5) the `AsAgentTools` adapter, (6) sample and regression harness. Slices 1 to 4 are merged; slice 5 (the adapter) is in review. See [Plan](#plan-and-risks).
## Summary
@@ -239,7 +239,7 @@ Rules:
- **Descriptions overlay the schema.** `Describe` replaces the schema text, with an append option.
- **Fail fast.** Unknown names, or describing something omitted, throw when `AsAgentTools` runs.
- **Created per user or conversation**, so the token delegate captures who the agent acts for.
-- **Examples for common tasks.** `o.Example(name, description, c => )` maps an intent to a query, possibly across operations, written with the client as above. The compiler checks it, it is validated against the scoped schema at setup, and it is rendered to GraphQL (arguments as variables) for the Index and `describe_operation`, without a server call. A task that always runs the same way should be a dedicated tool.
+- **Examples for common tasks.** `o.Example(name, description, c => )` maps an intent to a query, possibly across operations, written with the client as above. The compiler checks it, it is validated against the scoped schema at setup, and it is rendered to GraphQL (arguments as variables) for the Index and `describe_operation`, without a server call. Before the agent sees an example, variables lose the client's uniqueness suffixes (`$name_1_1` becomes `$name`) where unambiguous and the query is printed on one line. A task that always runs the same way should be a dedicated tool.
- **Query logging.** `o.OnQuery(...)` sees every query, its status and, where available, the user's question.
- **Later:** bound arguments (`.Bind("tenantId", () => currentTenant)`, filled from context and hidden from the agent) and dedicated tools (`o.Tool`, see [Path to dedicated tools](#path-to-dedicated-tools)).
@@ -398,6 +398,8 @@ Subscriptions stay out entirely; they don't fit the request/response shape of to
- **Schema cache key.** The safe-mode schema cache is keyed only by base address (`GraphClient.cs`), so two clients against the same endpoint with different credentials share one cached schema, and `includeDeprecated` is ignored. Agent mode no longer depends on introspection, but this should be fixed regardless.
- **`IGraphSchemaSource` interface** with a static abstract `Sdl` property. Implemented by clients generated with `--agent`; it lets `AsAgentTools` require agent mode at compile time.
- **Raw execution** (done). `GraphClient.ExecuteAsync(query, variables)` runs a query written as GraphQL text and returns the whole `data` object as JSON, with GraphQL errors in the result rather than thrown. Generated clients expose their `GraphClient` property (on the concrete client only, like the `With…` methods), so the agent package executes through the same hooks as typed queries without `InternalsVisibleTo`.
+- **`IGraphClientSource`** (done). Implemented by every generated client: its `GraphClient` and `WithRequestConfiguration`, so `AsAgentTools` can execute through the client and derive a per-user client generically, without reflection. Additive: the generated client interface is unchanged.
+- **`GetRequest()`** (done) on typed queries: the request built without contacting the server (safe mode's schema check skipped), used to render examples when the tools are set up.
**Generator**
diff --git a/src/Linq2GraphQL.Client.AI/AgentOptions.cs b/src/Linq2GraphQL.Client.AI/AgentOptions.cs
index 5f84cee9..b121e4be 100644
--- a/src/Linq2GraphQL.Client.AI/AgentOptions.cs
+++ b/src/Linq2GraphQL.Client.AI/AgentOptions.cs
@@ -15,6 +15,43 @@ public sealed class AgentOptions
internal List Omitted { get; } = [];
internal List Descriptions { get; } = [];
internal List InstructionTexts { get; } = [];
+ internal List> Examples { get; } = [];
+ internal List> QueryLoggers { get; } = [];
+
+ ///
+ /// The access token for the user or conversation the agent acts for, asked for on every request, for
+ /// example an on-behalf-of token from your token acquisition library. Leave it unset to use the client's
+ /// own configuration.
+ ///
+ public Func> AccessToken { get; set; }
+
+ ///
+ /// An example of a common task, written with the typed client, for example
+ /// o.Example("Recent orders", "A customer's most recent orders", c => c.Query.Customer(id: "4711").Select(...)).
+ /// It is checked by the compiler, validated against the scope when the tools are set up, and shown to the
+ /// agent as GraphQL. A task that always runs the same way is better as a dedicated tool.
+ ///
+ public AgentOptions Example(string name, string description,
+ Func> query)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(description);
+ ArgumentNullException.ThrowIfNull(query);
+ Examples.Add(new ExampleDefinition(name, description, client => query(client).GetRequest()));
+ return this;
+ }
+
+ ///
+ /// Called for every query the agent runs or tries to run: the query, its variables, validation errors if it
+ /// was rejected, server errors and duration. Use it to see what the agent actually does; repeated queries
+ /// are candidates for dedicated tools.
+ ///
+ public AgentOptions OnQuery(Action log)
+ {
+ ArgumentNullException.ThrowIfNull(log);
+ QueryLoggers.Add(log);
+ return this;
+ }
///
/// Allows the agent to call a query, for example o.AllowQuery(c => c.Query.Customers).
@@ -151,3 +188,24 @@ public override string ToString() => Member != null ? $"{ClrType.Name}.{Member.N
}
internal sealed record DescriptionOverride(MemberReference Target, string Text, bool Append);
+
+internal sealed record ExampleDefinition(string Name, string Description, Func Request);
+
+///
+/// One query the agent ran or tried to run, for .
+///
+/// The GraphQL text the agent wrote.
+/// The variables as JSON, or null.
+/// Why the query was rejected before sending; empty when it was sent.
+/// GraphQL or request errors from the server; empty when there were none.
+/// Time spent validating and, if sent, executing.
+public sealed record AgentQueryLog(
+ string Query,
+ string Variables,
+ IReadOnlyList ValidationErrors,
+ IReadOnlyList Errors,
+ TimeSpan Duration)
+{
+ /// Whether the query passed validation and was sent.
+ public bool WasSent => ValidationErrors.Count == 0;
+}
diff --git a/src/Linq2GraphQL.Client.AI/AgentToolsExtensions.cs b/src/Linq2GraphQL.Client.AI/AgentToolsExtensions.cs
new file mode 100644
index 00000000..37f60ebf
--- /dev/null
+++ b/src/Linq2GraphQL.Client.AI/AgentToolsExtensions.cs
@@ -0,0 +1,98 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Linq2GraphQL.Client.AI.Schema;
+using Linq2GraphQL.Client.AI.Tools;
+using Microsoft.Extensions.AI;
+
+namespace Linq2GraphQL.Client.AI;
+
+///
+/// Turns a generated client into agent tools: graphql_query, describe_operation,
+/// describe_type and search_schema, as Microsoft.Extensions.AI s that
+/// plug into any agent built on it. The agent writes GraphQL; the tools keep it inside the configured scope.
+///
+public static class AgentToolsExtensions
+{
+ ///
+ /// The agent tools for a client generated with --agent, using the schema it was generated from.
+ /// Create them per user or conversation when is set.
+ ///
+ /// The configuration does not match the schema.
+ public static IList AsAgentTools(this TClient client, Action> configure)
+ where TClient : IGraphClientSource, IGraphSchemaSource
+ {
+ return client.AsAgentTools(TClient.Sdl, configure);
+ }
+
+ ///
+ /// The agent tools for any generated client, with the schema given as SDL, for example a schema file
+ /// exported from the server.
+ ///
+ /// The configuration does not match the schema.
+ public static IList AsAgentTools(this TClient client, string sdl,
+ Action> configure)
+ where TClient : IGraphClientSource
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sdl);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var options = new AgentOptions();
+ configure(options);
+
+ var schema = ScopedSchema.Create(SdlReader.Read(sdl), options);
+
+ // Everything the agent causes goes through this client, so the user's token applies to raw and
+ // typed queries alike.
+ var agentClient = options.AccessToken == null
+ ? client
+ : client.WithRequestConfiguration(RequestConfigurations.BearerToken(options.AccessToken));
+
+ schema.SetExamples(options.Examples.Select(example =>
+ {
+ var request = example.Request(agentClient);
+ var (query, tidyVariables) = ExampleFormatter.Tidy(request.Query, request.Variables);
+ var variables = tidyVariables is { Count: > 0 }
+ ? JsonSerializer.Serialize(tidyVariables, agentClient.GraphClient.SerializerOptions)
+ : null;
+ return (example.Name, example.Description, query, variables);
+ }));
+
+ var tools = new AgentToolset(schema, agentClient.GraphClient, options.QueryLoggers.ToList());
+ return Create(tools);
+ }
+
+ private static List Create(AgentToolset tools)
+ {
+ return
+ [
+ AIFunctionFactory.Create(
+ ([Description("A GraphQL query document, for example `query { customers { name } }`. Only queries are allowed.")]
+ string query,
+ [Description("Values for the variables the query declares, as a JSON object. Omit when there are none.")]
+ JsonElement? variables = null,
+ CancellationToken cancellationToken = default) =>
+ tools.QueryAsync(query, variables, cancellationToken),
+ "graphql_query",
+ "Runs a read-only GraphQL query and returns the data as JSON. The query is checked first; if it is " +
+ "invalid nothing is sent and the errors say how to fix it. Use describe_operation for an operation's " +
+ "arguments and a starter query, describe_type for a type's fields, and search_schema to find a name.\n\n" +
+ tools.Index),
+ AIFunctionFactory.Create(
+ ([Description("Names of the operations to describe, as listed under Queries.")] string[] names) =>
+ tools.DescribeOperations(names),
+ "describe_operation",
+ "Describes operations: arguments with filters and sorting, the return type, a starter query to adapt, " +
+ "and examples."),
+ AIFunctionFactory.Create(
+ ([Description("Names of the types to describe.")] string[] names) => tools.DescribeTypes(names),
+ "describe_type",
+ "Describes types in full: fields, arguments and descriptions, plus the types they use."),
+ AIFunctionFactory.Create(
+ ([Description("A word to search for in names and descriptions.")] string text) => tools.Search(text),
+ "search_schema",
+ "Finds operations, examples, types, fields and enum values by name or description, when you don't " +
+ "know what something is called.")
+ ];
+ }
+}
diff --git a/src/Linq2GraphQL.Client.AI/Linq2GraphQL.Client.AI.csproj b/src/Linq2GraphQL.Client.AI/Linq2GraphQL.Client.AI.csproj
index fe679878..f4d5b88a 100644
--- a/src/Linq2GraphQL.Client.AI/Linq2GraphQL.Client.AI.csproj
+++ b/src/Linq2GraphQL.Client.AI/Linq2GraphQL.Client.AI.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/Linq2GraphQL.Client.AI/Schema/ExampleFormatter.cs b/src/Linq2GraphQL.Client.AI/Schema/ExampleFormatter.cs
new file mode 100644
index 00000000..2e863ce1
--- /dev/null
+++ b/src/Linq2GraphQL.Client.AI/Schema/ExampleFormatter.cs
@@ -0,0 +1,96 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using GraphQLParser;
+using GraphQLParser.AST;
+using GraphQLParser.Visitors;
+
+namespace Linq2GraphQL.Client.AI.Schema;
+
+///
+/// Makes a query built by the typed client read like one a person would write, so the agent can copy and adapt
+/// it: variables lose the client's uniqueness suffixes ($name_1_1 becomes $name) where that stays
+/// unambiguous, and the document is re-printed on one line with regular spacing.
+///
+internal static partial class ExampleFormatter
+{
+ [GeneratedRegex(@"(_\d+)+$")]
+ private static partial Regex UniquenessSuffix();
+
+ public static (string Query, Dictionary Variables) Tidy(string query,
+ IReadOnlyDictionary variables)
+ {
+ var document = Parser.Parse(query);
+ var declared = document.Definitions.OfType()
+ .SelectMany(e => e.Variables?.Items ?? [])
+ .Select(e => e.Variable.Name.StringValue)
+ .ToList();
+
+ var renames = new Dictionary();
+ foreach (var name in declared)
+ {
+ var plain = UniquenessSuffix().Replace(name, "");
+ var clashes = declared.Count(e => UniquenessSuffix().Replace(e, "") == plain) > 1 ||
+ (plain != name && declared.Contains(plain));
+ if (plain != name && plain.Length > 0 && !clashes)
+ {
+ renames[name] = plain;
+ }
+ }
+
+ foreach (var (from, to) in renames)
+ {
+ query = Regex.Replace(query, $@"\${Regex.Escape(from)}\b", "$" + to);
+ }
+
+ var tidyVariables = variables?.ToDictionary(e => renames.GetValueOrDefault(e.Key, e.Key), e => e.Value);
+ var printed = new SDLPrinter().Print(Parser.Parse(query));
+ return (OneLine(printed), tidyVariables);
+ }
+
+ ///
+ /// Collapses whitespace to single spaces outside string literals.
+ ///
+ private static string OneLine(string text)
+ {
+ var result = new StringBuilder();
+ var inString = false;
+ var pendingSpace = false;
+
+ for (var i = 0; i < text.Length; i++)
+ {
+ var c = text[i];
+ if (inString)
+ {
+ result.Append(c);
+ if (c == '\\' && i + 1 < text.Length)
+ {
+ result.Append(text[++i]);
+ }
+ else if (c == '"')
+ {
+ inString = false;
+ }
+
+ continue;
+ }
+
+ if (char.IsWhiteSpace(c))
+ {
+ pendingSpace = true;
+ continue;
+ }
+
+ var spaceBefore = c == '{' || pendingSpace && c is not ('(' or ')' or ']' or ':' or ',');
+ if (spaceBefore && result.Length > 0 && result[^1] is not ('(' or '[' or '$' or ' '))
+ {
+ result.Append(' ');
+ }
+
+ result.Append(c);
+ inString = c == '"';
+ pendingSpace = c is ':' or ',';
+ }
+
+ return result.ToString();
+ }
+}
diff --git a/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
index ac81c725..c7f85a62 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
@@ -36,6 +36,40 @@ internal sealed class QueryValidator(ScopedSchema schema)
private readonly SchemaModel model = schema.Schema;
private readonly SchemaShape shape = new(schema.Schema);
+ ///
+ /// The root fields a valid query selects, following fragments at the root.
+ ///
+ public static IReadOnlyList RootFields(string query)
+ {
+ var document = Parser.Parse(query);
+ var fragments = document.Definitions.OfType()
+ .ToDictionary(e => Name(e.FragmentName.Name));
+ var names = new List();
+
+ void Collect(GraphQLSelectionSet selectionSet, HashSet seen)
+ {
+ foreach (var selection in selectionSet.Selections)
+ {
+ switch (selection)
+ {
+ case GraphQLField field when Name(field.Name) != "__typename":
+ names.Add(Name(field.Name));
+ break;
+ case GraphQLInlineFragment inline:
+ Collect(inline.SelectionSet, seen);
+ break;
+ case GraphQLFragmentSpread spread when fragments.TryGetValue(Name(spread.FragmentName.Name), out var fragment) &&
+ seen.Add(Name(spread.FragmentName.Name)):
+ Collect(fragment.SelectionSet, seen);
+ break;
+ }
+ }
+ }
+
+ Collect(document.Definitions.OfType().Single().SelectionSet, []);
+ return names.Distinct().ToList();
+ }
+
public ValidationResult Validate(string query, JsonElement? variables = null)
{
var run = new Run(this);
diff --git a/src/Linq2GraphQL.Client.AI/Schema/SchemaModel.cs b/src/Linq2GraphQL.Client.AI/Schema/SchemaModel.cs
index 6588da5b..dfd3009a 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/SchemaModel.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/SchemaModel.cs
@@ -88,6 +88,13 @@ internal sealed class SchemaType
public SchemaField Field(string name) => Fields.FirstOrDefault(e => e.Name == name);
}
+///
+/// An example for a common task, as the agent sees it: GraphQL text, variables as JSON, and the root
+/// operations it uses.
+///
+internal sealed record AgentExample(string Name, string Description, string Query, string Variables,
+ IReadOnlyList Operations);
+
internal sealed class SchemaModel
{
public required Dictionary Types { get; init; }
diff --git a/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs b/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
index cdfbe9f5..d3e756f0 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
@@ -52,6 +52,16 @@ public static string Render(ScopedSchema schema, Detail detail)
RenderRoot(text, root, shape, detail);
}
+ if (schema.Examples.Count > 0)
+ {
+ text.AppendLine();
+ text.AppendLine("Examples (describe_operation shows their queries):");
+ foreach (var example in schema.Examples)
+ {
+ text.Append(Indent).AppendLine($"{example.Name} – {example.Description}");
+ }
+ }
+
var types = schema.Schema.Types.Values
.Where(e => e.Name != schema.Schema.QueryType && IsListed(e, shape))
.OrderBy(e => KindOrder(e.Kind))
diff --git a/src/Linq2GraphQL.Client.AI/Schema/ScopedSchema.cs b/src/Linq2GraphQL.Client.AI/Schema/ScopedSchema.cs
index 7ec886e9..0f3b0b88 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/ScopedSchema.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/ScopedSchema.cs
@@ -21,6 +21,44 @@ private ScopedSchema(SchemaModel schema, IReadOnlyList instructions)
public IReadOnlyList Instructions { get; }
+ ///
+ /// Examples for common tasks, already validated against this schema.
+ ///
+ public IReadOnlyList Examples { get; private set; } = [];
+
+ ///
+ /// Validates the examples against this schema and attaches them. Every problem is reported in one
+ /// exception, so a broken example surfaces when the tools are set up, not when the agent copies it.
+ ///
+ internal void SetExamples(IEnumerable<(string Name, string Description, string Query, string Variables)> examples)
+ {
+ var validator = new QueryValidator(this);
+ var list = new List();
+ var errors = new List();
+
+ foreach (var (name, description, query, variables) in examples)
+ {
+ var result = validator.Validate(query,
+ variables == null ? null : System.Text.Json.JsonDocument.Parse(variables).RootElement);
+ if (!result.IsValid)
+ {
+ errors.AddRange(result.Errors.Select(e => $"Example \"{name}\": {e}"));
+ continue;
+ }
+
+ list.Add(new AgentExample(name, description, query, variables, QueryValidator.RootFields(query)));
+ }
+
+ if (errors.Count > 0)
+ {
+ throw new AgentConfigurationException(
+ "An example does not match the agent's scope:" + Environment.NewLine +
+ string.Join(Environment.NewLine, errors.Select(e => "- " + e)));
+ }
+
+ Examples = list;
+ }
+
public static ScopedSchema Create(string sdl, Action> configure)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sdl);
diff --git a/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs b/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs
index a23070d0..d0fdec41 100644
--- a/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs
+++ b/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs
@@ -25,11 +25,13 @@ internal sealed class AgentToolset
private readonly QueryValidator validator;
private readonly SchemaShape shape;
private readonly SchemaModel model;
+ private readonly IReadOnlyList> queryLoggers;
- public AgentToolset(ScopedSchema schema, GraphClient client)
+ public AgentToolset(ScopedSchema schema, GraphClient client, IReadOnlyList> queryLoggers = null)
{
this.schema = schema;
this.client = client;
+ this.queryLoggers = queryLoggers ?? [];
validator = new QueryValidator(schema);
shape = new SchemaShape(schema.Schema);
model = schema.Schema;
@@ -48,12 +50,15 @@ public AgentToolset(ScopedSchema schema, GraphClient client)
public async Task QueryAsync(string query, JsonElement? variables = null,
CancellationToken cancellationToken = default)
{
+ var started = System.Diagnostics.Stopwatch.StartNew();
var validation = validator.Validate(query, variables);
if (!validation.IsValid)
{
+ var validationErrors = validation.Errors.Select(e => e.ToString()).ToList();
+ Log(query, variables, validationErrors, [], started.Elapsed);
return JsonSerializer.Serialize(new
{
- validationErrors = validation.Errors.Select(e => e.ToString()),
+ validationErrors,
note = "The query was not sent. Fix every error listed and try again."
}, ResultJson);
}
@@ -61,6 +66,7 @@ public async Task QueryAsync(string query, JsonElement? variables = null
try
{
var result = await client.ExecuteAsync(query, Variables(variables), cancellationToken);
+ Log(query, variables, [], result.Errors?.Select(e => e.Message).ToList() ?? [], started.Elapsed);
return JsonSerializer.Serialize(new
{
data = result.Data.ValueKind == JsonValueKind.Undefined ? (JsonElement?)null : result.Data,
@@ -69,10 +75,36 @@ public async Task QueryAsync(string query, JsonElement? variables = null
}
catch (GraphQueryRequestException ex)
{
+ Log(query, variables, [], [ex.Message], started.Elapsed);
return JsonSerializer.Serialize(new { requestError = ex.Message }, ResultJson);
}
}
+ ///
+ /// Reports a query to the OnQuery loggers. A failing logger must not break the agent's call.
+ ///
+ private void Log(string query, JsonElement? variables, IReadOnlyList validationErrors,
+ IReadOnlyList errors, TimeSpan duration)
+ {
+ if (queryLoggers.Count == 0)
+ {
+ return;
+ }
+
+ var entry = new AgentQueryLog(query, variables?.GetRawText(), validationErrors, errors, duration);
+ foreach (var logger in queryLoggers)
+ {
+ try
+ {
+ logger(entry);
+ }
+ catch (Exception)
+ {
+ // Logging is best effort; the agent's query result matters more.
+ }
+ }
+ }
+
///
/// describe_operation: everything needed to call each named operation, including a starter query.
///
@@ -160,7 +192,7 @@ public string DescribeTypes(IEnumerable names)
}
///
- /// search_schema: operations, types, fields and enum values matching the text, names before descriptions.
+ /// search_schema: operations, examples, types, fields and enum values matching the text, names before descriptions.
///
public string Search(string text)
{
@@ -186,6 +218,11 @@ void Consider(int kind, string name, string description, string line)
}
}
+ foreach (var example in schema.Examples)
+ {
+ Consider(1, example.Name, example.Description, $"example {example.Name} – {example.Description}");
+ }
+
foreach (var operation in schema.QueryType.Fields)
{
Consider(0, operation.Name, operation.Description,
@@ -194,17 +231,17 @@ void Consider(int kind, string name, string description, string line)
foreach (var type in model.Types.Values.Where(e => e.Name != model.QueryType && SchemaRenderer.IsListed(e, shape)))
{
- Consider(1, type.Name, type.Description, $"type {type.Name}{Summary(type.Description)}");
+ Consider(2, type.Name, type.Description, $"type {type.Name}{Summary(type.Description)}");
foreach (var field in type.Fields)
{
- Consider(2, field.Name, field.Description,
+ Consider(3, field.Name, field.Description,
$"field {type.Name}.{field.Name}: {SchemaRenderer.TypeText(field.Type, shape)}{Summary(field.Description)}");
}
foreach (var value in type.EnumValues)
{
- Consider(3, value.Name, value.Description, $"value {type.Name}.{value.Name}{Summary(value.Description)}");
+ Consider(4, value.Name, value.Description, $"value {type.Name}.{value.Name}{Summary(value.Description)}");
}
}
@@ -273,6 +310,21 @@ private void DescribeOperation(StringBuilder text, SchemaField operation)
text.AppendLine("Starter query:");
text.AppendLine(" " + StarterQuery(operation));
+
+ var examples = schema.Examples.Where(e => e.Operations.Contains(operation.Name)).ToList();
+ if (examples.Count > 0)
+ {
+ text.AppendLine("Examples:");
+ foreach (var example in examples)
+ {
+ text.AppendLine($" {example.Name} – {example.Description}");
+ text.AppendLine(" " + example.Query.ReplaceLineEndings(" ").Trim());
+ if (example.Variables != null)
+ {
+ text.AppendLine(" variables: " + example.Variables);
+ }
+ }
+ }
}
///
diff --git a/src/Linq2GraphQL.Client.AI/packages.lock.json b/src/Linq2GraphQL.Client.AI/packages.lock.json
index 0cfeeeef..b5596a06 100644
--- a/src/Linq2GraphQL.Client.AI/packages.lock.json
+++ b/src/Linq2GraphQL.Client.AI/packages.lock.json
@@ -8,6 +8,12 @@
"resolved": "9.5.1",
"contentHash": "pUYpupTsVyNcKemko8f0iknJa2z/oZsfzwb4crgEFN9/pKf3k35gxVXDDOkD3NVPF1ea6GUV0jgxkjCozFj7Fg=="
},
+ "Microsoft.Extensions.AI.Abstractions": {
+ "type": "Direct",
+ "requested": "[10.10.0, )",
+ "resolved": "10.10.0",
+ "contentHash": "frBHj2ckpevA+KdDLikxv8oxGPrJiyTvaiblYp+vtVEt0q6J73XP5m2SICm86nFK6rl+L1GCKxIy4S5Lb3p2kQ=="
+ },
"Nerdbank.GitVersioning": {
"type": "Direct",
"requested": "[3.8.118, )",
diff --git a/src/Linq2GraphQL.Client/GraphBaseExecute.cs b/src/Linq2GraphQL.Client/GraphBaseExecute.cs
index 6ea4a0d1..eac8a7cb 100644
--- a/src/Linq2GraphQL.Client/GraphBaseExecute.cs
+++ b/src/Linq2GraphQL.Client/GraphBaseExecute.cs
@@ -85,6 +85,35 @@ public async Task GetRequestAsJsonAsync()
}
+ ///
+ /// The request this query would send, built without contacting the server: safe mode's schema check is
+ /// skipped, so automatically included fields are not validated against the live schema. Use it to show or
+ /// log a query; builds the request the query actually sends.
+ ///
+ public GraphQLRequest GetRequest()
+ {
+ _lock.Wait();
+ try
+ {
+ if (!intialized)
+ {
+ QueryNode.SetAllUniqueVariableNames();
+ QueryNode.AddPrimitiveChildren(true, null);
+ intialized = true;
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ return new GraphQLRequest
+ {
+ Query = GetGraphQLQuery(),
+ Variables = QueryNode.GetAllActiveArguments().ToDictionary(x => x.VariableName, e => e.Value)
+ };
+ }
+
public async Task GetRequestAsync()
{
await InitQueryAsync();
diff --git a/src/Linq2GraphQL.Client/IGraphClientSource.cs b/src/Linq2GraphQL.Client/IGraphClientSource.cs
new file mode 100644
index 00000000..e44e3e10
--- /dev/null
+++ b/src/Linq2GraphQL.Client/IGraphClientSource.cs
@@ -0,0 +1,13 @@
+namespace Linq2GraphQL.Client;
+
+///
+/// Implemented by every generated client: access to its , and a way to derive a
+/// client of the same type with an extra request hook. Lets other packages work with any generated client
+/// generically, for example to execute queries with a per-user token.
+///
+public interface IGraphClientSource where TSelf : IGraphClientSource
+{
+ GraphClient GraphClient { get; }
+
+ TSelf WithRequestConfiguration(Func configureRequest);
+}
diff --git a/src/Linq2GraphQL.Generator/Templates/Client/ClientTemplate.tt.cs b/src/Linq2GraphQL.Generator/Templates/Client/ClientTemplate.tt.cs
index d1aa8352..48826d4e 100644
--- a/src/Linq2GraphQL.Generator/Templates/Client/ClientTemplate.tt.cs
+++ b/src/Linq2GraphQL.Generator/Templates/Client/ClientTemplate.tt.cs
@@ -10,7 +10,9 @@ public partial class ClientTemplate(
bool nullable,
bool agent = false)
{
- private string Interfaces => agent ? $"I{name}, IGraphSchemaSource" : $"I{name}";
+ private string Interfaces => agent
+ ? $"I{name}, IGraphClientSource<{name}>, IGraphSchemaSource"
+ : $"I{name}, IGraphClientSource<{name}>";
private bool includeQuery => queryType != null;
private bool includeMutation => mutationType != null;
diff --git a/test/Linq2GraphQL.TestClient/Generated/Client/SampleClient.cs b/test/Linq2GraphQL.TestClient/Generated/Client/SampleClient.cs
index 483ca013..a5548d7f 100644
--- a/test/Linq2GraphQL.TestClient/Generated/Client/SampleClient.cs
+++ b/test/Linq2GraphQL.TestClient/Generated/Client/SampleClient.cs
@@ -18,7 +18,7 @@ namespace Linq2GraphQL.TestClient;
/// Provides strongly-typed access to GraphQL queries, mutations, and subscriptions.
/// Supports dependency injection for better testability and flexibility.
///
-public class SampleClient : ISampleClient, IGraphSchemaSource
+public class SampleClient : ISampleClient, IGraphClientSource, IGraphSchemaSource
{
private readonly GraphClient client;
diff --git a/test/Linq2GraphQL.TestClientNullable/Generated/Client/SampleNullableClient.cs b/test/Linq2GraphQL.TestClientNullable/Generated/Client/SampleNullableClient.cs
index 9123707a..d463ca94 100644
--- a/test/Linq2GraphQL.TestClientNullable/Generated/Client/SampleNullableClient.cs
+++ b/test/Linq2GraphQL.TestClientNullable/Generated/Client/SampleNullableClient.cs
@@ -18,7 +18,7 @@ namespace Linq2GraphQL.TestClientNullable;
/// Provides strongly-typed access to GraphQL queries, mutations, and subscriptions.
/// Supports dependency injection for better testability and flexibility.
///
-public class SampleNullableClient : ISampleNullableClient, IGraphSchemaSource
+public class SampleNullableClient : ISampleNullableClient, IGraphClientSource, IGraphSchemaSource
{
private readonly GraphClient client;
diff --git a/test/Linq2GraphQL.Tests/AgentToolsExtensionsTests.cs b/test/Linq2GraphQL.Tests/AgentToolsExtensionsTests.cs
new file mode 100644
index 00000000..6b1a5a95
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/AgentToolsExtensionsTests.cs
@@ -0,0 +1,177 @@
+using System.Text.Json;
+using Linq2GraphQL.Client;
+using Linq2GraphQL.Client.AI;
+using Linq2GraphQL.TestClient;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+
+namespace Linq2GraphQL.Tests;
+
+///
+/// Covers AsAgentTools: the four AIFunctions as an agent framework calls them, the per-user access token,
+/// query logging and typed examples, against the in-process test server.
+///
+public class AgentToolsExtensionsTests : IDisposable
+{
+ private readonly WebApplicationFactory application = new();
+ private readonly List authorizations = [];
+ private readonly SampleClient client;
+
+ public AgentToolsExtensionsTests()
+ {
+ var recorder = new RecordingHandler(authorizations) { InnerHandler = application.Server.CreateHandler() };
+ var httpClient = new HttpClient(recorder) { BaseAddress = new Uri("https://localhost:7184/graphql/") };
+ client = new SampleClient(httpClient, Options.Create(new GraphClientOptions()),
+ new ServiceCollection().AddMemoryCache().BuildServiceProvider());
+ }
+
+ public void Dispose() => application.Dispose();
+
+ private static AIFunction Tool(IList tools, string name) => tools.Single(e => e.Name == name);
+
+ private static async Task InvokeAsync(AIFunction tool, Dictionary arguments)
+ {
+ var result = await tool.InvokeAsync(new AIFunctionArguments(arguments));
+ return result is JsonElement { ValueKind: JsonValueKind.String } text ? text.GetString()! : result?.ToString() ?? "";
+ }
+
+ [Fact]
+ public void AsAgentTools_ReturnsTheFourTools()
+ {
+ var tools = client.AsAgentTools(o => o.AllowQuery(c => c.Query.Customers).Describe("All customers."));
+
+ tools.Select(e => e.Name).ShouldBe(["graphql_query", "describe_operation", "describe_type", "search_schema"]);
+ Tool(tools, "graphql_query").Description.ShouldContain("customers: [Customer] – All customers.");
+ Tool(tools, "graphql_query").JsonSchema.GetRawText().ShouldContain("\"query\"");
+ }
+
+ [Fact]
+ public async Task GraphqlQuery_RunsTheQuery()
+ {
+ var tools = client.AsAgentTools(o => o.AllowQuery(c => c.Query.Hello));
+
+ var result = await InvokeAsync(Tool(tools, "graphql_query"), new()
+ {
+ ["query"] = "query($n: String) { hello(name: $n) }",
+ ["variables"] = JsonDocument.Parse("""{ "n": "Agent" }""").RootElement
+ });
+
+ result.ShouldContain("Hello, Agent!");
+ }
+
+ [Fact]
+ public async Task DescribeAndSearchTools_Answer()
+ {
+ var tools = client.AsAgentTools(o => o.AllowQuery(c => c.Query.Customers));
+
+ (await InvokeAsync(Tool(tools, "describe_operation"), new() { ["names"] = new[] { "customers" } }))
+ .ShouldContain("Starter query:");
+ (await InvokeAsync(Tool(tools, "describe_type"), new() { ["names"] = new[] { "Customer" } }))
+ .ShouldContain("type Customer {");
+ (await InvokeAsync(Tool(tools, "search_schema"), new() { ["text"] = "customerName" }))
+ .ShouldContain("field Customer.customerName");
+ }
+
+ [Fact]
+ public async Task AccessToken_IsSentWithEveryQuery()
+ {
+ var tools = client.AsAgentTools(o =>
+ {
+ o.AllowQuery(c => c.Query.Hello);
+ o.AccessToken = _ => ValueTask.FromResult("user-token");
+ });
+
+ await InvokeAsync(Tool(tools, "graphql_query"), new() { ["query"] = "{ hello }" });
+
+ authorizations.ShouldBe(["Bearer user-token"]);
+ }
+
+ [Fact]
+ public async Task OnQuery_SeesSentAndRejectedQueries()
+ {
+ var logs = new List();
+ var tools = client.AsAgentTools(o =>
+ {
+ o.AllowQuery(c => c.Query.Hello);
+ o.OnQuery(logs.Add);
+ o.OnQuery(_ => throw new InvalidOperationException("A broken logger must not break the agent."));
+ });
+
+ var sent = await InvokeAsync(Tool(tools, "graphql_query"), new() { ["query"] = "{ hello }" });
+ await InvokeAsync(Tool(tools, "graphql_query"), new() { ["query"] = "{ customers { customerName } }" });
+
+ sent.ShouldContain("Hello, World!");
+ logs.Count.ShouldBe(2);
+ logs[0].WasSent.ShouldBeTrue();
+ logs[0].Query.ShouldBe("{ hello }");
+ logs[1].WasSent.ShouldBeFalse();
+ logs[1].ValidationErrors.ShouldContain(e => e.Contains("Query customers is not available"));
+ authorizations.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Examples_AreShownToTheAgent()
+ {
+ var tools = client.AsAgentTools(o =>
+ {
+ o.AllowQuery(c => c.Query.Customers);
+ o.AllowQuery(c => c.Query.Hello);
+ o.Example("Customer names", "The name and status of every customer",
+ c => c.Query.Customers().Select(e => e.Select(x => new { x.CustomerName, x.Status })));
+ o.Example("Greeting", "Greets a named person", c => c.Query.Hello("Jocke").Select());
+ });
+
+ var index = Tool(tools, "graphql_query").Description!;
+ index.ShouldContain("Examples (describe_operation shows their queries):");
+ index.ShouldContain("Customer names – The name and status of every customer");
+
+ var customers = await InvokeAsync(Tool(tools, "describe_operation"), new() { ["names"] = new[] { "customers" } });
+ customers.ShouldContain("Customer names – The name and status of every customer");
+ customers.ShouldContain("customerName");
+ customers.ShouldNotContain("Greeting");
+
+ var hello = await InvokeAsync(Tool(tools, "describe_operation"), new() { ["names"] = new[] { "hello" } });
+ hello.ShouldContain("Greeting – Greets a named person");
+ hello.ShouldContain("variables: {");
+ hello.ShouldContain("\"Jocke\"");
+
+ (await InvokeAsync(Tool(tools, "search_schema"), new() { ["text"] = "greeting" }))
+ .ShouldStartWith("example Greeting");
+ }
+
+ [Fact]
+ public void Example_OutsideTheScope_FailsAtSetup()
+ {
+ var ex = Should.Throw(() => client.AsAgentTools(o =>
+ {
+ o.AllowQuery(c => c.Query.Customers);
+ o.Omit(c => c.Status);
+ o.Example("Statuses", "Every customer's status",
+ c => c.Query.Customers().Select(e => e.Select(x => new { x.CustomerName, x.Status })));
+ }));
+
+ ex.Message.ShouldContain("Example \"Statuses\"");
+ ex.Message.ShouldContain("Customer has no field status");
+ }
+
+ [Fact]
+ public async Task ExplicitSdl_Works()
+ {
+ var tools = client.AsAgentTools("type Query { hello(name: String): String }", o => o.AllowAllQueries());
+
+ (await InvokeAsync(Tool(tools, "graphql_query"), new() { ["query"] = "{ hello }" })).ShouldContain("Hello, World!");
+ }
+
+ private sealed class RecordingHandler(List authorizations) : DelegatingHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ authorizations.Add(request.Headers.Authorization?.ToString());
+ return base.SendAsync(request, cancellationToken);
+ }
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/ExampleFormatterTests.cs b/test/Linq2GraphQL.Tests/ExampleFormatterTests.cs
new file mode 100644
index 00000000..1e063c10
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/ExampleFormatterTests.cs
@@ -0,0 +1,47 @@
+using Linq2GraphQL.Client.AI.Schema;
+using Shouldly;
+
+namespace Linq2GraphQL.Tests;
+
+///
+/// Covers how examples built by the typed client are tidied before the agent sees them.
+///
+public class ExampleFormatterTests
+{
+ [Fact]
+ public void Tidy_RemovesUniquenessSuffixesAndNormalisesSpacing()
+ {
+ var (query, variables) = ExampleFormatter.Tidy("query($name_1_1: String ){ hello(name:$name_1_1 ) }",
+ new Dictionary { ["name_1_1"] = "Jocke" });
+
+ query.ShouldBe("query($name: String) { hello(name: $name) }");
+ variables.ShouldBe(new Dictionary { ["name"] = "Jocke" });
+ }
+
+ [Fact]
+ public void Tidy_KeepsSuffixesWhenPlainNamesWouldClash()
+ {
+ var (query, variables) = ExampleFormatter.Tidy(
+ "query($first_1_1: Int, $first_2_1: Int) { a: orders(first: $first_1_1) { totalCount } b: orders(first: $first_2_1) { totalCount } }",
+ new Dictionary { ["first_1_1"] = 1, ["first_2_1"] = 2 });
+
+ query.ShouldBe("query($first_1_1: Int, $first_2_1: Int) { a: orders(first: $first_1_1) { totalCount } b: orders(first: $first_2_1) { totalCount } }");
+ variables.Keys.ShouldBe(["first_1_1", "first_2_1"]);
+ }
+
+ [Fact]
+ public void Tidy_LeavesStringLiteralsAlone()
+ {
+ var (query, _) = ExampleFormatter.Tidy("{ hello(name: \"two spaces\") }", null);
+
+ query.ShouldBe("{ hello(name: \"two spaces\") }");
+ }
+
+ [Fact]
+ public void Tidy_PrintsNestedSelectionsOnOneLine()
+ {
+ var (query, _) = ExampleFormatter.Tidy("query{\n customers {\n customerName\n status }\n }", null);
+
+ query.ShouldBe("{ customers { customerName status } }");
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/packages.lock.json b/test/Linq2GraphQL.Tests/packages.lock.json
index 06fc8708..a37a9b2f 100644
--- a/test/Linq2GraphQL.Tests/packages.lock.json
+++ b/test/Linq2GraphQL.Tests/packages.lock.json
@@ -887,7 +887,8 @@
"type": "Project",
"dependencies": {
"GraphQL-Parser": "[9.5.1, )",
- "Linq2GraphQL.Client": "[1.0.0, )"
+ "Linq2GraphQL.Client": "[1.0.0, )",
+ "Microsoft.Extensions.AI.Abstractions": "[10.10.0, )"
}
},
"linq2graphql.client.subscriptions": {
@@ -1005,6 +1006,12 @@
"resolved": "3.0.0",
"contentHash": "AkNshs6dopj8FXsmkkJxvLivN2SyDJQDbjcds5lo9+Y6L4zpcoXdmzXQ3VVN+AIWQr0CTD5A7vkuHGAr2aypZg=="
},
+ "Microsoft.Extensions.AI.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.10.0, )",
+ "resolved": "10.10.0",
+ "contentHash": "frBHj2ckpevA+KdDLikxv8oxGPrJiyTvaiblYp+vtVEt0q6J73XP5m2SICm86nFK6rl+L1GCKxIy4S5Lb3p2kQ=="
+ },
"Microsoft.Extensions.Caching.Memory": {
"type": "CentralTransitive",
"requested": "[10.0.11, )",