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
4 changes: 2 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 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

Expand Down Expand Up @@ -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.

Expand Down
55 changes: 55 additions & 0 deletions src/Linq2GraphQL.Client.AI/Schema/NameSuggestions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace Linq2GraphQL.Client.AI.Schema;

/// <summary>
/// "Did you mean" suggestions and capped name lists, so every message to the agent says what it can use instead.
/// </summary>
internal static class NameSuggestions
{
private const int MaxListedNames = 15;

/// <summary>
/// " Did you mean `x`?" for a near miss, followed by the available names.
/// </summary>
public static string Suggest(string name, IEnumerable<string> 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<string> 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<string> 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];
}
}
63 changes: 10 additions & 53 deletions src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal sealed class ValidationResult(IReadOnlyList<ValidationError> errors)
/// </summary>
internal sealed class QueryValidator(ScopedSchema schema)
{
private const int MaxListedNames = 15;


private readonly SchemaModel model = schema.Schema;
private readonly SchemaShape shape = new(schema.Schema);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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;
}

Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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.");
Expand All @@ -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;
}

Expand All @@ -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))}");
}
}

Expand Down Expand Up @@ -555,49 +555,6 @@ private IEnumerable<string> InputTypeNames() => model.Types.Values
private void Error(string path, string message) => Errors.Add(new ValidationError(path, message));
}

/// <summary>
/// " Did you mean `x`?" for a near miss, and the available names, so the agent can fix the query in one go.
/// </summary>
private static string Suggest(string name, IEnumerable<string> 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<string> 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}";

Expand Down
38 changes: 24 additions & 14 deletions src/Linq2GraphQL.Client.AI/Schema/SchemaRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public static string RenderTypes(ScopedSchema schema, IEnumerable<string> typeNames)
public static string RenderTypes(ScopedSchema schema, IEnumerable<string> typeNames, Detail detail = Detail.Full)
{
var shape = new SchemaShape(schema.Schema);
var text = new StringBuilder();
Expand All @@ -97,21 +97,21 @@ public static string RenderTypes(ScopedSchema schema, IEnumerable<string> 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);

/// <summary>
/// Types are grouped by kind, each group in schema order: the shapes the agent selects from first,
/// scalars last.
/// </summary>
private static int KindOrder(SchemaTypeKind kind) => kind switch
internal static int KindOrder(SchemaTypeKind kind) => kind switch
{
SchemaTypeKind.Interface => 0,
SchemaTypeKind.Object => 1,
Expand Down Expand Up @@ -325,7 +325,7 @@ private static string ArgumentText(SchemaArgument argument) =>
/// <summary>
/// The type as the agent should read it: a connection becomes its node type marked @paged.
/// </summary>
private static string TypeText(TypeRef type, SchemaShape shape)
internal static string TypeText(TypeRef type, SchemaShape shape)
{
if (shape.ConnectionOf(type.NamedType) is not { } connection)
{
Expand Down Expand Up @@ -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)}");
}
}

/// <summary>
/// One entity filter as a line, for example <c>customerName (text), orders (list of OrderFilterInput: …)</c>.
/// </summary>
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)}"));

/// <summary>
/// One sort input as a line, for example <c>orderId, customer (CustomerSortInput)</c>.
/// </summary>
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);
Expand Down Expand Up @@ -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)}");
}
}

Expand All @@ -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))
{
Expand Down
Loading
Loading