Skip to main content
CodeAlive 3.0: From Context Engine to Code Research Agent
CodeAlive

Back to blog

Canonicalization for small agent LLMs: a practical AX pattern

The same tool-call exception kept appearing in our observability feed.

In one production sample, we found ten occurrences on our fast agent path running qwen3.6-35b-a3b. In each case, the .NET argument binder rejected the call before the tool ran.

The traces showed the rejected arguments:

json
{  "data_source_names": "dotnet/eshop"}

The schema expected:

json
{  "data_source_names": ["dotnet/eshop"]}

The model had picked the right tool and the right data source. One pair of square brackets separated a valid call from an exception.

This is a classic agent integration problem: the intent is right, the data is almost right, and a deterministic boundary turns a near miss into an exception. An agent-facing interface should adapt to the agent as far as semantics allow. The rest of the system should still receive one canonical shape.

We adapted to the model

The tempting fix was to declare data_source_names as string | string[]. We left the public schema as string[]; callers should not have to know which model needs extra tolerance.

Instead, we added one canonicalization policy at the function-invocation boundary:

  • inspect the tool's JSON Schema;
  • when the schema says array<string> and the model sends a scalar string, promote it to a singleton array;
  • leave every other value unchanged;
  • record the repair in telemetry, then invoke the tool normally.

For us, this is Agent Experience (AX) work. The schema stays precise; the invocation boundary absorbs a harmless representational mismatch.

The boundary is narrow on purpose. We do not split comma-separated strings, rename parameters, or coerce unrelated types. A repair is safe here because "dotnet/eshop" and ["dotnet/eshop"] mean the same thing to this tool.

The result

We replayed the captured calls through the real framework binder after adding the middleware. The failure rate for this scalar-to-string[] mismatch was 0%.

The middleware reads each function's schema, so the same rule applies to every array<string> parameter rather than a hard-coded list of parameter names.

Minimal Microsoft Agent Framework implementation

Microsoft Agent Framework middleware can intercept a function call before argument binding. This minimal version promotes scalar strings only when the declared schema expects an array of strings.

csharp
using System.Text.Json;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;
static async ValueTask<object?> CanonicalizeArgumentsAsync(    AIAgent _,    FunctionInvocationContext context,    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,    CancellationToken cancellationToken){    JsonElement schema = context.Function.JsonSchema;    if (!schema.TryGetProperty("properties", out JsonElement properties))    {        return await next(context, cancellationToken);    }
    AIFunctionArguments? repaired = null;
    foreach (JsonProperty property in properties.EnumerateObject())    {        if (!IsStringArray(property.Value)            || !context.Arguments.TryGetValue(property.Name, out object? raw)            || !TryReadString(raw, out string? value))        {            continue;        }
        repaired ??= new AIFunctionArguments(context.Arguments)        {            Services = context.Arguments.Services,            Context = context.Arguments.Context,        };        repaired[property.Name] = new[] { value };    }
    if (repaired is not null)    {        context.Arguments = repaired;    }
    return await next(context, cancellationToken);}
static bool IsStringArray(JsonElement schema) =>    HasType(schema, "array")    && schema.TryGetProperty("items", out JsonElement items)    && HasType(items, "string");
static bool HasType(JsonElement schema, string expected) =>    schema.TryGetProperty("type", out JsonElement type)    && (type.ValueKind == JsonValueKind.String        ? type.GetString() == expected        : type.ValueKind == JsonValueKind.Array          && type.EnumerateArray().Any(item => item.GetString() == expected));
static bool TryReadString(object? raw, out string? value){    value = raw switch    {        string text => text,        JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(),        _ => null,    };    return value is not null;}
static AIAgent WithArgumentCanonicalization(AIAgent agent) =>    agent.AsBuilder()        .Use(CanonicalizeArgumentsAsync)        .Build();

When canonicalization is not enough

Canonicalization only works when both representations mean the same thing. A missing required parameter or a malformed object cannot be fixed without guessing, so those calls should not be silently rewritten.

Return a structured validation result instead, tell the model what must change, and let it try again. That repair loop is the subject of the next article.

Appendix: example environment

The code above was verified with:

  • Microsoft.Agents.AI 1.13
  • Microsoft.Extensions.AI 10.7

Framework references: Agent Framework function-calling middleware and FunctionInvokingChatClient.

Agent Experience (AX)

Give your agents the whole codebase

Index your first repo in minutes — or try the Playground without signing up.