diff --git a/docs/design/agent-mode.md b/docs/design/agent-mode.md
index 7982df69..172c13e1 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 and 2 are merged; slice 3 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 3 are merged; slice 4 is in review. See [Plan](#plan-and-risks).
## Summary
@@ -286,7 +286,7 @@ Starter query:
Examples: those that use this operation, e.g. "Unpaid invoices"
```
-The starter query is generated from the scoped schema at no cost, and next to developer examples it is probably the strongest single lever for query quality. `describe_operation` also covers mutations later, so it needs no redesign when `graphql_mutate` arrives.
+The starter query is generated from the scoped schema at no cost: required arguments become variables, and paged operations get no page size, because the schema does not publish the server's maximum (the server then uses its default). Every starter query is tested against the test server. Next to developer examples, it is probably the strongest single lever for query quality. `describe_operation` also covers mutations later, so it needs no redesign when `graphql_mutate` arrives.
**All four tools are in the first version.** They work only on the scoped schema, and the three schema tools answer locally with no server calls. Large schemas are common, so the agent explores on demand: the Index lists only operations, types and examples, and details come from the describe and search tools. Small schemas can raise the inline level to Outline or Full.
diff --git a/src/Linq2GraphQL.Client.AI/Schema/NameSuggestions.cs b/src/Linq2GraphQL.Client.AI/Schema/NameSuggestions.cs
new file mode 100644
index 00000000..c7e3d48b
--- /dev/null
+++ b/src/Linq2GraphQL.Client.AI/Schema/NameSuggestions.cs
@@ -0,0 +1,55 @@
+namespace Linq2GraphQL.Client.AI.Schema;
+
+///
+/// "Did you mean" suggestions and capped name lists, so every message to the agent says what it can use instead.
+///
+internal static class NameSuggestions
+{
+ private const int MaxListedNames = 15;
+
+ ///
+ /// " Did you mean `x`?" for a near miss, followed by the available names.
+ ///
+ public static string Suggest(string name, IEnumerable candidates)
+ {
+ var list = candidates.ToList();
+ var closest = Closest(name, list);
+ var suggestion = closest == null ? "" : $" Did you mean `{closest}`?";
+ return list.Count == 0 ? suggestion : $"{suggestion} Available: {List(list)}.";
+ }
+
+ public static string Closest(string name, IEnumerable candidates) =>
+ candidates
+ .Select(e => (Name: e, Distance: Distance(name.ToLowerInvariant(), e.ToLowerInvariant())))
+ .Where(e => e.Distance <= Math.Max(2, name.Length / 3))
+ .OrderBy(e => e.Distance)
+ .Select(e => e.Name)
+ .FirstOrDefault();
+
+ public static string List(IEnumerable names)
+ {
+ var list = names.ToList();
+ return list.Count <= MaxListedNames
+ ? string.Join(", ", list)
+ : string.Join(", ", list.Take(MaxListedNames)) + $" and {list.Count - MaxListedNames} more";
+ }
+
+ private static int Distance(string a, string b)
+ {
+ var previous = Enumerable.Range(0, b.Length + 1).ToArray();
+ for (var i = 1; i <= a.Length; i++)
+ {
+ var current = new int[b.Length + 1];
+ current[0] = i;
+ for (var j = 1; j <= b.Length; j++)
+ {
+ var cost = a[i - 1] == b[j - 1] ? 0 : 1;
+ current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
+ }
+
+ previous = current;
+ }
+
+ return previous[b.Length];
+ }
+}
diff --git a/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
index 61ec7e25..ac81c725 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
@@ -31,7 +31,7 @@ internal sealed class ValidationResult(IReadOnlyList errors)
///
internal sealed class QueryValidator(ScopedSchema schema)
{
- private const int MaxListedNames = 15;
+
private readonly SchemaModel model = schema.Schema;
private readonly SchemaShape shape = new(schema.Schema);
@@ -111,7 +111,7 @@ private void ValidateVariableDefinitions(GraphQLOperationDefinition operation, J
if (model.Type(type.NamedType) is not { } namedType)
{
- Error(path, $"Type {type.NamedType} is not available.{Suggest(type.NamedType, InputTypeNames())}");
+ Error(path, $"Type {type.NamedType} is not available.{NameSuggestions.Suggest(type.NamedType, InputTypeNames())}");
continue;
}
@@ -273,7 +273,7 @@ private string MissingField(SchemaType parent, string name)
{
if (parent.Name == model.QueryType)
{
- return $"Query {name} is not available.{Suggest(name, parent.Fields.Select(e => e.Name))}";
+ return $"Query {name} is not available.{NameSuggestions.Suggest(name, parent.Fields.Select(e => e.Name))}";
}
if (shape.ConnectionOf(parent.Name) is { } connection)
@@ -286,7 +286,7 @@ private string MissingField(SchemaType parent, string name)
return parent.Kind == SchemaTypeKind.Union
? $"{parent.Name} is a union; select fields inside `... on {string.Join(" / ", parent.PossibleTypes)} {{ ... }}`."
- : $"{parent.Name} has no field {name}.{Suggest(name, parent.Fields.Select(e => e.Name))}";
+ : $"{parent.Name} has no field {name}.{NameSuggestions.Suggest(name, parent.Fields.Select(e => e.Name))}";
}
private void ValidateArguments(SchemaField definition, GraphQLField field, string path)
@@ -300,7 +300,7 @@ private void ValidateArguments(SchemaField definition, GraphQLField field, strin
if (argumentDefinition == null)
{
Error(argumentPath, $"{definition.Name} has no argument {name}." +
- Suggest(name, definition.Arguments.Select(e => e.Name)));
+ NameSuggestions.Suggest(name, definition.Arguments.Select(e => e.Name)));
continue;
}
@@ -397,10 +397,10 @@ private void ValidateValue(GraphQLValue value, TypeRef expected, string path)
break;
case SchemaTypeKind.Enum when value is GraphQLStringValue text:
Error(path, $"{type.Name} values are written without quotes, for example `{text.Value.ToString()}`." +
- Suggest(text.Value.ToString(), type.EnumValues.Select(e => e.Name)));
+ NameSuggestions.Suggest(text.Value.ToString(), type.EnumValues.Select(e => e.Name)));
break;
case SchemaTypeKind.Enum:
- Error(path, $"Expected a {type.Name} value: {Names(type.EnumValues.Select(e => e.Name))}.");
+ Error(path, $"Expected a {type.Name} value: {NameSuggestions.List(type.EnumValues.Select(e => e.Name))}.");
break;
case SchemaTypeKind.Scalar when !ScalarAccepts(type.Name, value):
Error(path, $"Expected a {type.Name} value.");
@@ -464,7 +464,7 @@ private void ValidateJson(JsonElement value, TypeRef expected, string path)
ValidateEnumValue(type, value.GetString(), path);
break;
case SchemaTypeKind.Enum:
- Error(path, $"Expected a {type.Name} value as a string: {Names(type.EnumValues.Select(e => e.Name))}.");
+ Error(path, $"Expected a {type.Name} value as a string: {NameSuggestions.List(type.EnumValues.Select(e => e.Name))}.");
break;
case SchemaTypeKind.Scalar when !ScalarAccepts(type.Name, value.ValueKind):
Error(path, $"Expected a {type.Name} value.");
@@ -477,7 +477,7 @@ private void ValidateInputField(SchemaType type, string name, string path, Actio
var fieldPath = Join(path, name);
if (type.Field(name) == null)
{
- Error(fieldPath, $"{type.Name} has no field {name}.{Suggest(name, type.Fields.Select(e => e.Name))}");
+ Error(fieldPath, $"{type.Name} has no field {name}.{NameSuggestions.Suggest(name, type.Fields.Select(e => e.Name))}");
return;
}
@@ -498,7 +498,7 @@ private void ValidateEnumValue(SchemaType type, string value, string path)
{
if (type.EnumValues.All(e => e.Name != value))
{
- Error(path, $"{type.Name} has no value {value}.{Suggest(value, type.EnumValues.Select(e => e.Name))}");
+ Error(path, $"{type.Name} has no value {value}.{NameSuggestions.Suggest(value, type.EnumValues.Select(e => e.Name))}");
}
}
@@ -555,49 +555,6 @@ private IEnumerable InputTypeNames() => model.Types.Values
private void Error(string path, string message) => Errors.Add(new ValidationError(path, message));
}
- ///
- /// " Did you mean `x`?" for a near miss, and the available names, so the agent can fix the query in one go.
- ///
- private static string Suggest(string name, IEnumerable candidates)
- {
- var list = candidates.ToList();
- var closest = list
- .Select(e => (Name: e, Distance: Distance(name.ToLowerInvariant(), e.ToLowerInvariant())))
- .Where(e => e.Distance <= Math.Max(2, name.Length / 3))
- .OrderBy(e => e.Distance)
- .Select(e => e.Name)
- .FirstOrDefault();
-
- var suggestion = closest == null ? "" : $" Did you mean `{closest}`?";
- return list.Count == 0 ? suggestion : $"{suggestion} Available: {Names(list)}.";
- }
-
- private static string Names(IEnumerable names)
- {
- var list = names.ToList();
- return list.Count <= MaxListedNames
- ? string.Join(", ", list)
- : string.Join(", ", list.Take(MaxListedNames)) + $" and {list.Count - MaxListedNames} more";
- }
-
- private static int Distance(string a, string b)
- {
- var previous = Enumerable.Range(0, b.Length + 1).ToArray();
- for (var i = 1; i <= a.Length; i++)
- {
- var current = new int[b.Length + 1];
- current[0] = i;
- for (var j = 1; j <= b.Length; j++)
- {
- var cost = a[i - 1] == b[j - 1] ? 0 : 1;
- current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
- }
-
- previous = current;
- }
-
- return previous[b.Length];
- }
private static string Join(string path, string name) => string.IsNullOrEmpty(path) ? name : $"{path}.{name}";
diff --git a/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs b/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
index c7e5b363..cdfbe9f5 100644
--- a/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
+++ b/src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
@@ -88,7 +88,7 @@ public static string Render(ScopedSchema schema, Detail detail)
/// The named types at Full detail, for describing them on demand. Plumbing types are explained by the
/// conventions instead, and unknown names are skipped.
///
- public static string RenderTypes(ScopedSchema schema, IEnumerable typeNames)
+ public static string RenderTypes(ScopedSchema schema, IEnumerable typeNames, Detail detail = Detail.Full)
{
var shape = new SchemaShape(schema.Schema);
var text = new StringBuilder();
@@ -97,21 +97,21 @@ public static string RenderTypes(ScopedSchema schema, IEnumerable typeNa
{
if (schema.Schema.Type(name) is { } type && IsListed(type, shape))
{
- RenderType(text, type, shape, Detail.Full, "");
+ RenderType(text, type, shape, detail, "");
}
}
return Finish(text);
}
- private static bool IsListed(SchemaType type, SchemaShape shape) =>
+ internal static bool IsListed(SchemaType type, SchemaShape shape) =>
!shape.IsPlumbing(type.Name) && !IsBuiltInScalar(type);
///
/// Types are grouped by kind, each group in schema order: the shapes the agent selects from first,
/// scalars last.
///
- private static int KindOrder(SchemaTypeKind kind) => kind switch
+ internal static int KindOrder(SchemaTypeKind kind) => kind switch
{
SchemaTypeKind.Interface => 0,
SchemaTypeKind.Object => 1,
@@ -325,7 +325,7 @@ private static string ArgumentText(SchemaArgument argument) =>
///
/// The type as the agent should read it: a connection becomes its node type marked @paged.
///
- private static string TypeText(TypeRef type, SchemaShape shape)
+ internal static string TypeText(TypeRef type, SchemaShape shape)
{
if (shape.ConnectionOf(type.NamedType) is not { } connection)
{
@@ -353,13 +353,26 @@ private static void RenderFilters(StringBuilder text, SchemaModel schema, Schema
text.AppendLine("Filters:");
foreach (var filter in filters)
{
- var fields = filter.Fields
- .Where(e => e.Name is not ("and" or "or"))
- .Select(e => $"{e.Name} {FilterOperatorsOf(schema, shape, e)}");
- text.Append(Indent).AppendLine($"{filter.Name}: {string.Join(", ", fields)}");
+ text.Append(Indent).AppendLine($"{filter.Name}: {FilterSummary(schema, shape, filter)}");
}
}
+ ///
+ /// One entity filter as a line, for example customerName (text), orders (list of OrderFilterInput: …).
+ ///
+ internal static string FilterSummary(SchemaModel schema, SchemaShape shape, SchemaType filter) =>
+ string.Join(", ", filter.Fields
+ .Where(e => e.Name is not ("and" or "or"))
+ .Select(e => $"{e.Name} {FilterOperatorsOf(schema, shape, e)}"));
+
+ ///
+ /// One sort input as a line, for example orderId, customer (CustomerSortInput).
+ ///
+ internal static string SortSummary(SchemaModel schema, SchemaShape shape, SchemaType sort) =>
+ string.Join(", ", sort.Fields.Select(e => shape.IsSortEnum(schema.Type(e.Type.NamedType))
+ ? e.Name
+ : $"{e.Name} ({e.Type.NamedType})"));
+
private static string FilterOperatorsOf(SchemaModel schema, SchemaShape shape, SchemaField field)
{
var type = schema.Type(field.Type.NamedType);
@@ -393,10 +406,7 @@ private static void RenderSorting(StringBuilder text, SchemaModel schema, Schema
text.AppendLine("Sorting:");
foreach (var sort in sorts)
{
- var fields = sort.Fields.Select(e => shape.IsSortEnum(schema.Type(e.Type.NamedType))
- ? e.Name
- : $"{e.Name} ({e.Type.NamedType})");
- text.Append(Indent).AppendLine($"{sort.Name}: {string.Join(", ", fields)}");
+ text.Append(Indent).AppendLine($"{sort.Name}: {SortSummary(schema, shape, sort)}");
}
}
@@ -411,7 +421,7 @@ private static void RenderDescription(StringBuilder text, string description, st
text.Append(indent).Append('"').Append(escaped).AppendLine("\"");
}
- private static string FirstLine(string description)
+ internal static string FirstLine(string description)
{
if (string.IsNullOrWhiteSpace(description))
{
diff --git a/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs b/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs
new file mode 100644
index 00000000..a23070d0
--- /dev/null
+++ b/src/Linq2GraphQL.Client.AI/Tools/AgentToolset.cs
@@ -0,0 +1,364 @@
+using System.Text;
+using System.Text.Json;
+using Linq2GraphQL.Client.AI.Schema;
+
+namespace Linq2GraphQL.Client.AI.Tools;
+
+///
+/// The four agent tools as plain methods, with no AI framework dependency: run a query, describe operations,
+/// describe types and search the schema. Everything works from the scoped schema; only
+/// talks to the server, and only after the query has passed validation. Results are text, because that is what
+/// a tool hands back to the model.
+///
+internal sealed class AgentToolset
+{
+ private const int StarterFieldCount = 6;
+ private const int MaxSearchResults = 20;
+
+ private static readonly JsonSerializerOptions ResultJson = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private readonly ScopedSchema schema;
+ private readonly GraphClient client;
+ private readonly QueryValidator validator;
+ private readonly SchemaShape shape;
+ private readonly SchemaModel model;
+
+ public AgentToolset(ScopedSchema schema, GraphClient client)
+ {
+ this.schema = schema;
+ this.client = client;
+ validator = new QueryValidator(schema);
+ shape = new SchemaShape(schema.Schema);
+ model = schema.Schema;
+ Index = SchemaRenderer.Render(schema, Detail.Index);
+ }
+
+ ///
+ /// The Index: the allowed operations and type names, for the query tool's description.
+ ///
+ public string Index { get; }
+
+ ///
+ /// graphql_query: validates the query and its variables, and only sends a valid query. Returns JSON with
+ /// data and errors, or validationErrors when nothing was sent.
+ ///
+ public async Task QueryAsync(string query, JsonElement? variables = null,
+ CancellationToken cancellationToken = default)
+ {
+ var validation = validator.Validate(query, variables);
+ if (!validation.IsValid)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ validationErrors = validation.Errors.Select(e => e.ToString()),
+ note = "The query was not sent. Fix every error listed and try again."
+ }, ResultJson);
+ }
+
+ try
+ {
+ var result = await client.ExecuteAsync(query, Variables(variables), cancellationToken);
+ return JsonSerializer.Serialize(new
+ {
+ data = result.Data.ValueKind == JsonValueKind.Undefined ? (JsonElement?)null : result.Data,
+ errors = result.Errors?.Select(e => new { message = e.Message, path = e.Path })
+ }, ResultJson);
+ }
+ catch (GraphQueryRequestException ex)
+ {
+ return JsonSerializer.Serialize(new { requestError = ex.Message }, ResultJson);
+ }
+ }
+
+ ///
+ /// describe_operation: everything needed to call each named operation, including a starter query.
+ ///
+ public string DescribeOperations(IEnumerable names)
+ {
+ var text = new StringBuilder();
+ foreach (var name in names.Distinct())
+ {
+ if (text.Length > 0)
+ {
+ text.AppendLine();
+ }
+
+ var operation = schema.QueryType.Field(name);
+ if (operation == null)
+ {
+ text.AppendLine($"Operation {name} is not available." +
+ NameSuggestions.Suggest(name, schema.QueryType.Fields.Select(e => e.Name)));
+ continue;
+ }
+
+ DescribeOperation(text, operation);
+ }
+
+ return Finish(text);
+ }
+
+ ///
+ /// describe_type: full detail for each named type, plus the types its fields use at Outline level. Filter
+ /// and sort inputs are described by their summary line.
+ ///
+ public string DescribeTypes(IEnumerable names)
+ {
+ var requested = names.Distinct().ToList();
+ var text = new StringBuilder();
+ var described = new List();
+
+ foreach (var name in requested)
+ {
+ var type = model.Type(name);
+ if (type == null)
+ {
+ text.AppendLine($"Type {name} is not available." + NameSuggestions.Suggest(name, ListedTypeNames()));
+ }
+ else if (shape.IsEntityFilter(type))
+ {
+ text.AppendLine($"{type.Name} (filter): {SchemaRenderer.FilterSummary(model, shape, type)}");
+ }
+ else if (shape.IsSortInput(type))
+ {
+ text.AppendLine($"{type.Name} (sorting): {SchemaRenderer.SortSummary(model, shape, type)}");
+ }
+ else if (shape.ConnectionOf(type.Name) is { } connection)
+ {
+ text.AppendLine($"{type.Name} is a page of {connection.NodeType}; see the @paged convention. " +
+ $"Describe {connection.NodeType} for its fields.");
+ }
+ else if (SchemaRenderer.IsListed(type, shape))
+ {
+ described.Add(type.Name);
+ text.Append(SchemaRenderer.RenderTypes(schema, [type.Name]));
+ }
+ else
+ {
+ text.AppendLine($"{type.Name} is a built-in scalar.");
+ }
+ }
+
+ if (described.Count > 0)
+ {
+ var related = described
+ .SelectMany(e => RelatedTypes(model.Type(e)))
+ .Where(e => !described.Contains(e))
+ .Distinct()
+ .ToList();
+ if (related.Count > 0)
+ {
+ text.AppendLine();
+ text.AppendLine("Related types:");
+ text.Append(SchemaRenderer.RenderTypes(schema, related, Detail.Outline));
+ }
+ }
+
+ return Finish(text);
+ }
+
+ ///
+ /// search_schema: operations, types, fields and enum values matching the text, names before descriptions.
+ ///
+ public string Search(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return "Give a word to search for, such as a field or type name.\n";
+ }
+
+ var term = text.Trim();
+ var matches = new List<(int Score, int Kind, int Order, string Line)>();
+ var order = 0;
+
+ void Consider(int kind, string name, string description, string line)
+ {
+ var score = name.Equals(term, StringComparison.OrdinalIgnoreCase) ? 0
+ : name.StartsWith(term, StringComparison.OrdinalIgnoreCase) ? 1
+ : name.Contains(term, StringComparison.OrdinalIgnoreCase) ? 2
+ : description?.Contains(term, StringComparison.OrdinalIgnoreCase) == true ? 3
+ : -1;
+ if (score >= 0)
+ {
+ matches.Add((score, kind, order++, line));
+ }
+ }
+
+ foreach (var operation in schema.QueryType.Fields)
+ {
+ Consider(0, operation.Name, operation.Description,
+ $"operation {operation.Name}: {SchemaRenderer.TypeText(operation.Type, shape)}{Summary(operation.Description)}");
+ }
+
+ 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)}");
+
+ foreach (var field in type.Fields)
+ {
+ Consider(2, 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)}");
+ }
+ }
+
+ if (matches.Count == 0)
+ {
+ return $"No operations, types or fields match \"{term}\". Try a shorter or broader word.\n";
+ }
+
+ var result = new StringBuilder();
+ foreach (var match in matches.OrderBy(e => e.Score).ThenBy(e => e.Kind).ThenBy(e => e.Order).Take(MaxSearchResults))
+ {
+ result.AppendLine(match.Line);
+ }
+
+ if (matches.Count > MaxSearchResults)
+ {
+ result.AppendLine($"… and {matches.Count - MaxSearchResults} more. Use a more specific word.");
+ }
+
+ return Finish(result);
+ }
+
+ private void DescribeOperation(StringBuilder text, SchemaField operation)
+ {
+ text.AppendLine($"{operation.Name}{Summary(operation.Description, full: true)}");
+
+ if (operation.Arguments.Count > 0)
+ {
+ text.AppendLine("Arguments:");
+ foreach (var argument in operation.Arguments)
+ {
+ var line = new StringBuilder($" {argument.Name}: {argument.Type}");
+ if (argument.DefaultValue != null)
+ {
+ line.Append($" = {argument.DefaultValue}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(argument.Description))
+ {
+ line.Append($" – {argument.Description.Trim().ReplaceLineEndings(" ")}");
+ }
+
+ var argumentType = model.Type(argument.Type.NamedType);
+ if (shape.IsEntityFilter(argumentType))
+ {
+ line.Append($" – filterable: {SchemaRenderer.FilterSummary(model, shape, argumentType)}");
+ }
+ else if (shape.IsSortInput(argumentType))
+ {
+ line.Append($" – sortable: {SchemaRenderer.SortSummary(model, shape, argumentType)}");
+ }
+
+ text.AppendLine(line.ToString());
+ }
+ }
+
+ text.AppendLine($"Returns: {SchemaRenderer.TypeText(operation.Type, shape)}");
+ var returnType = ReturnedType(operation);
+ if (SchemaRenderer.IsListed(returnType, shape) && returnType.Kind is not (SchemaTypeKind.Scalar or SchemaTypeKind.Enum))
+ {
+ foreach (var line in SchemaRenderer.RenderTypes(schema, [returnType.Name], Detail.Outline).TrimEnd().Split('\n'))
+ {
+ text.AppendLine(" " + line);
+ }
+ }
+
+ text.AppendLine("Starter query:");
+ text.AppendLine(" " + StarterQuery(operation));
+ }
+
+ ///
+ /// A valid query to adapt: required arguments as variables and the first few plain fields of the result.
+ /// Paged operations get no page size, because the schema doesn't publish the server's maximum; the server
+ /// then uses its own default.
+ ///
+ internal string StarterQuery(SchemaField operation)
+ {
+ var required = operation.Arguments.Where(e => e.Type.IsNonNull && e.DefaultValue == null).ToList();
+ var declarations = required.Count == 0 ? "" : $"({string.Join(", ", required.Select(e => $"${e.Name}: {e.Type}"))})";
+ var arguments = required.Select(e => $"{e.Name}: ${e.Name}").ToList();
+
+ var call = arguments.Count == 0 ? operation.Name : $"{operation.Name}({string.Join(", ", arguments)})";
+ return $"query{declarations} {{ {call}{Selection(model.Type(operation.Type.NamedType))} }}";
+ }
+
+ private string Selection(SchemaType type)
+ {
+ if (type.Kind is SchemaTypeKind.Scalar or SchemaTypeKind.Enum)
+ {
+ return "";
+ }
+
+ if (shape.ConnectionOf(type.Name) is { } connection)
+ {
+ var node = Selection(model.Type(connection.NodeType));
+ return connection.HasNodes
+ ? $" {{ nodes{node} pageInfo {{ hasNextPage endCursor }} }}"
+ : $" {{ edges {{ node{node} }} pageInfo {{ hasNextPage endCursor }} }}";
+ }
+
+ if (type.Kind == SchemaTypeKind.Union)
+ {
+ var first = type.PossibleTypes.FirstOrDefault();
+ return first == null ? " { __typename }" : $" {{ __typename ... on {first}{Selection(model.Type(first))} }}";
+ }
+
+ var fields = type.Fields
+ .Where(e => e.Arguments.All(a => !a.Type.IsNonNull || a.DefaultValue != null) &&
+ model.Type(e.Type.NamedType).Kind is SchemaTypeKind.Scalar or SchemaTypeKind.Enum)
+ .Take(StarterFieldCount)
+ .Select(e => e.Name)
+ .ToList();
+ return $" {{ {(fields.Count == 0 ? "__typename" : string.Join(" ", fields))} }}";
+ }
+
+ private SchemaType ReturnedType(SchemaField operation) =>
+ shape.ConnectionOf(operation.Type.NamedType) is { } connection
+ ? model.Type(connection.NodeType)
+ : model.Type(operation.Type.NamedType);
+
+ ///
+ /// The listed types a type's fields and arguments use, with connections resolved to their node types.
+ ///
+ private IEnumerable RelatedTypes(SchemaType type) =>
+ type.Fields
+ .SelectMany(e => e.Arguments.Select(a => a.Type.NamedType).Append(e.Type.NamedType))
+ .Select(e => shape.ConnectionOf(e)?.NodeType ?? e)
+ .Concat(type.PossibleTypes)
+ .Concat(type.Interfaces)
+ .Where(e => model.Type(e) is { } related && SchemaRenderer.IsListed(related, shape) &&
+ related.Kind != SchemaTypeKind.Scalar);
+
+ private IEnumerable ListedTypeNames() =>
+ model.Types.Values
+ .Where(e => e.Name != model.QueryType && SchemaRenderer.IsListed(e, shape))
+ .OrderBy(e => SchemaRenderer.KindOrder(e.Kind))
+ .Select(e => e.Name);
+
+ private static Dictionary Variables(JsonElement? variables) =>
+ variables is { ValueKind: JsonValueKind.Object } values
+ ? values.EnumerateObject().ToDictionary(e => e.Name, e => (object)e.Value.Clone())
+ : null;
+
+ private static string Summary(string description, bool full = false)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ return "";
+ }
+
+ return full
+ ? " – " + description.Trim().ReplaceLineEndings(" ")
+ : " – " + SchemaRenderer.FirstLine(description);
+ }
+
+ private static string Finish(StringBuilder text) => text.ToString().ReplaceLineEndings("\n").TrimEnd() + "\n";
+}
diff --git a/test/Linq2GraphQL.Tests/AgentToolsetTests.cs b/test/Linq2GraphQL.Tests/AgentToolsetTests.cs
new file mode 100644
index 00000000..78ac28ca
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/AgentToolsetTests.cs
@@ -0,0 +1,161 @@
+using System.Text.Json;
+using Linq2GraphQL.Client.AI.Schema;
+using Linq2GraphQL.Client.AI.Tools;
+using Linq2GraphQL.TestClient;
+using Shouldly;
+
+namespace Linq2GraphQL.Tests;
+
+///
+/// Covers the four agent tools as plain methods, against the in-process test server. The scope allows hello,
+/// customers, orders and animals, and omits Customer.address.
+///
+public class AgentToolsetTests : IClassFixture
+{
+ private readonly SampleClient client;
+ private readonly AgentToolset tools;
+
+ public AgentToolsetTests(SampleClientFixture fixture)
+ {
+ client = fixture.sampleClient;
+ tools = new AgentToolset(Scope(), client.GraphClient);
+ }
+
+ private static ScopedSchema Scope() => ScopedSchema.Create(SampleClient.Sdl, o =>
+ {
+ o.AllowQuery(c => c.Query.Hello).Describe("Greets someone.");
+ o.AllowQuery(c => c.Query.Customers).Describe("All customers.");
+ o.AllowQuery(c => c.Query.Orders).Argument("first", "Max 50.", append: true);
+ o.AllowQuery(c => c.Query.Animals);
+ o.Describe("A B2B customer account.");
+ o.Omit(c => c.Address);
+ });
+
+ private static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement;
+
+ [Fact]
+ public void Index_IsTheRenderedIndex()
+ {
+ tools.Index.ShouldBe(SchemaRenderer.Render(Scope(), Detail.Index));
+ }
+
+ [Fact]
+ public async Task Query_ReturnsData()
+ {
+ var result = Json(await tools.QueryAsync("{ customers { customerName } }"));
+
+ result.GetProperty("data").GetProperty("customers").GetArrayLength().ShouldBeGreaterThan(0);
+ result.TryGetProperty("errors", out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Query_WithVariables_ReturnsData()
+ {
+ var result = Json(await tools.QueryAsync("query($n: String) { hello(name: $n) }", Json("""{ "n": "Agent" }""")));
+
+ result.GetProperty("data").GetProperty("hello").GetString().ShouldBe("Hello, Agent!");
+ }
+
+ [Fact]
+ public async Task Query_Invalid_ReturnsValidationErrorsAndSendsNothing()
+ {
+ var sent = false;
+ var watched = new AgentToolset(Scope(), client.WithRequestConfiguration((_, _) =>
+ {
+ sent = true;
+ return ValueTask.CompletedTask;
+ }).GraphClient);
+
+ var result = Json(await watched.QueryAsync("{ customers { address { name } } }"));
+
+ result.GetProperty("validationErrors")[0].GetString().ShouldBe("customers.address: Customer has no field address. " +
+ "Available: customerId, customerName, status, orders, created, relatedCustomers, relatedCustomer.");
+ result.GetProperty("note").GetString()!.ShouldContain("not sent");
+ sent.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Query_ServerError_IsReturnedAsErrors()
+ {
+ var tools = new AgentToolset(ScopedSchema.Create(SampleClient.Sdl, o => o.AllowQuery(c => c.Query.RaiseError)),
+ client.GraphClient);
+
+ var result = Json(await tools.QueryAsync("{ raiseError(message: \"boom\") }"));
+
+ result.GetProperty("errors")[0].GetProperty("message").GetString()!.ShouldContain("boom");
+ }
+
+ [Fact]
+ public void DescribeOperations_Snapshot()
+ {
+ Snapshot.Match(tools.DescribeOperations(["orders", "hello", "customers"]), "Tools.DescribeOperations");
+ }
+
+ [Fact]
+ public void DescribeOperations_Unknown_Suggests()
+ {
+ tools.DescribeOperations(["order"]).ShouldStartWith("Operation order is not available. Did you mean `orders`?");
+ }
+
+ [Fact]
+ public async Task StarterQueries_AreValidAndRunOnTheServer()
+ {
+ var all = ScopedSchema.Create(SampleClient.Sdl, o =>
+ o.AllowAllQueries().Except(c => c.Query.RaiseError, c => c.Query.RaiseAuthError));
+ var allTools = new AgentToolset(all, client.GraphClient);
+ var validator = new QueryValidator(all);
+
+ foreach (var operation in all.QueryType.Fields)
+ {
+ var query = allTools.StarterQuery(operation);
+ var validation = validator.Validate(query);
+ validation.IsValid.ShouldBeTrue($"{query}\n{validation}");
+
+ var result = await client.GraphClient.ExecuteAsync(query);
+ result.HasErrors.ShouldBeFalse($"{query}\n{string.Join("; ", result.Errors?.Select(e => e.Message) ?? [])}");
+ }
+ }
+
+ [Fact]
+ public void StarterQuery_RequiredArguments_BecomeVariables()
+ {
+ var schema = ScopedSchema.Create("""
+ type Query { customer(id: ID!, includeArchived: Boolean = false): Customer }
+ type Customer { id: ID! name: String }
+ """, o => o.AllowAllQueries());
+
+ new AgentToolset(schema, client.GraphClient).StarterQuery(schema.QueryType.Field("customer"))
+ .ShouldBe("query($id: ID!) { customer(id: $id) { id name } }");
+ }
+
+ [Fact]
+ public void DescribeTypes_Snapshot()
+ {
+ Snapshot.Match(tools.DescribeTypes(["Customer", "OrderFilterInput", "OrderSortInput", "OrdersConnection", "String", "Custmer"]),
+ "Tools.DescribeTypes");
+ }
+
+ [Fact]
+ public void Search_RanksNamesBeforeDescriptions()
+ {
+ var lines = tools.Search("customer").TrimEnd().Split('\n');
+
+ lines[0].ShouldBe("type Customer – A B2B customer account.");
+ lines.ShouldContain("operation customers: [Customer] – All customers.");
+ lines.ShouldContain("field Order.customer: Customer!");
+ lines.ShouldNotContain(e => e.Contains("address"));
+ }
+
+ [Fact]
+ public void Search_FindsDescriptionsAndEnumValues()
+ {
+ tools.Search("greets").ShouldBe("operation hello: String – Greets someone.\n");
+ tools.Search("active").ShouldContain("value CustomerStatus.ACTIVE");
+ }
+
+ [Fact]
+ public void Search_NoMatch_SaysSo()
+ {
+ tools.Search("payment").ShouldStartWith("No operations, types or fields match \"payment\".");
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeOperations.txt b/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeOperations.txt
new file mode 100644
index 00000000..72d9205a
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeOperations.txt
@@ -0,0 +1,25 @@
+orders
+Arguments:
+ first: Int – Returns the first _n_ elements from the list. Max 50.
+ after: String – Returns the elements in the list that come after the specified cursor.
+ last: Int – Returns the last _n_ elements from the list.
+ before: String – Returns the elements in the list that come before the specified cursor.
+ where: OrderFilterInput – filterable: orderId (comparable), customer (CustomerFilterInput), address (AddressFilterInput), orderDate (comparable), orderDateTime (comparable), orderDay (comparable), lines (list of OrderLineFilterInput: some/all/none, any), entryTime (comparable)
+ order: [OrderSortInput!] – sortable: orderId, customer (CustomerSortInput), address (AddressSortInput), orderDate, orderDateTime, orderDay, entryTime
+Returns: [Order] @paged(totalCount)
+ type Order { orderHello(name: String!, first: Int! = 0): String! orderAddress(addressType: AddressType!): Address! orderId: UUID! customer: Customer! address: Address orderDate: DateTime! orderDateTime: DateTime! orderDay: LocalDate! lines: [OrderLine!]! entryTime: LocalTime }
+Starter query:
+ query { orders { nodes { orderId orderDate orderDateTime orderDay entryTime } pageInfo { hasNextPage endCursor } } }
+
+hello – Greets someone.
+Arguments:
+ name: String = "World"
+Returns: String
+Starter query:
+ query { hello }
+
+customers – All customers.
+Returns: [Customer]
+ type Customer { customerId: UUID! customerName: String! status: CustomerStatus! orders: [Order!]! created: DateTime relatedCustomers(relationType: Int!): [Customer] relatedCustomer(relationType: Int!): Customer }
+Starter query:
+ query { customers { customerId customerName status created } }
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeTypes.txt b/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeTypes.txt
new file mode 100644
index 00000000..8b40629f
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Tools.DescribeTypes.txt
@@ -0,0 +1,19 @@
+"A B2B customer account."
+type Customer {
+ customerId: UUID!
+ customerName: String!
+ status: CustomerStatus!
+ orders: [Order!]!
+ created: DateTime
+ relatedCustomers(relationType: Int!): [Customer]
+ relatedCustomer(relationType: Int!): Customer
+}
+OrderFilterInput (filter): orderId (comparable), customer (CustomerFilterInput), address (AddressFilterInput), orderDate (comparable), orderDateTime (comparable), orderDay (comparable), lines (list of OrderLineFilterInput: some/all/none, any), entryTime (comparable)
+OrderSortInput (sorting): orderId, customer (CustomerSortInput), address (AddressSortInput), orderDate, orderDateTime, orderDay, entryTime
+OrdersConnection is a page of Order; see the @paged convention. Describe Order for its fields.
+String is a built-in scalar.
+Type Custmer is not available. Did you mean `Customer`? Available: IAnimal, Pig, Spider, Order, Customer, OrderLine, Address, Item, CustomerStatus, AddressType, UUID, DateTime, LocalDate, LocalTime, Decimal and 1 more.
+
+Related types:
+enum CustomerStatus { ACTIVE DISABLED }
+type Order { orderHello(name: String!, first: Int! = 0): String! orderAddress(addressType: AddressType!): Address! orderId: UUID! customer: Customer! address: Address orderDate: DateTime! orderDateTime: DateTime! orderDay: LocalDate! lines: [OrderLine!]! entryTime: LocalTime }