Skip to content

WolverineFx.AI: source-generated response schemas so LLM callouts are AOT-clean #4230

Description

@jeremydmiller

WolverineFx.AI ships with <IsAotCompatible>false</IsAotCompatible>. This is the follow-up to make it trim- and AOT-clean.

Why it isn't today

A callout names its response type as a string on the message (LlmCallout.ResponseType) rather than as a type parameter — see decision 7 on #4227 for why. LlmCalloutExecutor then does two reflective things with the Type it resolves from that string, neither of which the trimmer can follow from any static call site:

  1. AIJsonUtilities.CreateJsonSchema(responseType, ...) — walks the response type's properties to build the schema handed to the model as ChatResponseFormat.ForJsonSchema.
  2. JsonSerializer.Deserialize(text, responseType, options) — turns the model's answer back into the response type.

LlmCalloutExecutor carries [RequiresUnreferencedCode] / [RequiresDynamicCode] rather than a suppression, deliberately: a suppression here would claim a guarantee the design cannot make. Under a trimmed publish the response type's properties can be trimmed away, and the schema and the deserialization both silently degrade rather than failing loudly.

Note that this is not the same problem the non-generic decision solved. That one was about resolving a name to a Type at all on a cold start, and it is fixed. This one is about what the trimmer can prove about that type once resolved, and it is unchanged by the message's shape — a generic LlmCallout<T> would need exactly the same work here.

What it would take

  • Source-generated JSON schemas. Discover the response types an application actually asks for and emit their schemas at compile time, so CreateJsonSchema never runs at all in an AOT app. The natural trigger is the Ask<TResponse>(...) call sites, which are statically visible in the common handler and HTTP-endpoint cases; a [LlmResponse]-style attribute would be the escape hatch for the cases a call-site scan cannot see (a callout constructed inside a projection's RaiseSideEffects, say).
  • A generated JsonSerializerContext over the same set, fed to LlmCalloutOptions.JsonSerializerOptions so the deserialization is source-generated too.
  • A runtime lookup keyed by the same identifier the message already carries, so the executor consults the generated table first and falls back to reflection only on the non-AOT path.
  • Flip IsAotCompatible to true, drop the [Requires*] annotations, and cover it in the AOT publishing guide.

Worth doing alongside, or after, whatever shape the tier 2 agents work (#4226) needs — tool schemas have the same generation problem, and it would be a shame to build the generator twice.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions