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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageVersion Include="HotChocolate.Types.Analyzers" Version="16.6.4" />
<PackageVersion Include="Macross.Json.Extensions" Version="3.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
Expand Down
6 changes: 4 additions & 2 deletions docs/design/agent-mode.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 => <typed query>)` 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 => <typed query>)` 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)).

Expand Down Expand Up @@ -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<TSelf>`** (done). Implemented by every generated client: its `GraphClient` and `WithRequestConfiguration`, so `AsAgentTools<TClient>` 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**

Expand Down
58 changes: 58 additions & 0 deletions src/Linq2GraphQL.Client.AI/AgentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,43 @@ public sealed class AgentOptions<TClient>
internal List<MemberReference> Omitted { get; } = [];
internal List<DescriptionOverride> Descriptions { get; } = [];
internal List<string> InstructionTexts { get; } = [];
internal List<ExampleDefinition<TClient>> Examples { get; } = [];
internal List<Action<AgentQueryLog>> QueryLoggers { get; } = [];

/// <summary>
/// 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.
/// </summary>
public Func<CancellationToken, ValueTask<string>> AccessToken { get; set; }

/// <summary>
/// An example of a common task, written with the typed client, for example
/// <c>o.Example("Recent orders", "A customer's most recent orders", c =&gt; c.Query.Customer(id: "4711").Select(...))</c>.
/// 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.
/// </summary>
public AgentOptions<TClient> Example<T, TResult>(string name, string description,
Func<TClient, GraphBaseExecute<T, TResult>> query)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(description);
ArgumentNullException.ThrowIfNull(query);
Examples.Add(new ExampleDefinition<TClient>(name, description, client => query(client).GetRequest()));
return this;
}

/// <summary>
/// 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.
/// </summary>
public AgentOptions<TClient> OnQuery(Action<AgentQueryLog> log)
{
ArgumentNullException.ThrowIfNull(log);
QueryLoggers.Add(log);
return this;
}

/// <summary>
/// Allows the agent to call a query, for example <c>o.AllowQuery(c =&gt; c.Query.Customers)</c>.
Expand Down Expand Up @@ -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<TClient>(string Name, string Description, Func<TClient, GraphQLRequest> Request);

/// <summary>
/// One query the agent ran or tried to run, for <see cref="AgentOptions{TClient}.OnQuery"/>.
/// </summary>
/// <param name="Query">The GraphQL text the agent wrote.</param>
/// <param name="Variables">The variables as JSON, or null.</param>
/// <param name="ValidationErrors">Why the query was rejected before sending; empty when it was sent.</param>
/// <param name="Errors">GraphQL or request errors from the server; empty when there were none.</param>
/// <param name="Duration">Time spent validating and, if sent, executing.</param>
public sealed record AgentQueryLog(
string Query,
string Variables,
IReadOnlyList<string> ValidationErrors,
IReadOnlyList<string> Errors,
TimeSpan Duration)
{
/// <summary>Whether the query passed validation and was sent.</summary>
public bool WasSent => ValidationErrors.Count == 0;
}
98 changes: 98 additions & 0 deletions src/Linq2GraphQL.Client.AI/AgentToolsExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Turns a generated client into agent tools: <c>graphql_query</c>, <c>describe_operation</c>,
/// <c>describe_type</c> and <c>search_schema</c>, as Microsoft.Extensions.AI <see cref="AIFunction"/>s that
/// plug into any agent built on it. The agent writes GraphQL; the tools keep it inside the configured scope.
/// </summary>
public static class AgentToolsExtensions
{
/// <summary>
/// The agent tools for a client generated with <c>--agent</c>, using the schema it was generated from.
/// Create them per user or conversation when <see cref="AgentOptions{TClient}.AccessToken"/> is set.
/// </summary>
/// <exception cref="AgentConfigurationException">The configuration does not match the schema.</exception>
public static IList<AIFunction> AsAgentTools<TClient>(this TClient client, Action<AgentOptions<TClient>> configure)
where TClient : IGraphClientSource<TClient>, IGraphSchemaSource
{
return client.AsAgentTools(TClient.Sdl, configure);
}

/// <summary>
/// The agent tools for any generated client, with the schema given as SDL, for example a schema file
/// exported from the server.
/// </summary>
/// <exception cref="AgentConfigurationException">The configuration does not match the schema.</exception>
public static IList<AIFunction> AsAgentTools<TClient>(this TClient client, string sdl,
Action<AgentOptions<TClient>> configure)
where TClient : IGraphClientSource<TClient>
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(sdl);
ArgumentNullException.ThrowIfNull(configure);

var options = new AgentOptions<TClient>();
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<AIFunction> 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.")
];
}
}
1 change: 1 addition & 0 deletions src/Linq2GraphQL.Client.AI/Linq2GraphQL.Client.AI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

<ItemGroup>
<PackageReference Include="GraphQL-Parser"/>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions"/>
</ItemGroup>

</Project>
96 changes: 96 additions & 0 deletions src/Linq2GraphQL.Client.AI/Schema/ExampleFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Text;
using System.Text.RegularExpressions;
using GraphQLParser;
using GraphQLParser.AST;
using GraphQLParser.Visitors;

namespace Linq2GraphQL.Client.AI.Schema;

/// <summary>
/// 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 (<c>$name_1_1</c> becomes <c>$name</c>) where that stays
/// unambiguous, and the document is re-printed on one line with regular spacing.
/// </summary>
internal static partial class ExampleFormatter
{
[GeneratedRegex(@"(_\d+)+$")]
private static partial Regex UniquenessSuffix();

public static (string Query, Dictionary<string, object> Variables) Tidy(string query,
IReadOnlyDictionary<string, object> variables)
{
var document = Parser.Parse(query);
var declared = document.Definitions.OfType<GraphQLOperationDefinition>()
.SelectMany(e => e.Variables?.Items ?? [])
.Select(e => e.Variable.Name.StringValue)
.ToList();

var renames = new Dictionary<string, string>();
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);
}

/// <summary>
/// Collapses whitespace to single spaces outside string literals.
/// </summary>
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();
}
}
34 changes: 34 additions & 0 deletions src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ internal sealed class QueryValidator(ScopedSchema schema)
private readonly SchemaModel model = schema.Schema;
private readonly SchemaShape shape = new(schema.Schema);

/// <summary>
/// The root fields a valid query selects, following fragments at the root.
/// </summary>
public static IReadOnlyList<string> RootFields(string query)
{
var document = Parser.Parse(query);
var fragments = document.Definitions.OfType<GraphQLFragmentDefinition>()
.ToDictionary(e => Name(e.FragmentName.Name));
var names = new List<string>();

void Collect(GraphQLSelectionSet selectionSet, HashSet<string> 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<GraphQLOperationDefinition>().Single().SelectionSet, []);
return names.Distinct().ToList();
}

public ValidationResult Validate(string query, JsonElement? variables = null)
{
var run = new Run(this);
Expand Down
7 changes: 7 additions & 0 deletions src/Linq2GraphQL.Client.AI/Schema/SchemaModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ internal sealed class SchemaType
public SchemaField Field(string name) => Fields.FirstOrDefault(e => e.Name == name);
}

/// <summary>
/// An example for a common task, as the agent sees it: GraphQL text, variables as JSON, and the root
/// operations it uses.
/// </summary>
internal sealed record AgentExample(string Name, string Description, string Query, string Variables,
IReadOnlyList<string> Operations);

internal sealed class SchemaModel
{
public required Dictionary<string, SchemaType> Types { get; init; }
Expand Down
10 changes: 10 additions & 0 deletions src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading