From 38edc72e44fd54f39e1fc821d901d3e61b451a9c Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Wed, 30 Sep 2026 18:11:44 -0400 Subject: [PATCH 01/12] Add decision interoperability layer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c97c7d20-076b-47b5-9a19-4068de9b4af2 --- .../Decisions/DecisionFunction.cs | 207 ++++++++++++ .../Decisions/README.md | 91 ++++++ .../Microsoft.Extensions.AI.Abstractions.json | 70 +++++ .../Decisions/DecisionFunctionTests.cs | 210 +++++++++++++ .../Decisions/DecisionRoutingProofTests.cs | 228 ++++++++++++++ .../DecisionSemanticPipelineTests.cs | 296 ++++++++++++++++++ 6 files changed, 1102 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs new file mode 100644 index 00000000000..2bb78526363 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Decision task contracts are co-located as one API surface. +#pragma warning disable SA1649 // Decision task contracts are co-located as one API surface. +#pragma warning disable SA1204 // Static extension members are intentionally grouped after the task contracts. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Describes a host-defined decision task that can be exposed as one bounded AI function invocation. +/// +/// The application-owned state type supplied to the decision provider. +/// The application-owned result type produced by the local mapper. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionTask +{ + /// + /// Initializes a new instance of the class. + /// + /// The nonempty, ordered questions evaluated by the task. + /// The source-generated JSON contract for . + /// The explicit mapper for the complete decision response. + /// + /// The source-generated JSON contract for the safe function result, including + /// . + /// + /// + /// The named feature schema used to retain a portable, probability-rich projection. + /// Every coordinate must reference a question in . + /// + public DecisionTask( + IEnumerable questions, + JsonTypeInfo stateTypeInfo, + DecisionResultBinding resultBinding, + JsonTypeInfo> resultTypeInfo, + DecisionFeatureSchema featureSchema) + { + _ = Throw.IfNull(questions); + StateTypeInfo = Throw.IfNull(stateTypeInfo); + ResultBinding = Throw.IfNull(resultBinding); + ResultTypeInfo = Throw.IfNull(resultTypeInfo); + FeatureSchema = Throw.IfNull(featureSchema); + + DecisionQuestion[] questionSnapshot = questions.ToArray(); + if (questionSnapshot.Length == 0 || Array.Exists(questionSnapshot, static question => question is null)) + { + Throw.ArgumentException(nameof(questions), "Questions must be nonempty."); + } + + HashSet questionIds = new(StringComparer.Ordinal); + foreach (DecisionQuestion question in questionSnapshot) + { + if (!questionIds.Add(question.Id)) + { + Throw.ArgumentException(nameof(questions), "Question IDs must be unique using ordinal comparison."); + } + } + + foreach (DecisionFeatureCoordinate coordinate in featureSchema.Coordinates) + { + if (!questionIds.Contains(coordinate.QuestionId)) + { + Throw.ArgumentException( + nameof(featureSchema), + $"Feature coordinate '{coordinate.Name}' references unknown question '{coordinate.QuestionId}'."); + } + } + + Questions = Array.AsReadOnly(questionSnapshot); + } + + /// Gets the owned question snapshot. + public IReadOnlyList Questions { get; } + + /// Gets the explicit source-generated JSON contract for the task state. + public JsonTypeInfo StateTypeInfo { get; } + + /// Gets the explicit mapper for the complete response. + public DecisionResultBinding ResultBinding { get; } + + /// Gets the explicit source-generated JSON contract for the function result. + public JsonTypeInfo> ResultTypeInfo { get; } + + /// Gets the named feature schema projected into the function result. + public DecisionFeatureSchema FeatureSchema { get; } +} + +/// +/// Represents the safe result of a decision function invocation. +/// +/// The application-owned result type produced by the local mapper. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionFunctionResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The application-owned mapped result. + /// The portable named feature projection. + /// The decision provider/model provenance, when reported. + /// The decision usage, kept separate from chat usage. + public DecisionFunctionResult( + TResult result, + DecisionFeatureVector features, + DecisionProvenance? provenance = null, + UsageDetails? usage = null) + { + Result = result; + Features = Throw.IfNull(features); + Provenance = provenance; + Usage = usage; + } + + /// Gets the application-owned mapped result. + public TResult Result { get; } + + /// Gets the portable named feature projection. + public DecisionFeatureVector Features { get; } + + /// Gets decision provider/model provenance, when reported. + public DecisionProvenance? Provenance { get; } + + /// Gets decision usage, kept separate from chat usage. + public UsageDetails? Usage { get; } +} + +/// Provides interoperability helpers for decision tasks. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public static class DecisionFunctionExtensions +{ + /// + /// Creates one bounded that evaluates the supplied decision task. + /// + /// The application-owned state type accepted by the function. + /// The application-owned mapped result type. + /// The explicit decision task definition. + /// The caller-owned decision client to invoke. + /// Optional request options captured for each invocation. + /// Optional AIFunction metadata and source-generated serializer options. + /// An invocable function whose only input is . + /// + /// + /// The returned function closes over the questions, feature schema, mapper, and client. It performs exactly one + /// decision request per invocation, propagates provider, mapper, and cancellation failures, and never disposes + /// the caller-owned client. + /// + /// + /// The default result contains only the explicitly mapped result, named native-double features, provenance, and + /// usage. Raw state, raw provider objects, reasoning text, and provider extension dictionaries are not included. + /// The supplied must contain source-generated metadata + /// for and when reflection-based JSON + /// serialization is disabled. + /// + /// + public static AIFunction AsAIFunction( + this DecisionTask task, + IDecisionClient client, + DecisionOptions? decisionOptions = null, + AIFunctionFactoryOptions? functionOptions = null) + { + _ = Throw.IfNull(task); + _ = Throw.IfNull(client); + + DecisionOptions? optionsSnapshot = decisionOptions?.Clone(); + AIFunctionFactoryOptions effectiveOptions = new() + { + Name = functionOptions?.Name ?? "decision", + Description = functionOptions?.Description, + SerializerOptions = functionOptions?.SerializerOptions ?? task.ResultTypeInfo.Options, + JsonSchemaCreateOptions = functionOptions?.JsonSchemaCreateOptions, + AdditionalProperties = functionOptions?.AdditionalProperties, + ConfigureParameterBinding = functionOptions?.ConfigureParameterBinding, + MarshalResult = functionOptions?.MarshalResult, + ExcludeResultSchema = functionOptions?.ExcludeResultSchema ?? false, + }; + + Func>> invoke = InvokeAsync; + return AIFunctionFactory.Create(invoke, effectiveOptions); + + async Task> InvokeAsync( + TState state, + CancellationToken cancellationToken) + { + DecisionResponse response = await client.GetResponseAsync( + state, + task.StateTypeInfo, + task.Questions, + optionsSnapshot, + cancellationToken).ConfigureAwait(false); + + TResult result = response.Bind(task.ResultBinding); + DecisionFeatureVector features = DecisionFeatureProjection.Project(response, task.FeatureSchema); + return new(result, features, response.Provenance, response.Usage); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index ca0e7d73916..b52f171e6c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -149,3 +149,94 @@ Automated arbitrary semantic inference is out. This layer already uses bounded o Provider evidence for the package-level contract is bounded and documented in the [upstream provider-validation report](https://github.com/luisquintanilla/typesafe-meai/blob/13adb58c7d7479bc8c73bf31960c08349b7c3185/docs/upstream-provider-validation.md): Julia/Laya provide CPU binary/choice/score proof, Qwen provides choice-only proof, and the dedicated `tev1:0.8b` Ollama/OllamaSharp path provides live binary/choice/score proof. Other adapters use native HTTP fixtures rather than live executions; this is not a claim of universal provider accuracy. This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. + +## Layer 2 interoperability + +### Prefer the existing MEAI path for a result-only business tool + +When a tool only needs the business result, the simplest composition is the existing `AIFunctionFactory.Create` plus an ordinary host-owned delegate. With the `client` and `definition` from the business example above, the tool keeps the complete response in the host and returns only the mapped result: + +```csharp +Func> analyze = + async (ticket, cancellationToken) => + { + DecisionResponse response = + await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + // Evidence remains available to the host for reporting or review. + DecisionResponse evidence = response.Evidence; + + return response.Result; + }; + +AIFunction function = AIFunctionFactory.Create( + analyze, + + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + Description = "Annotate a support ticket for reporting and human review.", + }); +``` + +The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. This path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. + +
+Reflection-disabled/source-generated JSON variant + +When reflection metadata is disabled or trimming is required, supply source-generated metadata to both the decision definition and the typed state call. `AIFunctionFactoryOptions.SerializerOptions` controls the tool's JSON contract; it does not provide the decision definition or state contract: + +```csharp +DecisionDefinition definition = + DecisionDefinition.Create( + TicketJsonContext.Default.TicketAnalysis, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + }); + +Func> analyze = + async (ticket, cancellationToken) => + { + DecisionResponse response = + await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + definition, + cancellationToken: cancellationToken); + + return response.Result; + }; + +AIFunction function = AIFunctionFactory.Create( + analyze, + + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = TicketJsonContext.Default.Options, + }); +``` + +This is still ordinary delegate composition; the extra metadata is the deployment-safe JSON boundary, not a new decision or tool abstraction. + +
+ +
+Optional probability-feature export with the proposed helper + +For a host that explicitly needs portable probability features, provenance, and separate decision usage in the tool result, the existing Layer 2 `DecisionTask.AsAIFunction` proof remains available. It closes over explicit questions, mapper, feature schema, source-generated state/result contracts, and the caller-owned `IDecisionClient`, performs one decision call, verifies that the response corresponds to the outgoing state and ordered question definitions before mapping or projection, and returns bounded mapped output with named native-`double` features. The advanced sample is optional and is not required by `AIFunctionFactory`, `JsonTypeInfo`, or the .NET platform. + +
+ +The current `DecisionTask` contract intentionally requires a feature schema and returns its feature vector even when a business tool only needs `TicketAnalysis`. That is an ergonomics simplification candidate under review, not a requirement of `AIFunction`, `JsonTypeInfo`, or the .NET platform. The feature schema remains explicit for hosts that want to retain probability-rich evidence for reporting or review; it does not authorize a refund, calibrate a score, or replace the business result. + +The same explicit feature projection can be used as optional downstream MEDI enrichment. A consumer-owned semantic chunk processor can run after the existing document reader and chunker, ask a host-defined decision question for each bounded chunk, and attach versioned named `double` coordinates, provenance, document identity/context, and unrelated metadata before the existing collecting or serializing writer. The proof uses the current reader -> chunker -> processor -> writer primitives and preserves bounded streaming, cancellation, and failure propagation; it does not add a reusable MEDI processor or change `ClassificationEnricher` or batching. + +Routing remains a secondary compatibility and lifetime proof rather than the headline scenario. A consumer-owned selector can use the existing `RoutingChatClient.Create` callback to project bounded state into a Choice question, validate the exact selected candidate against a configured client map, and return the configured caller-owned `IChatClient` while preserving message/options forwarding, cancellation, and selected-client lifetime. It does not add a parallel router, retry, failover, quality cascading, learning, persistence, or outcome policy. + +This layer intentionally does not define provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. Those concerns require provider-owned or later-layer work and are not implied by these abstractions. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index b422e38fe80..c783a4cb8bd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5368,6 +5368,76 @@ } ] }, + { + "Type": "static class Microsoft.Extensions.AI.DecisionFunctionExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DecisionFunctionExtensions.AsAIFunction(this Microsoft.Extensions.AI.DecisionTask task, Microsoft.Extensions.AI.IDecisionClient client, Microsoft.Extensions.AI.DecisionOptions? decisionOptions = null, Microsoft.Extensions.AI.AIFunctionFactoryOptions? functionOptions = null);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionFunctionResult", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFunctionResult.DecisionFunctionResult(TResult result, Microsoft.Extensions.AI.DecisionFeatureVector features, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureVector Microsoft.Extensions.AI.DecisionFunctionResult.Features { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionProvenance? Microsoft.Extensions.AI.DecisionFunctionResult.Provenance { get; }", + "Stage": "Experimental" + }, + { + "Member": "TResult Microsoft.Extensions.AI.DecisionFunctionResult.Result { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.DecisionFunctionResult.Usage { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionTask", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionTask.DecisionTask(System.Collections.Generic.IEnumerable questions, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, Microsoft.Extensions.AI.DecisionResultBinding resultBinding, System.Text.Json.Serialization.Metadata.JsonTypeInfo> resultTypeInfo, Microsoft.Extensions.AI.DecisionFeatureSchema featureSchema);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema Microsoft.Extensions.AI.DecisionTask.FeatureSchema { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionTask.Questions { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionResultBinding Microsoft.Extensions.AI.DecisionTask.ResultBinding { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo> Microsoft.Extensions.AI.DecisionTask.ResultTypeInfo { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo Microsoft.Extensions.AI.DecisionTask.StateTypeInfo { get; }", + "Stage": "Experimental" + } + ] + }, { "Type": "enum Microsoft.Extensions.AI.DecisionKind", "Stage": "Experimental", diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs new file mode 100644 index 00000000000..781bc80055e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. +#pragma warning disable SA1118 // Long generic constructor arguments are kept readable. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public sealed class DecisionFunctionTests +{ + [Fact] + public async Task AsAIFunction_UsesBoundedSchemaAndPortableResult() + { + using RecordingDecisionClient client = new(); + DecisionTask task = CreateTask( + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => new() + { + Selected = answers.GetChoice("route").SelectedCandidateId, + })); + + DecisionOptions options = new() { ModelId = "decision-model" }; + AIFunction function = task.AsAIFunction( + client, + options, + new AIFunctionFactoryOptions + { + Name = "choose_route", + Description = "Choose one route.", + SerializerOptions = DecisionFunctionJsonContext.Default.Options, + }); + options.ModelId = "changed-after-creation"; + + Assert.Equal("choose_route", function.Name); + Assert.Equal("Choose one route.", function.Description); + Assert.Equal(["state"], function.JsonSchema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); + Assert.True(function.ReturnJsonSchema!.Value.GetProperty("properties").TryGetProperty("features", out _)); + Assert.False(function.ReturnJsonSchema.Value.GetProperty("properties").TryGetProperty("request", out _)); + + JsonElement serializedState = JsonSerializer.SerializeToElement( + new DecisionToolState { Payload = "sensitive request" }, + DecisionFunctionJsonContext.Default.DecisionToolState); + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["state"] = serializedState })); + Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); + Assert.Equal("route.primary", resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("name").GetString()); + Assert.Equal(0.75, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); + Assert.Equal("test-provider", resultJson.GetProperty("provenance").GetProperty("providerName").GetString()); + Assert.Equal(12, resultJson.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); + Assert.Equal(1, client.CallCount); + Assert.Equal("decision-model", client.LastOptions!.ModelId); + Assert.DoesNotContain("sensitive request", resultJson.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("raw-secret", resultJson.GetRawText(), StringComparison.Ordinal); + Assert.Equal(0, client.DisposeCount); + } + + [Fact] + public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using RecordingDecisionClient cancelledClient = new() + { + Cancellation = true, + }; + AIFunction cancelledFunction = CreateTask().AsAIFunction( + cancelledClient, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + await Assert.ThrowsAnyAsync( + () => cancelledFunction.InvokeAsync( + new AIFunctionArguments { ["state"] = new DecisionToolState() }, + cancellation.Token).AsTask()); + Assert.Equal(1, cancelledClient.CallCount); + + using RecordingDecisionClient failedClient = new() + { + Failure = new DecisionClientException("provider failed", isTransient: true), + }; + AIFunction failedFunction = CreateTask().AsAIFunction( + failedClient, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + DecisionClientException providerFailure = await Assert.ThrowsAsync( + () => failedFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); + Assert.True(providerFailure.IsTransient); + + using RecordingDecisionClient mapperClient = new(); + AIFunction mapperFunction = CreateTask( + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + _ => throw new InvalidOperationException("mapper failed"))).AsAIFunction( + mapperClient, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + InvalidOperationException mapperFailure = await Assert.ThrowsAsync( + () => mapperFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); + Assert.Equal("mapper failed", mapperFailure.Message); + } + + private static DecisionTask CreateTask( + DecisionResultBinding? binding = null) + { + ChoiceDecisionQuestion question = new( + "route", + "Choose one route.", + [ + new DecisionCandidate("primary", "Use the primary route."), + new DecisionCandidate("secondary", "Use the secondary route."), + ]); + + return new( + [question], + DecisionFunctionJsonContext.Default.DecisionToolState, + binding ?? new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => new() { Selected = answers.GetChoice("route").SelectedCandidateId }), + DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult, + new DecisionFeatureSchema( + "decision-tool-v1", + 1, + [new("route.primary", "route", DecisionFeatureValueKind.ChoiceProbability, "primary")])); + } + + private sealed class RecordingDecisionClient : IDecisionClient + { + public int CallCount { get; private set; } + + public int DisposeCount { get; private set; } + + public DecisionOptions? LastOptions { get; private set; } + + public bool Cancellation { get; init; } + + public Exception? Failure { get; init; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + LastOptions = options; + + if (Failure is not null) + { + return Task.FromException(Failure); + } + + if (Cancellation) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult( + new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + "route", + "primary", + [ + new("primary", 0.75), + new("secondary", 0.25), + ]), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), + new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, + rawRepresentation: "raw-secret", + additionalProperties: [new KeyValuePair("secret", "raw-secret")])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() => DisposeCount++; + } + + internal sealed class DecisionToolState + { + public string? Payload { get; set; } + } + + internal sealed class DecisionToolResult + { + public string? Selected { get; set; } + } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(DecisionFunctionTests.DecisionToolState))] +[JsonSerializable(typeof(DecisionFunctionTests.DecisionToolResult))] +[JsonSerializable(typeof(DecisionFunctionResult))] +[JsonSerializable(typeof(DecisionFeatureVector))] +[JsonSerializable(typeof(DecisionFeatureSchema))] +[JsonSerializable(typeof(DecisionFeatureCoordinate))] +[JsonSerializable(typeof(DecisionFeatureValue))] +[JsonSerializable(typeof(DecisionProvenance))] +[JsonSerializable(typeof(UsageDetails))] +internal sealed partial class DecisionFunctionJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs new file mode 100644 index 00000000000..48b272a8fc1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs @@ -0,0 +1,228 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public sealed class DecisionRoutingProofTests +{ + private static readonly ChoiceDecisionQuestion _routeQuestion = new( + "route", + "Choose the configured chat route.", + [ + new DecisionCandidate("primary", "Use the primary route."), + new DecisionCandidate("secondary", "Use the secondary route."), + ]); + + [Fact] + public async Task ConsumerOwnedDecisionSelectorPreservesForwardingAndLifetime() + { + ChatMessage[] messages = [new(ChatRole.User, "hello")]; + ChatOptions options = new() { ModelId = "request-model" }; + using CancellationTokenSource cancellation = new(); + using RecordingDecisionClient decisionClient = new("primary"); + using CountingChatClient selectedClient = new(); + Dictionary clients = new(StringComparer.Ordinal) + { + ["primary"] = selectedClient, + }; + IEnumerable? forwardedMessages = null; + ChatOptions? forwardedOptions = null; + ChatResponse expectedResponse = new(new ChatMessage(ChatRole.Assistant, "selected")); + selectedClient.GetResponseAsyncCallback = (forwarded, forwardedChatOptions, token) => + { + forwardedMessages = forwarded; + forwardedOptions = forwardedChatOptions; + Assert.Equal(cancellation.Token, token); + return Task.FromResult(expectedResponse); + }; + + using RoutingChatClient router = CreateConsumerOwnedRouter( + decisionClient, + clients, + context => + { + Assert.Same(messages, context.Messages); + context.ChatOptions!.ModelId = "configured-primary"; + return new RoutingDecisionState( + context.Messages.Count(), + context.ChatOptions.ModelId, + context.ChatOptions.Tools is not null); + }); + + ChatResponse actual = await router.GetResponseAsync(messages, options, cancellation.Token); + + Assert.Same(expectedResponse, actual); + Assert.Same(messages, forwardedMessages); + Assert.NotSame(options, forwardedOptions); + Assert.Equal("configured-primary", forwardedOptions!.ModelId); + Assert.Equal("request-model", options.ModelId); + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(cancellation.Token, decisionClient.LastCancellationToken); + + router.Dispose(); + Assert.Equal(0, selectedClient.DisposeCount); + } + + [Fact] + public async Task ConsumerOwnedDecisionSelectorRequiresConfiguredCandidate() + { + using RecordingDecisionClient decisionClient = new("secondary"); + using CountingChatClient primaryClient = new(); + using RoutingChatClient router = CreateConsumerOwnedRouter( + decisionClient, + new Dictionary(StringComparer.Ordinal) + { + ["primary"] = primaryClient, + }); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => router.GetResponseAsync([new(ChatRole.User, "hello")])); + + Assert.Contains("secondary", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(0, primaryClient.CallCount); + } + + [Fact] + public async Task ConsumerOwnedDecisionSelectorPropagatesCancellation() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using RecordingDecisionClient decisionClient = new("primary"); + using CountingChatClient selectedClient = new(); + using RoutingChatClient router = CreateConsumerOwnedRouter( + decisionClient, + new Dictionary(StringComparer.Ordinal) + { + ["primary"] = selectedClient, + }); + + await Assert.ThrowsAnyAsync( + () => router.GetResponseAsync([new(ChatRole.User, "hello")], cancellationToken: cancellation.Token)); + + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(0, selectedClient.CallCount); + } + + private static RoutingChatClient CreateConsumerOwnedRouter( + IDecisionClient decisionClient, + IReadOnlyDictionary clients, + Func? projectState = null) + { + projectState ??= static context => new( + context.Messages.Count(), + context.ChatOptions?.ModelId, + context.ChatOptions?.Tools is not null); + + return RoutingChatClient.Create(async (context, cancellationToken) => + { + RoutingDecisionState state = projectState(context); + DecisionResponse response = await decisionClient.GetResponseAsync( + state, + DecisionRoutingJsonContext.Default.RoutingDecisionState, + [_routeQuestion], + cancellationToken: cancellationToken); + string selectedId = ((ChoiceDecisionAnswer)response.GetAnswer("route")).SelectedCandidateId; + + if (!_routeQuestion.Candidates.Any(candidate => string.Equals(candidate.Id, selectedId, StringComparison.Ordinal)) || + !clients.TryGetValue(selectedId, out IChatClient? selected)) + { + throw new InvalidOperationException($"Decision selected unconfigured route '{selectedId}'."); + } + + return selected; + }); + } + + private sealed class RecordingDecisionClient : IDecisionClient + { + private readonly string _selectedId; + + public RecordingDecisionClient(string selectedId) + { + _selectedId = selectedId; + } + + public int CallCount { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + LastCancellationToken = cancellationToken; + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult( + new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + "route", + _selectedId, + [ + new("primary", _selectedId == "primary" ? 0.8 : 0.2), + new("secondary", _selectedId == "secondary" ? 0.8 : 0.2), + ]), + ])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + private sealed class CountingChatClient : IChatClient + { + public int CallCount { get; private set; } + + public int DisposeCount { get; private set; } + + public Func, ChatOptions?, CancellationToken, Task>? GetResponseAsyncCallback { get; set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + return GetResponseAsyncCallback?.Invoke(messages, options, cancellationToken) ?? + Task.FromResult(new ChatResponse()); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() => DisposeCount++; + } + + internal sealed record RoutingDecisionState(int MessageCount, string? ModelId, bool HasTools); +} + +[System.Text.Json.Serialization.JsonSourceGenerationOptions( + PropertyNamingPolicy = System.Text.Json.Serialization.JsonKnownNamingPolicy.CamelCase)] +[System.Text.Json.Serialization.JsonSerializable(typeof(DecisionRoutingProofTests.RoutingDecisionState))] +internal sealed partial class DecisionRoutingJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs new file mode 100644 index 00000000000..133f2f5216a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs @@ -0,0 +1,296 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 // Decision APIs are intentionally exercised by this proof. +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. +#pragma warning disable SA1118 // Reader input is kept readable in the test. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +public sealed class DecisionSemanticPipelineTests +{ + private static readonly ChoiceDecisionQuestion _question = new( + "topic", + "Choose the semantic topic of this chunk.", + [ + new DecisionCandidate("technical", "Technical content"), + new DecisionCandidate("other", "Other content"), + ]); + + [Fact] + public async Task RealReaderChunkerDecisionProcessorAndWriterPreserveAndSerialize() + { + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".md"); + File.WriteAllText( + path, + """ + # First section + + Technical content stays in the first chunk. + + ## Second section + + More content stays in the second chunk. + """); + + try + { + using RecordingDecisionClient decisionClient = new(); + CollectingWriter writer = new(); + using IngestionPipeline pipeline = new( + new MarkdownReader(), + CreateChunker(), + writer); + pipeline.ChunkProcessors.Add(new MetadataSeedProcessor()); + pipeline.ChunkProcessors.Add( + new SemanticDecisionChunkProcessor( + decisionClient, + DecisionPipelineJsonContext.Default.SemanticDecisionState, + new DecisionFeatureSchema( + "semantic-topic-v3", + 3, + [new("topic.technical", "topic", DecisionFeatureValueKind.ChoiceProbability, "technical")]))); + + IngestionResult result = await pipeline.ProcessAsync([new FileInfo(path)]).SingleAsync(); + + Assert.True(result.Succeeded); + Assert.Equal(2, decisionClient.CallCount); + Assert.Equal(2, writer.Serialized.Count); + + List records = writer.Serialized + .Select(json => JsonSerializer.Deserialize(json, DecisionPipelineJsonContext.Default.SerializedChunk)!) + .ToList(); + + Assert.All(records, record => + { + Assert.Equal(path, record.DocumentId); + Assert.Equal("preserve-me", record.UnrelatedMetadata); + Assert.Equal("semantic-topic-v3", record.FeatureSchemaId); + Assert.Equal(3, record.FeatureSchemaVersion); + Assert.Equal("test-provider", record.ProviderName); + Assert.Equal(0.12345678901234567, record.FeatureValues["topic.technical"]); + Assert.NotEmpty(record.Content); + }); + Assert.Contains(records, record => record.Context is not null); + Assert.Contains(records, record => record.Content.Contains("Technical content", StringComparison.Ordinal)); + Assert.Contains(records, record => record.Content.Contains("More content", StringComparison.Ordinal)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task DecisionProcessorReportsProviderFailureThroughIngestionResult() + { + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".md"); + File.WriteAllText(path, "# Section\n\nContent."); + + try + { + using RecordingDecisionClient decisionClient = new() + { + Failure = new DecisionClientException("decision provider failed", isTransient: false), + }; + CollectingWriter writer = new(); + using IngestionPipeline pipeline = new( + new MarkdownReader(), + CreateChunker(), + writer); + pipeline.ChunkProcessors.Add(new SemanticDecisionChunkProcessor( + decisionClient, + DecisionPipelineJsonContext.Default.SemanticDecisionState, + new DecisionFeatureSchema( + "semantic-topic-v3", + 3, + [new("topic.technical", "topic", DecisionFeatureValueKind.ChoiceProbability, "technical")]))); + + IngestionResult result = await pipeline.ProcessAsync([new FileInfo(path)]).SingleAsync(); + + Assert.False(result.Succeeded); + Assert.Same(decisionClient.Failure, result.Exception); + Assert.Empty(writer.Serialized); + } + finally + { + File.Delete(path); + } + } + + private sealed class MetadataSeedProcessor : IngestionChunkProcessor + { + public override async IAsyncEnumerable> ProcessAsync( + IAsyncEnumerable> chunks, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + chunk.Metadata["unrelated"] = "preserve-me"; + yield return chunk; + } + + } + } + + private static IngestionChunker CreateChunker() => + new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + + private sealed class SemanticDecisionChunkProcessor : IngestionChunkProcessor + { + private readonly IDecisionClient _client; + private readonly JsonTypeInfo _stateTypeInfo; + private readonly DecisionFeatureSchema _featureSchema; + + public SemanticDecisionChunkProcessor( + IDecisionClient client, + JsonTypeInfo stateTypeInfo, + DecisionFeatureSchema featureSchema) + { + _client = client; + _stateTypeInfo = stateTypeInfo; + _featureSchema = featureSchema; + } + + public override async IAsyncEnumerable> ProcessAsync( + IAsyncEnumerable> chunks, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + DecisionResponse response = await _client.GetResponseAsync( + new SemanticDecisionState(chunk.Content.Length, chunk.Context is not null), + _stateTypeInfo, + [_question], + cancellationToken: cancellationToken).ConfigureAwait(false); + DecisionFeatureVector features = DecisionFeatureProjection.Project(response, _featureSchema); + + chunk.Metadata["decision.features"] = features; + yield return chunk; + } + } + } + + private sealed class CollectingWriter : IngestionChunkWriter + { + public List Serialized { get; } = []; + + public override async Task WriteAsync( + IAsyncEnumerable> chunks, + CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (chunk.Metadata["decision.features"] is not DecisionFeatureVector features) + { + throw new InvalidOperationException("The decision feature projection was not attached."); + } + + Serialized.Add( + JsonSerializer.Serialize( + new SerializedChunk + { + Content = chunk.Content, + DocumentId = chunk.Document.Identifier, + Context = chunk.Context, + UnrelatedMetadata = (string)chunk.Metadata["unrelated"], + FeatureSchemaId = features.Schema.Id, + FeatureSchemaVersion = features.Schema.Version, + FeatureValues = features.Values.ToDictionary(static value => value.Name, static value => value.Value), + ProviderName = features.Provenance?.ProviderName, + }, + DecisionPipelineJsonContext.Default.SerializedChunk)); + } + } + } + + private sealed class RecordingDecisionClient : IDecisionClient + { + public int CallCount { get; private set; } + + public Exception? Failure { get; set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + + if (Failure is not null) + { + return Task.FromException(Failure); + } + + return Task.FromResult( + new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + "topic", + "technical", + [ + new("technical", 0.12345678901234567), + new("other", 0.8765432109876543), + ]), + ], + new DecisionProvenance(providerName: "test-provider"))); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + internal sealed class SemanticDecisionState + { + public SemanticDecisionState(int contentLength, bool hasContext) + { + ContentLength = contentLength; + HasContext = hasContext; + } + + public int ContentLength { get; } + + public bool HasContext { get; } + } + + internal sealed class SerializedChunk + { + public string Content { get; set; } = string.Empty; + + public string DocumentId { get; set; } = string.Empty; + + public string? Context { get; set; } + + public string UnrelatedMetadata { get; set; } = string.Empty; + + public string FeatureSchemaId { get; set; } = string.Empty; + + public int FeatureSchemaVersion { get; set; } + + public Dictionary FeatureValues { get; set; } = []; + + public string? ProviderName { get; set; } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(DecisionSemanticPipelineTests.SemanticDecisionState))] +[JsonSerializable(typeof(DecisionSemanticPipelineTests.SerializedChunk))] +internal sealed partial class DecisionPipelineJsonContext : JsonSerializerContext; From 4deb6b1fdb2f0b159918277b77e252e87e38adae Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Wed, 30 Sep 2026 19:28:35 -0400 Subject: [PATCH 02/12] Adapt Layer 2 proof to foundation API Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c97c7d20-076b-47b5-9a19-4068de9b4af2 --- .../Decisions/DecisionFunctionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs index 781bc80055e..c52bc7005f5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -176,7 +176,7 @@ public Task GetResponseAsync( new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, rawRepresentation: "raw-secret", - additionalProperties: [new KeyValuePair("secret", "raw-secret")])); + additionalProperties: new Dictionary { ["secret"] = "raw-secret" })); } public object? GetService(Type serviceType, object? serviceKey = null) => null; From 6f5e176378f778ed413e3a8a0a343d0a4400deee Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 11:29:40 -0400 Subject: [PATCH 03/12] Add typed decision function integration proof Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c97c7d20-076b-47b5-9a19-4068de9b4af2 --- .../Decisions/DecisionTypedFunctionTests.cs | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs new file mode 100644 index 00000000000..439f3623762 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public sealed class DecisionTypedFunctionTests +{ + [Fact] + public async Task AIFunctionFactory_RoundTripsTypedBusinessResultAndEvidence() + { + using SupportTicketDecisionClient client = new(); + DecisionResponse? observed = null; + + AIFunction function = AIFunctionFactory.Create( + async (SupportTicket ticket, CancellationToken cancellationToken) => + { + observed = await client.GetResponseAsync( + ticket, + CreateDefinition(), + cancellationToken: cancellationToken); + return observed.Result; + }, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + JsonElement input = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket); + JsonElement output = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["ticket"] = input })); + + Assert.Equal("analyze_support_ticket", function.Name); + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + + Assert.NotNull(observed); + Assert.Equal(new TicketAnalysis(TicketCategory.Technical, 0.25), observed.Result); + Assert.Equal(1, client.CallCount); + Assert.Equal( + "The product is unavailable and I need help.", + client.LastRequest!.State.GetProperty("message").GetString()); + + ChoiceDecisionQuestion category = Assert.IsType( + client.LastRequest.Questions[0]); + Assert.Equal(["Billing", "Technical", "Account"], category.Candidates.Select(static candidate => candidate.Id)); + Assert.Equal( + [ + "Payment, invoice, or charge concerns.", + "Product defects, errors, outages, or troubleshooting.", + "Sign-in, profile, or account-access concerns.", + ], + category.Candidates.Select(static candidate => candidate.Description)); + + Assert.Equal( + new Dictionary + { + [TicketCategory.Billing] = 0.2, + [TicketCategory.Technical] = 0.5, + [TicketCategory.Account] = 0.3, + }, + observed.GetDistribution(result => result.Category)); + Assert.Equal( + 0.25, + ((BinaryDecisionAnswer)observed.Evidence.GetAnswer("refundRequestProbability")).TrueProbability); + Assert.Equal("test-provider", observed.Evidence.Provenance!.ProviderName); + Assert.Equal("provider-evidence", observed.Evidence.RawRepresentation); + Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); + } + + [Fact] + public async Task AIFunctionFactory_RejectsWrongDomainResponseWithoutToolResult() + { + using SupportTicketDecisionClient client = new() { ReturnWrongDomain = true }; + DecisionResponse? observed = null; + + AIFunction function = AIFunctionFactory.Create( + async (SupportTicket ticket, CancellationToken cancellationToken) => + { + observed = await client.GetResponseAsync( + ticket, + CreateDefinition(), + cancellationToken: cancellationToken); + return observed.Result; + }, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + JsonElement input = JsonSerializer.SerializeToElement( + new SupportTicket("Please review this account issue."), + DecisionTypedFunctionJsonContext.Default.SupportTicket); + + DecisionProtocolException exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new AIFunctionArguments { ["ticket"] = input }).AsTask()); + + Assert.NotNull(exception); + Assert.Equal(1, client.CallCount); + Assert.Null(observed); + } + + private static DecisionDefinition CreateDefinition() => + DecisionDefinition.Create( + DecisionTypedFunctionJsonContext.Default.TicketAnalysis, + definition => + { + definition.Choice(result => result.Category); + definition.BinaryProbability(result => result.RefundRequestProbability); + }); + + private sealed class SupportTicketDecisionClient : IDecisionClient + { + public int CallCount { get; private set; } + + public bool ReturnWrongDomain { get; init; } + + public DecisionRequest? LastRequest { get; private set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + LastRequest = request; + + DecisionQuestion[] questions = request.Questions.ToArray(); + if (ReturnWrongDomain) + { + ChoiceDecisionQuestion choice = Assert.IsType(questions[0]); + questions[0] = new ChoiceDecisionQuestion( + choice.Id, + choice.Instructions, + [new DecisionCandidate("Other", "An unrelated domain.")]); + } + + DecisionRequest responseRequest = new(request.State, questions); + ChoiceDecisionQuestion responseChoice = Assert.IsType(responseRequest.Questions[0]); + BinaryDecisionQuestion responseBinary = Assert.IsType(responseRequest.Questions[1]); + string selectedCandidateId = responseChoice.Candidates.Count > 1 + ? responseChoice.Candidates[1].Id + : responseChoice.Candidates[0].Id; + DecisionProbability[] probabilities = responseChoice.Candidates.Select((candidate, index) => + { + double probability; + if (responseChoice.Candidates.Count == 1) + { + probability = 1; + } + else + { + probability = index switch + { + 0 => 0.2, + 1 => 0.5, + _ => 0.3, + }; + } + + return new DecisionProbability(candidate.Id, probability); + }).ToArray(); + + return Task.FromResult( + new DecisionResponse( + responseRequest, + [ + new ChoiceDecisionAnswer( + responseChoice.Id, + selectedCandidateId, + probabilities), + new BinaryDecisionAnswer(responseBinary.Id, 0.25), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model"), + rawRepresentation: "provider-evidence", + additionalProperties: new Dictionary { ["evidence"] = "retained" })); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + internal sealed record SupportTicket(string Message); + + internal enum TicketCategory + { + [Description("Payment, invoice, or charge concerns.")] + Billing, + + [Description("Product defects, errors, outages, or troubleshooting.")] + Technical, + + [Description("Sign-in, profile, or account-access concerns.")] + Account, + } + + internal sealed record TicketAnalysis( + [Description("Classify the customer's main concern.")] TicketCategory Category, + [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionTypedFunctionTests.SupportTicket))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketCategory))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketAnalysis))] +internal sealed partial class DecisionTypedFunctionJsonContext : JsonSerializerContext; From b8157a53e2860c9f05fdaf56c977d6f5f597558d Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 11:40:21 -0400 Subject: [PATCH 04/12] Align decision tool proof with direct composition Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c97c7d20-076b-47b5-9a19-4068de9b4af2 --- .../Decisions/DecisionTypedFunctionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs index 439f3623762..491d71d93d9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -18,9 +18,10 @@ namespace Microsoft.Extensions.AI; public sealed class DecisionTypedFunctionTests { [Fact] - public async Task AIFunctionFactory_RoundTripsTypedBusinessResultAndEvidence() + public async Task AIFunctionFactory_RoundTripsSourceGeneratedTypedBusinessResultAndEvidence() { using SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); DecisionResponse? observed = null; AIFunction function = AIFunctionFactory.Create( @@ -28,7 +29,7 @@ public async Task AIFunctionFactory_RoundTripsTypedBusinessResultAndEvidence() { observed = await client.GetResponseAsync( ticket, - CreateDefinition(), + definition, cancellationToken: cancellationToken); return observed.Result; }, From 967a1bc0373bfcb405bb9f87acedcc0fc7768980 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 15:33:25 -0400 Subject: [PATCH 05/12] Cover correlated decision tool responses and scores Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionFunctionTests.cs | 276 +++++++++++++++++- 1 file changed, 260 insertions(+), 16 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs index c52bc7005f5..0f12e17d94d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -107,6 +107,180 @@ await Assert.ThrowsAnyAsync( Assert.Equal("mapper failed", mapperFailure.Message); } + [Fact] + public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper() + { + int mapperCalls = 0; + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateResponse( + new DecisionRequest( + JsonSerializer.SerializeToElement( + new DecisionToolState { Payload = "different state" }, + DecisionFunctionJsonContext.Default.DecisionToolState), + request.Questions)), + }; + AIFunction function = CreateTask( + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => + { + mapperCalls++; + return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; + })).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + DecisionProtocolException exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); + + Assert.Contains("does not correspond", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, client.CallCount); + Assert.Equal(0, mapperCalls); + } + + [Fact] + public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeMapper() + { + int mapperCalls = 0; + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateResponse( + new DecisionRequest( + request.State, + [ + new ChoiceDecisionQuestion( + "route", + "Use the alternate instructions.", + [ + new DecisionCandidate("secondary", "Alternate secondary route."), + new DecisionCandidate("primary", "Alternate primary route."), + ]), + ])), + }; + AIFunction function = CreateTask( + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => + { + mapperCalls++; + return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; + })).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + DecisionProtocolException exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); + + Assert.Contains("does not correspond", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, client.CallCount); + Assert.Equal(0, mapperCalls); + } + + [Fact] + public async Task AsAIFunction_AcceptsEquivalentOwnedRequestSnapshot() + { + int mapperCalls = 0; + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateResponse( + new DecisionRequest( + request.State.Clone(), + [ + new ChoiceDecisionQuestion( + "route", + "Choose one route.", + [ + new DecisionCandidate("primary", "Use the primary route."), + new DecisionCandidate("secondary", "Use the secondary route."), + ]), + ])), + }; + AIFunction function = CreateTask( + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => + { + mapperCalls++; + return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; + })).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); + + Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); + Assert.Equal(1, client.CallCount); + Assert.Equal(1, mapperCalls); + } + + [Fact] + public async Task AsAIFunction_ProjectsExpectedScoreWithoutReportedScoreOrSecondOperation() + { + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateScoreResponse( + request, + reportedScore: null, + [0, 0, 0.01, 1]), + }; + AIFunction function = CreateScoreTask(DecisionFeatureValueKind.ExpectedScore).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments + { + ["state"] = new DecisionToolState { Payload = "sensitive request" }, + })); + + Assert.Equal(3.02, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); + Assert.Equal(1, client.CallCount); + Assert.DoesNotContain("sensitive request", resultJson.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("raw-secret", resultJson.GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public async Task AsAIFunction_RejectsMissingReportedScoreForStrictScoreFeature() + { + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateScoreResponse( + request, + reportedScore: null, + [0, 0, 0.01, 1]), + }; + AIFunction function = CreateScoreTask(DecisionFeatureValueKind.Score).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); + Assert.Equal(1, client.CallCount); + } + + [Fact] + public async Task AsAIFunction_ProjectsReportedZeroAsDistinctFromMissingScore() + { + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateScoreResponse( + request, + reportedScore: 0, + [1, 0, 0, 0]), + }; + AIFunction function = CreateScoreTask(DecisionFeatureValueKind.Score).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); + + Assert.Equal(0, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); + Assert.Equal(1, client.CallCount); + } + private static DecisionTask CreateTask( DecisionResultBinding? binding = null) { @@ -131,6 +305,82 @@ private static DecisionTask CreateTask( [new("route.primary", "route", DecisionFeatureValueKind.ChoiceProbability, "primary")])); } + private static DecisionTask CreateScoreTask( + DecisionFeatureValueKind featureValueKind) + { + ScoreDecisionQuestion question = new( + "score", + "Score the state using the ordered rubric.", + [ + new DecisionScoreLevel("zero", "Zero"), + new DecisionScoreLevel("one", "One"), + new DecisionScoreLevel("two", "Two"), + new DecisionScoreLevel("three", "Three"), + ]); + + return new( + [question], + DecisionFunctionJsonContext.Default.DecisionToolState, + new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionScoreToolResult, + answers => new() { Score = answers.GetScore("score").ExpectedScore }), + DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionScoreToolResult, + new DecisionFeatureSchema( + "decision-score-tool-v1", + 1, + [new("score.value", "score", featureValueKind)])); + } + + private static DecisionResponse CreateResponse(DecisionRequest request) + { + ChoiceDecisionQuestion question = Assert.IsType(request.Questions[0]); + double firstProbability = 0.75; + double remainingProbability = (1 - firstProbability) / (question.Candidates.Count - 1); + DecisionProbability[] probabilities = question.Candidates + .Select((candidate, index) => new DecisionProbability( + candidate.Id, + index == 0 ? firstProbability : remainingProbability)) + .ToArray(); + + return new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + question.Id, + question.Candidates[0].Id, + probabilities), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), + new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, + rawRepresentation: "raw-secret", + additionalProperties: new Dictionary { ["secret"] = "raw-secret" }); + } + + private static DecisionResponse CreateScoreResponse( + DecisionRequest request, + double? reportedScore, + double[] values) + { + ScoreDecisionQuestion question = Assert.IsType(request.Questions[0]); + DecisionProbability[] probabilities = question.Levels + .Select((level, index) => new DecisionProbability(level.Id, values[index])) + .ToArray(); + + return new DecisionResponse( + request, + [ + new ScoreDecisionAnswer( + question.Id, + reportedScore, + probabilities, + DecisionPrecision.TwoDecimalPlaces), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), + new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, + rawRepresentation: "raw-secret", + additionalProperties: new Dictionary { ["secret"] = "raw-secret" }); + } + private sealed class RecordingDecisionClient : IDecisionClient { public int CallCount { get; private set; } @@ -143,6 +393,8 @@ private sealed class RecordingDecisionClient : IDecisionClient public Exception? Failure { get; init; } + public Func? ResponseFactory { get; init; } + public Task GetResponseAsync( DecisionRequest request, DecisionOptions? options = null, @@ -161,22 +413,7 @@ public Task GetResponseAsync( return Task.FromCanceled(cancellationToken); } - return Task.FromResult( - new DecisionResponse( - request, - [ - new ChoiceDecisionAnswer( - "route", - "primary", - [ - new("primary", 0.75), - new("secondary", 0.25), - ]), - ], - new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), - new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, - rawRepresentation: "raw-secret", - additionalProperties: new Dictionary { ["secret"] = "raw-secret" })); + return Task.FromResult((ResponseFactory ?? CreateResponse)(request)); } public object? GetService(Type serviceType, object? serviceKey = null) => null; @@ -193,6 +430,11 @@ internal sealed class DecisionToolResult { public string? Selected { get; set; } } + + internal sealed class DecisionScoreToolResult + { + public double Score { get; set; } + } } [JsonSourceGenerationOptions( @@ -200,7 +442,9 @@ internal sealed class DecisionToolResult DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(DecisionFunctionTests.DecisionToolState))] [JsonSerializable(typeof(DecisionFunctionTests.DecisionToolResult))] +[JsonSerializable(typeof(DecisionFunctionTests.DecisionScoreToolResult))] [JsonSerializable(typeof(DecisionFunctionResult))] +[JsonSerializable(typeof(DecisionFunctionResult))] [JsonSerializable(typeof(DecisionFeatureVector))] [JsonSerializable(typeof(DecisionFeatureSchema))] [JsonSerializable(typeof(DecisionFeatureCoordinate))] From 2097fbad59b3abe0e97c6e6b6b82e1967e2a9dea Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 11:41:07 -0400 Subject: [PATCH 06/12] Make decision feature projection optional A useful decision tool can return a mapped result with decision metadata without requiring an analytics schema. Preserve explicit feature projection as an opt-in path and document host-owned evidence retention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionFunction.cs | 47 +++++++----- .../Decisions/README.md | 42 ++++++++++- .../Microsoft.Extensions.AI.Abstractions.json | 8 +- .../Decisions/DecisionFunctionTests.cs | 75 ++++++++++++++++--- .../Decisions/DecisionTypedFunctionTests.cs | 53 +++++++++++++ 5 files changed, 187 insertions(+), 38 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs index 2bb78526363..d14f309abed 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; @@ -36,22 +37,21 @@ public sealed class DecisionTask /// . /// /// - /// The named feature schema used to retain a portable, probability-rich projection. - /// Every coordinate must reference a question in . + /// The optional named feature schema used to retain a portable, probability-rich projection. + /// When omitted, no feature projection is performed. Every coordinate, when supplied, must + /// reference a question in . /// public DecisionTask( IEnumerable questions, JsonTypeInfo stateTypeInfo, DecisionResultBinding resultBinding, JsonTypeInfo> resultTypeInfo, - DecisionFeatureSchema featureSchema) + DecisionFeatureSchema? featureSchema = null) { _ = Throw.IfNull(questions); StateTypeInfo = Throw.IfNull(stateTypeInfo); ResultBinding = Throw.IfNull(resultBinding); ResultTypeInfo = Throw.IfNull(resultTypeInfo); - FeatureSchema = Throw.IfNull(featureSchema); - DecisionQuestion[] questionSnapshot = questions.ToArray(); if (questionSnapshot.Length == 0 || Array.Exists(questionSnapshot, static question => question is null)) { @@ -67,17 +67,21 @@ public DecisionTask( } } - foreach (DecisionFeatureCoordinate coordinate in featureSchema.Coordinates) + if (featureSchema is not null) { - if (!questionIds.Contains(coordinate.QuestionId)) + foreach (DecisionFeatureCoordinate coordinate in featureSchema.Coordinates) { - Throw.ArgumentException( - nameof(featureSchema), - $"Feature coordinate '{coordinate.Name}' references unknown question '{coordinate.QuestionId}'."); + if (!questionIds.Contains(coordinate.QuestionId)) + { + Throw.ArgumentException( + nameof(featureSchema), + $"Feature coordinate '{coordinate.Name}' references unknown question '{coordinate.QuestionId}'."); + } } } Questions = Array.AsReadOnly(questionSnapshot); + FeatureSchema = featureSchema; } /// Gets the owned question snapshot. @@ -92,8 +96,8 @@ public DecisionTask( /// Gets the explicit source-generated JSON contract for the function result. public JsonTypeInfo> ResultTypeInfo { get; } - /// Gets the named feature schema projected into the function result. - public DecisionFeatureSchema FeatureSchema { get; } + /// Gets the optional named feature schema projected into the function result. + public DecisionFeatureSchema? FeatureSchema { get; } } /// @@ -107,17 +111,17 @@ public sealed class DecisionFunctionResult /// Initializes a new instance of the class. /// /// The application-owned mapped result. - /// The portable named feature projection. + /// The optional portable named feature projection. /// The decision provider/model provenance, when reported. /// The decision usage, kept separate from chat usage. public DecisionFunctionResult( TResult result, - DecisionFeatureVector features, + DecisionFeatureVector? features = null, DecisionProvenance? provenance = null, UsageDetails? usage = null) { Result = result; - Features = Throw.IfNull(features); + Features = features; Provenance = provenance; Usage = usage; } @@ -125,8 +129,9 @@ public DecisionFunctionResult( /// Gets the application-owned mapped result. public TResult Result { get; } - /// Gets the portable named feature projection. - public DecisionFeatureVector Features { get; } + /// Gets the optional portable named feature projection. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DecisionFeatureVector? Features { get; } /// Gets decision provider/model provenance, when reported. public DecisionProvenance? Provenance { get; } @@ -151,12 +156,12 @@ public static class DecisionFunctionExtensions /// An invocable function whose only input is . /// /// - /// The returned function closes over the questions, feature schema, mapper, and client. It performs exactly one + /// The returned function closes over the questions, optional feature schema, mapper, and client. It performs exactly one /// decision request per invocation, propagates provider, mapper, and cancellation failures, and never disposes /// the caller-owned client. /// /// - /// The default result contains only the explicitly mapped result, named native-double features, provenance, and + /// The result contains the explicitly mapped result, optional named native-double features, provenance, and /// usage. Raw state, raw provider objects, reasoning text, and provider extension dictionaries are not included. /// The supplied must contain source-generated metadata /// for and when reflection-based JSON @@ -200,7 +205,9 @@ async Task> InvokeAsync( cancellationToken).ConfigureAwait(false); TResult result = response.Bind(task.ResultBinding); - DecisionFeatureVector features = DecisionFeatureProjection.Project(response, task.FeatureSchema); + DecisionFeatureVector? features = task.FeatureSchema is null + ? null + : DecisionFeatureProjection.Project(response, task.FeatureSchema); return new(result, features, response.Provenance, response.Usage); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index b52f171e6c0..22d36643cdc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -152,7 +152,7 @@ This layer intentionally does not define tools, routing composition, MEDI proces ## Layer 2 interoperability -### Prefer the existing MEAI path for a result-only business tool +### A useful decision tool needs no analytics contract When a tool only needs the business result, the simplest composition is the existing `AIFunctionFactory.Create` plus an ordinary host-owned delegate. With the `client` and `definition` from the business example above, the tool keeps the complete response in the host and returns only the mapped result: @@ -182,7 +182,41 @@ AIFunction function = AIFunctionFactory.Create( }); ``` -The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. This path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. +The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. A useful decision tool needs no analytics contract: this path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. + +If the tool should return selected decision metadata as well as the business result, keep that output type application-owned and retain the complete evidence separately: + +```csharp +public sealed record TicketToolOutput( + TicketAnalysis Result, + DecisionProvenance? Provenance, + UsageDetails? Usage); + +DecisionResponse? observed = null; +Func> analyzeWithMetadata = + async (ticket, cancellationToken) => + { + observed = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + // The host's retained reference is the evidence boundary for reporting or review. + return new( + observed.Result, + observed.Evidence.Provenance, + observed.Evidence.Usage); + }; + +AIFunction metadataFunction = AIFunctionFactory.Create( + analyzeWithMetadata, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket_with_metadata", + }); +``` + +Decision provenance and usage describe the decision operation; they do not execute or authorize a business action. The host decides separately whether to persist evidence, perform an action, or record an outcome.
Reflection-disabled/source-generated JSON variant @@ -229,11 +263,11 @@ This is still ordinary delegate composition; the extra metadata is the deploymen
Optional probability-feature export with the proposed helper -For a host that explicitly needs portable probability features, provenance, and separate decision usage in the tool result, the existing Layer 2 `DecisionTask.AsAIFunction` proof remains available. It closes over explicit questions, mapper, feature schema, source-generated state/result contracts, and the caller-owned `IDecisionClient`, performs one decision call, verifies that the response corresponds to the outgoing state and ordered question definitions before mapping or projection, and returns bounded mapped output with named native-`double` features. The advanced sample is optional and is not required by `AIFunctionFactory`, `JsonTypeInfo`, or the .NET platform. +For a host that explicitly needs portable probability features, provenance, and separate decision usage in the tool result, the existing Layer 2 `DecisionTask.AsAIFunction` proof remains available. Its `featureSchema` argument is optional: omitting it returns the mapped result, provenance, and usage without invoking feature projection, while supplying it is an explicit extension that returns bounded named native-`double` features. The helper closes over explicit questions, mapper, source-generated state/result contracts, and the caller-owned `IDecisionClient`, performs one decision call, verifies that the response corresponds to the outgoing state and ordered question definitions before mapping or projection, and returns safe output. The advanced feature path is optional and is not required by `AIFunctionFactory`, `JsonTypeInfo`, or the .NET platform.
-The current `DecisionTask` contract intentionally requires a feature schema and returns its feature vector even when a business tool only needs `TicketAnalysis`. That is an ergonomics simplification candidate under review, not a requirement of `AIFunction`, `JsonTypeInfo`, or the .NET platform. The feature schema remains explicit for hosts that want to retain probability-rich evidence for reporting or review; it does not authorize a refund, calibrate a score, or replace the business result. +When a `DecisionTask` has no feature schema, `DecisionFunctionResult.Features` is null and the normal JSON contract omits the `features` property. No empty vector or fallback projection is fabricated. Supplying a schema preserves the existing coordinate validation, native `double` values, ordering, and failure behavior; a strict reported-score coordinate still fails when the provider did not report that scalar. The same explicit feature projection can be used as optional downstream MEDI enrichment. A consumer-owned semantic chunk processor can run after the existing document reader and chunker, ask a host-defined decision question for each bounded chunk, and attach versioned named `double` coordinates, provenance, document identity/context, and unrelated metadata before the existing collecting or serializing writer. The proof uses the current reader -> chunker -> processor -> writer primitives and preserves bounded streaming, cancellation, and failure propagation; it does not add a reusable MEDI processor or change `ClassificationEnricher` or batching. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index c783a4cb8bd..505cbf9afc9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5383,13 +5383,13 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.DecisionFunctionResult.DecisionFunctionResult(TResult result, Microsoft.Extensions.AI.DecisionFeatureVector features, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null);", + "Member": "Microsoft.Extensions.AI.DecisionFunctionResult.DecisionFunctionResult(TResult result, Microsoft.Extensions.AI.DecisionFeatureVector? features = null, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null);", "Stage": "Experimental" } ], "Properties": [ { - "Member": "Microsoft.Extensions.AI.DecisionFeatureVector Microsoft.Extensions.AI.DecisionFunctionResult.Features { get; }", + "Member": "Microsoft.Extensions.AI.DecisionFeatureVector? Microsoft.Extensions.AI.DecisionFunctionResult.Features { get; }", "Stage": "Experimental" }, { @@ -5411,13 +5411,13 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.DecisionTask.DecisionTask(System.Collections.Generic.IEnumerable questions, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, Microsoft.Extensions.AI.DecisionResultBinding resultBinding, System.Text.Json.Serialization.Metadata.JsonTypeInfo> resultTypeInfo, Microsoft.Extensions.AI.DecisionFeatureSchema featureSchema);", + "Member": "Microsoft.Extensions.AI.DecisionTask.DecisionTask(System.Collections.Generic.IEnumerable questions, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, Microsoft.Extensions.AI.DecisionResultBinding resultBinding, System.Text.Json.Serialization.Metadata.JsonTypeInfo> resultTypeInfo, Microsoft.Extensions.AI.DecisionFeatureSchema? featureSchema = null);", "Stage": "Experimental" } ], "Properties": [ { - "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema Microsoft.Extensions.AI.DecisionTask.FeatureSchema { get; }", + "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema? Microsoft.Extensions.AI.DecisionTask.FeatureSchema { get; }", "Stage": "Experimental" }, { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs index 0f12e17d94d..07cdeae33da 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -64,6 +64,34 @@ public async Task AsAIFunction_UsesBoundedSchemaAndPortableResult() Assert.Equal(0, client.DisposeCount); } + [Fact] + public async Task AsAIFunction_ReturnsMappedResultAndMetadataWithoutFeatureSchema() + { + using RecordingDecisionClient client = new(); + DecisionTask task = CreateTask(includeFeatureSchema: false); + DecisionOptions options = new() { ModelId = "decision-model" }; + AIFunction function = task.AsAIFunction( + client, + options, + new AIFunctionFactoryOptions + { + SerializerOptions = DecisionFunctionJsonContext.Default.Options, + }); + options.ModelId = "changed-after-creation"; + + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); + + Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); + Assert.False(resultJson.TryGetProperty("features", out _)); + Assert.Equal("test-provider", resultJson.GetProperty("provenance").GetProperty("providerName").GetString()); + Assert.Equal(12, resultJson.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); + Assert.Null(task.FeatureSchema); + Assert.Equal(1, client.CallCount); + Assert.Equal("decision-model", client.LastOptions!.ModelId); + Assert.Equal(0, client.DisposeCount); + } + [Fact] public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures() { @@ -241,6 +269,28 @@ await function.InvokeAsync(new AIFunctionArguments Assert.DoesNotContain("raw-secret", resultJson.GetRawText(), StringComparison.Ordinal); } + [Fact] + public async Task AsAIFunction_SkipsFeatureProjectionWhenSchemaIsOmitted() + { + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateScoreResponse( + request, + reportedScore: null, + [0, 0, 0.01, 1]), + }; + AIFunction function = CreateScoreTask(featureValueKind: null).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + + JsonElement resultJson = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); + + Assert.Equal(3.02, resultJson.GetProperty("result").GetProperty("score").GetDouble()); + Assert.False(resultJson.TryGetProperty("features", out _)); + Assert.Equal(1, client.CallCount); + } + [Fact] public async Task AsAIFunction_RejectsMissingReportedScoreForStrictScoreFeature() { @@ -282,7 +332,8 @@ public async Task AsAIFunction_ProjectsReportedZeroAsDistinctFromMissingScore() } private static DecisionTask CreateTask( - DecisionResultBinding? binding = null) + DecisionResultBinding? binding = null, + bool includeFeatureSchema = true) { ChoiceDecisionQuestion question = new( "route", @@ -299,14 +350,16 @@ private static DecisionTask CreateTask( DecisionFunctionJsonContext.Default.DecisionToolResult, answers => new() { Selected = answers.GetChoice("route").SelectedCandidateId }), DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult, - new DecisionFeatureSchema( - "decision-tool-v1", - 1, - [new("route.primary", "route", DecisionFeatureValueKind.ChoiceProbability, "primary")])); + includeFeatureSchema + ? new DecisionFeatureSchema( + "decision-tool-v1", + 1, + [new("route.primary", "route", DecisionFeatureValueKind.ChoiceProbability, "primary")]) + : null); } private static DecisionTask CreateScoreTask( - DecisionFeatureValueKind featureValueKind) + DecisionFeatureValueKind? featureValueKind) { ScoreDecisionQuestion question = new( "score", @@ -325,10 +378,12 @@ private static DecisionTask CreateSc DecisionFunctionJsonContext.Default.DecisionScoreToolResult, answers => new() { Score = answers.GetScore("score").ExpectedScore }), DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionScoreToolResult, - new DecisionFeatureSchema( - "decision-score-tool-v1", - 1, - [new("score.value", "score", featureValueKind)])); + featureValueKind is null + ? null + : new DecisionFeatureSchema( + "decision-score-tool-v1", + 1, + [new("score.value", "score", featureValueKind.Value)])); } private static DecisionResponse CreateResponse(DecisionRequest request) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs index 491d71d93d9..d31e6117b2e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -83,6 +83,50 @@ public async Task AIFunctionFactory_RoundTripsSourceGeneratedTypedBusinessResult Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); } + [Fact] + public async Task AIFunctionFactory_CanReturnMetadataWithoutAnAnalyticsContract() + { + using SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); + DecisionResponse? observed = null; + + AIFunction function = AIFunctionFactory.Create( + async (SupportTicket ticket, CancellationToken cancellationToken) => + { + observed = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + return new MetadataOnlyToolResult( + observed.Result, + observed.Evidence.Provenance, + observed.Evidence.Usage); + }, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + JsonElement output = Assert.IsType( + await function.InvokeAsync( + new AIFunctionArguments + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + })); + + Assert.Equal("Technical", output.GetProperty("result").GetProperty("category").GetString()); + Assert.Equal("test-provider", output.GetProperty("provenance").GetProperty("providerName").GetString()); + Assert.Equal(7, output.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); + Assert.NotNull(observed); + Assert.Equal("provider-evidence", observed.Evidence.RawRepresentation); + Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); + Assert.Equal(1, client.CallCount); + } + [Fact] public async Task AIFunctionFactory_RejectsWrongDomainResponseWithoutToolResult() { @@ -188,6 +232,7 @@ public Task GetResponseAsync( new BinaryDecisionAnswer(responseBinary.Id, 0.25), ], new DecisionProvenance(providerName: "test-provider", modelId: "test-model"), + new UsageDetails { InputTokenCount = 7, OutputTokenCount = 2 }, rawRepresentation: "provider-evidence", additionalProperties: new Dictionary { ["evidence"] = "retained" })); } @@ -216,6 +261,11 @@ internal enum TicketCategory internal sealed record TicketAnalysis( [Description("Classify the customer's main concern.")] TicketCategory Category, [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); + + internal sealed record MetadataOnlyToolResult( + TicketAnalysis Result, + DecisionProvenance? Provenance, + UsageDetails? Usage); } [JsonSourceGenerationOptions( @@ -224,4 +274,7 @@ internal sealed record TicketAnalysis( [JsonSerializable(typeof(DecisionTypedFunctionTests.SupportTicket))] [JsonSerializable(typeof(DecisionTypedFunctionTests.TicketCategory))] [JsonSerializable(typeof(DecisionTypedFunctionTests.TicketAnalysis))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.MetadataOnlyToolResult))] +[JsonSerializable(typeof(DecisionProvenance))] +[JsonSerializable(typeof(UsageDetails))] internal sealed partial class DecisionTypedFunctionJsonContext : JsonSerializerContext; From 1f465bef49e3ce2391f0f06dd1eef039366edd59 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 11:52:11 -0400 Subject: [PATCH 07/12] Strengthen optional decision function coverage Cover response correlation and failure propagation with and without feature projection, verify source-generated result round-tripping, and assert metadata-only tool safety and evidence retention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionFunctionTests.cs | 165 +++++++++++++++--- .../Decisions/DecisionTypedFunctionTests.cs | 7 + 2 files changed, 143 insertions(+), 29 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs index 07cdeae33da..ad8612e164c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -93,7 +93,89 @@ public async Task AsAIFunction_ReturnsMappedResultAndMetadataWithoutFeatureSchem } [Fact] - public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures() + public void DecisionTask_UsesFourArgumentConstructorWhenFeatureSchemaIsOmitted() + { + DecisionTask task = CreateTask(omitFeatureSchema: true); + + Assert.Null(task.FeatureSchema); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task AsAIFunction_SourceGeneratedResultRoundTripsOptionalFeaturesAndMetadata( + bool includeFeatureSchema, + bool includeMetadata) + { + using RecordingDecisionClient client = new() + { + ResponseFactory = request => CreateResponse(request, includeMetadata), + }; + DecisionTask task = CreateTask(includeFeatureSchema: includeFeatureSchema); + DecisionFunctionResult? captured = null; + AIFunction function = task.AsAIFunction( + client, + functionOptions: new() + { + SerializerOptions = DecisionFunctionJsonContext.Default.Options, + MarshalResult = (value, _, _) => + { + captured = Assert.IsType>(value); + return new(value); + }, + }); + + object? output = await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }); + Assert.Same(captured, output); + Assert.NotNull(captured); + DecisionFunctionResult result = captured!; + JsonElement resultJson = JsonSerializer.SerializeToElement( + result, + DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); + + if (includeFeatureSchema) + { + DecisionFeatureVector? features = result.Features; + Assert.NotNull(features); + Assert.Equal("decision-tool-v1", features.Schema.Id); + Assert.Equal("route.primary", features.Values[0].Name); + Assert.Equal(0.75, features.Values[0].Value); + } + else + { + DecisionFunctionResult? roundTripped = JsonSerializer.Deserialize( + resultJson, + DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); + Assert.NotNull(roundTripped); + Assert.Null(roundTripped!.Features); + Assert.False(resultJson.TryGetProperty("features", out _)); + } + + if (includeMetadata) + { + DecisionProvenance? provenance = result.Provenance; + UsageDetails? usage = result.Usage; + Assert.NotNull(provenance); + Assert.NotNull(usage); + Assert.Equal("test-provider", provenance.ProviderName); + Assert.Equal("test-model", provenance.ModelId); + Assert.Equal(12, usage.InputTokenCount); + Assert.Equal(3, usage.OutputTokenCount); + } + else + { + Assert.Null(result.Provenance); + Assert.Null(result.Usage); + Assert.False(resultJson.TryGetProperty("provenance", out _)); + Assert.False(resultJson.TryGetProperty("usage", out _)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures(bool includeFeatureSchema) { using CancellationTokenSource cancellation = new(); cancellation.Cancel(); @@ -101,7 +183,7 @@ public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures() { Cancellation = true, }; - AIFunction cancelledFunction = CreateTask().AsAIFunction( + AIFunction cancelledFunction = CreateTask(includeFeatureSchema: includeFeatureSchema).AsAIFunction( cancelledClient, functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); @@ -115,28 +197,33 @@ await Assert.ThrowsAnyAsync( { Failure = new DecisionClientException("provider failed", isTransient: true), }; - AIFunction failedFunction = CreateTask().AsAIFunction( + AIFunction failedFunction = CreateTask(includeFeatureSchema: includeFeatureSchema).AsAIFunction( failedClient, functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); DecisionClientException providerFailure = await Assert.ThrowsAsync( () => failedFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); Assert.True(providerFailure.IsTransient); + Assert.Equal(1, failedClient.CallCount); using RecordingDecisionClient mapperClient = new(); AIFunction mapperFunction = CreateTask( new DecisionResultBinding( DecisionFunctionJsonContext.Default.DecisionToolResult, - _ => throw new InvalidOperationException("mapper failed"))).AsAIFunction( - mapperClient, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + _ => throw new InvalidOperationException("mapper failed")), + includeFeatureSchema).AsAIFunction( + mapperClient, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); InvalidOperationException mapperFailure = await Assert.ThrowsAsync( () => mapperFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); Assert.Equal("mapper failed", mapperFailure.Message); + Assert.Equal(1, mapperClient.CallCount); } - [Fact] - public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper(bool includeFeatureSchema) { int mapperCalls = 0; using RecordingDecisionClient client = new() @@ -155,9 +242,10 @@ public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper() { mapperCalls++; return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - })).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + }), + includeFeatureSchema).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); DecisionProtocolException exception = await Assert.ThrowsAsync( () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); @@ -167,8 +255,10 @@ public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper() Assert.Equal(0, mapperCalls); } - [Fact] - public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeMapper() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeMapper(bool includeFeatureSchema) { int mapperCalls = 0; using RecordingDecisionClient client = new() @@ -193,9 +283,10 @@ public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeM { mapperCalls++; return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - })).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + }), + includeFeatureSchema).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); DecisionProtocolException exception = await Assert.ThrowsAsync( () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); @@ -205,8 +296,10 @@ public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeM Assert.Equal(0, mapperCalls); } - [Fact] - public async Task AsAIFunction_AcceptsEquivalentOwnedRequestSnapshot() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AsAIFunction_AcceptsEquivalentOwnedRequestSnapshot(bool includeFeatureSchema) { int mapperCalls = 0; using RecordingDecisionClient client = new() @@ -231,9 +324,10 @@ public async Task AsAIFunction_AcceptsEquivalentOwnedRequestSnapshot() { mapperCalls++; return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - })).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); + }), + includeFeatureSchema).AsAIFunction( + client, + functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); JsonElement resultJson = Assert.IsType( await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); @@ -333,7 +427,8 @@ public async Task AsAIFunction_ProjectsReportedZeroAsDistinctFromMissingScore() private static DecisionTask CreateTask( DecisionResultBinding? binding = null, - bool includeFeatureSchema = true) + bool includeFeatureSchema = true, + bool omitFeatureSchema = false) { ChoiceDecisionQuestion question = new( "route", @@ -343,12 +438,24 @@ private static DecisionTask CreateTask( new DecisionCandidate("secondary", "Use the secondary route."), ]); + DecisionResultBinding effectiveBinding = binding + ?? new DecisionResultBinding( + DecisionFunctionJsonContext.Default.DecisionToolResult, + answers => new() { Selected = answers.GetChoice("route").SelectedCandidateId }); + + if (omitFeatureSchema) + { + return new( + [question], + DecisionFunctionJsonContext.Default.DecisionToolState, + effectiveBinding, + DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); + } + return new( [question], DecisionFunctionJsonContext.Default.DecisionToolState, - binding ?? new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => new() { Selected = answers.GetChoice("route").SelectedCandidateId }), + effectiveBinding, DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult, includeFeatureSchema ? new DecisionFeatureSchema( @@ -386,7 +493,7 @@ featureValueKind is null [new("score.value", "score", featureValueKind.Value)])); } - private static DecisionResponse CreateResponse(DecisionRequest request) + private static DecisionResponse CreateResponse(DecisionRequest request, bool includeMetadata = true) { ChoiceDecisionQuestion question = Assert.IsType(request.Questions[0]); double firstProbability = 0.75; @@ -405,8 +512,8 @@ private static DecisionResponse CreateResponse(DecisionRequest request) question.Candidates[0].Id, probabilities), ], - new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), - new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, + includeMetadata ? new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response") : null, + includeMetadata ? new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 } : null, rawRepresentation: "raw-secret", additionalProperties: new Dictionary { ["secret"] = "raw-secret" }); } @@ -468,7 +575,7 @@ public Task GetResponseAsync( return Task.FromCanceled(cancellationToken); } - return Task.FromResult((ResponseFactory ?? CreateResponse)(request)); + return Task.FromResult((ResponseFactory ?? (request => CreateResponse(request)))(request)); } public object? GetService(Type serviceType, object? serviceKey = null) => null; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs index d31e6117b2e..18a7ad1034e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -119,8 +119,15 @@ await function.InvokeAsync( })); Assert.Equal("Technical", output.GetProperty("result").GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("result").GetProperty("refundRequestProbability").GetDouble()); Assert.Equal("test-provider", output.GetProperty("provenance").GetProperty("providerName").GetString()); + Assert.Equal("test-model", output.GetProperty("provenance").GetProperty("modelId").GetString()); Assert.Equal(7, output.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); + Assert.Equal(2, output.GetProperty("usage").GetProperty("outputTokenCount").GetInt32()); + Assert.False(output.TryGetProperty("features", out _)); + Assert.DoesNotContain("provider-evidence", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("retained", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("The product is unavailable", output.GetRawText(), StringComparison.Ordinal); Assert.NotNull(observed); Assert.Equal("provider-evidence", observed.Evidence.RawRepresentation); Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); From 746b231ddeff7b3b42421cc05e29795e26a82921 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 12:02:26 -0400 Subject: [PATCH 08/12] Clarify decision result documentation Document per-invocation evidence callbacks and correct the source-generated optional-feature test semantics without changing production APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/README.md | 59 +++++++++++-------- .../Decisions/DecisionFunctionTests.cs | 20 ++++++- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 22d36643cdc..3290fd2f193 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -166,9 +166,6 @@ Func> analyze = definition, cancellationToken: cancellationToken); - // Evidence remains available to the host for reporting or review. - DecisionResponse evidence = response.Evidence; - return response.Result; }; @@ -182,7 +179,7 @@ AIFunction function = AIFunctionFactory.Create( }); ``` -The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. A useful decision tool needs no analytics contract: this path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. +The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. The complete evidence is available inside the delegate; retaining it requires an explicit host callback such as the metadata example below. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. A useful decision tool needs no analytics contract: this path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. If the tool should return selected decision metadata as well as the business result, keep that output type application-owned and retain the complete evidence separately: @@ -191,31 +188,43 @@ public sealed record TicketToolOutput( TicketAnalysis Result, DecisionProvenance? Provenance, UsageDetails? Usage); +``` -DecisionResponse? observed = null; -Func> analyzeWithMetadata = - async (ticket, cancellationToken) => - { - observed = await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); - - // The host's retained reference is the evidence boundary for reporting or review. - return new( - observed.Result, - observed.Evidence.Provenance, - observed.Evidence.Usage); - }; - -AIFunction metadataFunction = AIFunctionFactory.Create( - analyzeWithMetadata, - new AIFunctionFactoryOptions +```csharp +public static class SupportTicketTools +{ + public static AIFunction CreateMetadataFunction( + IDecisionClient client, + DecisionDefinition definition, + Action recordEvidence) { - Name = "analyze_support_ticket_with_metadata", - }); + Func> analyzeWithMetadata = + async (ticket, cancellationToken) => + { + DecisionResponse response = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + recordEvidence(response.Evidence); + return new( + response.Result, + response.Evidence.Provenance, + response.Evidence.Usage); + }; + + return AIFunctionFactory.Create( + analyzeWithMetadata, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket_with_metadata", + }); + } +} ``` +Here `recordEvidence` is an application-owned `Action` supplied by the host and invoked once for each response; it is not shared production state holding the last response. The DTO remains bounded and excludes raw evidence from tool output. + Decision provenance and usage describe the decision operation; they do not execute or authorize a business action. The host decides separately whether to persist evidence, perform an action, or record an outcome.
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs index ad8612e164c..c9aa905e0ad 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs @@ -104,7 +104,7 @@ public void DecisionTask_UsesFourArgumentConstructorWhenFeatureSchemaIsOmitted() [InlineData(true, true)] [InlineData(false, true)] [InlineData(false, false)] - public async Task AsAIFunction_SourceGeneratedResultRoundTripsOptionalFeaturesAndMetadata( + public async Task AsAIFunction_SourceGeneratedResultPreservesFeaturesAndRoundTripsMetadataOnlyOutput( bool includeFeatureSchema, bool includeMetadata) { @@ -141,6 +141,8 @@ public async Task AsAIFunction_SourceGeneratedResultRoundTripsOptionalFeaturesAn Assert.Equal("decision-tool-v1", features.Schema.Id); Assert.Equal("route.primary", features.Values[0].Name); Assert.Equal(0.75, features.Values[0].Value); + Assert.Equal("route.primary", resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("name").GetString()); + Assert.Equal(0.75, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); } else { @@ -148,8 +150,24 @@ public async Task AsAIFunction_SourceGeneratedResultRoundTripsOptionalFeaturesAn resultJson, DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); Assert.NotNull(roundTripped); + Assert.Equal("primary", roundTripped!.Result.Selected); Assert.Null(roundTripped!.Features); Assert.False(resultJson.TryGetProperty("features", out _)); + + if (includeMetadata) + { + Assert.Equal("test-provider", roundTripped.Provenance!.ProviderName); + Assert.Equal("test-model", roundTripped.Provenance.ModelId); + Assert.Equal(12, roundTripped.Usage!.InputTokenCount); + Assert.Equal(3, roundTripped.Usage.OutputTokenCount); + } + else + { + Assert.Null(roundTripped.Provenance); + Assert.Null(roundTripped.Usage); + Assert.False(resultJson.TryGetProperty("provenance", out _)); + Assert.False(resultJson.TryGetProperty("usage", out _)); + } } if (includeMetadata) From 9f77c49ea794d7e94c9945d77ed9b3869a93ee68 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 13:08:55 -0400 Subject: [PATCH 09/12] Simplify Layer 2 decision interop Replace the redundant decision-specific function helper with stock AIFunctionFactory composition and strengthen the consumer-owned typed routing proof. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionFunction.cs | 214 ------ .../Decisions/README.md | 310 ++++----- .../Microsoft.Extensions.AI.Abstractions.json | 70 -- .../Decisions/DecisionFunctionTests.cs | 634 ------------------ .../Decisions/DecisionRoutingProofTests.cs | 331 ++++++--- .../Decisions/DecisionTypedFunctionTests.cs | 350 ++++++---- 6 files changed, 580 insertions(+), 1329 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs deleted file mode 100644 index d14f309abed..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFunction.cs +++ /dev/null @@ -1,214 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#pragma warning disable SA1402 // Decision task contracts are co-located as one API surface. -#pragma warning disable SA1649 // Decision task contracts are co-located as one API surface. -#pragma warning disable SA1204 // Static extension members are intentionally grouped after the task contracts. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Describes a host-defined decision task that can be exposed as one bounded AI function invocation. -/// -/// The application-owned state type supplied to the decision provider. -/// The application-owned result type produced by the local mapper. -[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class DecisionTask -{ - /// - /// Initializes a new instance of the class. - /// - /// The nonempty, ordered questions evaluated by the task. - /// The source-generated JSON contract for . - /// The explicit mapper for the complete decision response. - /// - /// The source-generated JSON contract for the safe function result, including - /// . - /// - /// - /// The optional named feature schema used to retain a portable, probability-rich projection. - /// When omitted, no feature projection is performed. Every coordinate, when supplied, must - /// reference a question in . - /// - public DecisionTask( - IEnumerable questions, - JsonTypeInfo stateTypeInfo, - DecisionResultBinding resultBinding, - JsonTypeInfo> resultTypeInfo, - DecisionFeatureSchema? featureSchema = null) - { - _ = Throw.IfNull(questions); - StateTypeInfo = Throw.IfNull(stateTypeInfo); - ResultBinding = Throw.IfNull(resultBinding); - ResultTypeInfo = Throw.IfNull(resultTypeInfo); - DecisionQuestion[] questionSnapshot = questions.ToArray(); - if (questionSnapshot.Length == 0 || Array.Exists(questionSnapshot, static question => question is null)) - { - Throw.ArgumentException(nameof(questions), "Questions must be nonempty."); - } - - HashSet questionIds = new(StringComparer.Ordinal); - foreach (DecisionQuestion question in questionSnapshot) - { - if (!questionIds.Add(question.Id)) - { - Throw.ArgumentException(nameof(questions), "Question IDs must be unique using ordinal comparison."); - } - } - - if (featureSchema is not null) - { - foreach (DecisionFeatureCoordinate coordinate in featureSchema.Coordinates) - { - if (!questionIds.Contains(coordinate.QuestionId)) - { - Throw.ArgumentException( - nameof(featureSchema), - $"Feature coordinate '{coordinate.Name}' references unknown question '{coordinate.QuestionId}'."); - } - } - } - - Questions = Array.AsReadOnly(questionSnapshot); - FeatureSchema = featureSchema; - } - - /// Gets the owned question snapshot. - public IReadOnlyList Questions { get; } - - /// Gets the explicit source-generated JSON contract for the task state. - public JsonTypeInfo StateTypeInfo { get; } - - /// Gets the explicit mapper for the complete response. - public DecisionResultBinding ResultBinding { get; } - - /// Gets the explicit source-generated JSON contract for the function result. - public JsonTypeInfo> ResultTypeInfo { get; } - - /// Gets the optional named feature schema projected into the function result. - public DecisionFeatureSchema? FeatureSchema { get; } -} - -/// -/// Represents the safe result of a decision function invocation. -/// -/// The application-owned result type produced by the local mapper. -[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class DecisionFunctionResult -{ - /// - /// Initializes a new instance of the class. - /// - /// The application-owned mapped result. - /// The optional portable named feature projection. - /// The decision provider/model provenance, when reported. - /// The decision usage, kept separate from chat usage. - public DecisionFunctionResult( - TResult result, - DecisionFeatureVector? features = null, - DecisionProvenance? provenance = null, - UsageDetails? usage = null) - { - Result = result; - Features = features; - Provenance = provenance; - Usage = usage; - } - - /// Gets the application-owned mapped result. - public TResult Result { get; } - - /// Gets the optional portable named feature projection. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DecisionFeatureVector? Features { get; } - - /// Gets decision provider/model provenance, when reported. - public DecisionProvenance? Provenance { get; } - - /// Gets decision usage, kept separate from chat usage. - public UsageDetails? Usage { get; } -} - -/// Provides interoperability helpers for decision tasks. -[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] -public static class DecisionFunctionExtensions -{ - /// - /// Creates one bounded that evaluates the supplied decision task. - /// - /// The application-owned state type accepted by the function. - /// The application-owned mapped result type. - /// The explicit decision task definition. - /// The caller-owned decision client to invoke. - /// Optional request options captured for each invocation. - /// Optional AIFunction metadata and source-generated serializer options. - /// An invocable function whose only input is . - /// - /// - /// The returned function closes over the questions, optional feature schema, mapper, and client. It performs exactly one - /// decision request per invocation, propagates provider, mapper, and cancellation failures, and never disposes - /// the caller-owned client. - /// - /// - /// The result contains the explicitly mapped result, optional named native-double features, provenance, and - /// usage. Raw state, raw provider objects, reasoning text, and provider extension dictionaries are not included. - /// The supplied must contain source-generated metadata - /// for and when reflection-based JSON - /// serialization is disabled. - /// - /// - public static AIFunction AsAIFunction( - this DecisionTask task, - IDecisionClient client, - DecisionOptions? decisionOptions = null, - AIFunctionFactoryOptions? functionOptions = null) - { - _ = Throw.IfNull(task); - _ = Throw.IfNull(client); - - DecisionOptions? optionsSnapshot = decisionOptions?.Clone(); - AIFunctionFactoryOptions effectiveOptions = new() - { - Name = functionOptions?.Name ?? "decision", - Description = functionOptions?.Description, - SerializerOptions = functionOptions?.SerializerOptions ?? task.ResultTypeInfo.Options, - JsonSchemaCreateOptions = functionOptions?.JsonSchemaCreateOptions, - AdditionalProperties = functionOptions?.AdditionalProperties, - ConfigureParameterBinding = functionOptions?.ConfigureParameterBinding, - MarshalResult = functionOptions?.MarshalResult, - ExcludeResultSchema = functionOptions?.ExcludeResultSchema ?? false, - }; - - Func>> invoke = InvokeAsync; - return AIFunctionFactory.Create(invoke, effectiveOptions); - - async Task> InvokeAsync( - TState state, - CancellationToken cancellationToken) - { - DecisionResponse response = await client.GetResponseAsync( - state, - task.StateTypeInfo, - task.Questions, - optionsSnapshot, - cancellationToken).ConfigureAwait(false); - - TResult result = response.Bind(task.ResultBinding); - DecisionFeatureVector? features = task.FeatureSchema is null - ? null - : DecisionFeatureProjection.Project(response, task.FeatureSchema); - return new(result, features, response.Provenance, response.Usage); - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 3290fd2f193..600143c593a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -1,12 +1,12 @@ # Experimental decision abstractions -The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral proposed Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, probability repair, retry policy, or a joint-distribution promise. +The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order. Responses retain complete probability observations, score precision, provenance, usage, and provider evidence without applying thresholds, calibration, probability repair, retry policy, or a joint-distribution promise. ## A business example -Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and the complete provider evidence for reporting, human review, or downstream training. It is not automatically authorizing a refund, choosing a route, or treating a probability as a calibrated business decision. +Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and complete provider evidence for reporting or human review. It is not automatically authorizing a refund, choosing a route, or treating a probability as a calibrated business decision. -The domain types remain ordinary application-owned types. The standard `DescriptionAttribute` on the positional record parameters supplies business instructions for the selected result properties, while the enum members supply separate candidate criteria: +The domain types remain ordinary application-owned types: ```csharp #pragma warning disable MEAI001 @@ -32,254 +32,188 @@ public sealed record TicketAnalysis( [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); ``` -The business call is ordinary application code. Assume the host supplies the `IDecisionClient client`; the declaration is reusable and explicit. `Choice` declares the closed enum domain and uses its configured JSON enum names as exact candidate IDs; `BinaryProbability` maps the binary answer's `TrueProbability` to a `double`. No handwritten question IDs, enum dictionary, result mapper, JSON schema, or feature schema is required for this ordinary flow: +The host explicitly supplies an `IDecisionClient`. It is provider-neutral; MEAI does not create a default implementation and an arbitrary `IChatClient` is not automatically a decision client. In the external, unpublished provider proof, acquisition is separate from platform composition: ```csharp -using Microsoft.Extensions.AI; +// External proof/provider packages only; not shipped by Microsoft.Extensions.AI. +using DecisionInference.Julia; +using DecisionInference.MEAI; -var definition = DecisionDefinition.Create(builder => -{ - builder.Choice(result => result.Category); - builder.BinaryProbability(result => result.RefundRequestProbability); -}); - -var ticket = new SupportTicket( - "I was charged twice. Please refund the duplicate payment."); - -var response = await client.GetResponseAsync(ticket, definition); - -TicketAnalysis analysis = response.Result; -DecisionResponse evidence = response.Evidence; -``` - -When the complete choice evidence is useful for reporting or review, the typed distribution remains available: - -```csharp -var categoryDistribution = response.GetDistribution(value => value.Category); -``` - -The same definition can include an explicitly ordered score rubric, and heterogeneous declarations remain independent: - -```csharp -var definitionWithScore = DecisionDefinition.Create(builder => -{ - builder.Choice(result => result.Category, "Classify the customer's main concern."); - builder.BinaryProbability(result => result.RefundRequestProbability); - // A score property would be declared with an explicit DecisionScoreLevel list: - // builder.Score(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); - // builder.ExpectedScore(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); -}); +JuliaDecisionGenerator generator = + JuliaDecisionGenerator.LoadFromDirectory(assetDirectory); +IDecisionClient client = generator.AsDecisionClient(); ``` -`Score` projects only a provider-reported native ordinal score and fails explicitly when that scalar is absent. `ExpectedScore` projects the unmodified ordinal expectation calculated from the complete observed distribution, so the two choices remain distinguishable; after probability rounding, that observed expectation can fall outside the ordinal bounds and is not repaired. Every probability is conditioned on the full request state and question set; declaring questions independently does not promise marginal invariance, independence, a joint distribution, or calibration. - -Reported scalar consistency uses normalized latent feasibility within clipped rounding intervals; the observed probabilities are never normalized or rewritten. Typed binding verifies the actual CLR property values after materialization, independently of write-only JSON formatting. When options omit a resolver, the definition supplies the MEAI default resolver, while explicit caller resolvers and converters remain unchanged. +The Julia/MEAI bridge above is historical implementation evidence, not a released dependency or a claim that this repository repins or executes a provider. Jev/OllamaSharp and the Ollaya, Laya, Julia, and Qwen adapters remain provider-owned. A deterministic test double is used for the platform proofs. -At an explicit JSON boundary, callers can use source-generated metadata for both state and result contracts. The explicit generic arguments are optional for inference, but make the boundary visible: +The reusable typed declaration is ordinary application code: ```csharp -var metadataDefinition = - DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, builder => +DecisionDefinition definition = + DecisionDefinition.Create(builder => { builder.Choice(result => result.Category); builder.BinaryProbability(result => result.RefundRequestProbability); }); -var metadataResponse = await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - metadataDefinition); -``` +SupportTicket ticket = new( + "I was charged twice. Please refund the duplicate payment."); -When the application already has a JSON state snapshot, the result-only overload keeps the same local binding behavior without re-serializing the state: +DecisionResponse response = + await client.GetResponseAsync(ticket, definition); -```csharp -using System.Text.Json; - -JsonElement stateJson = JsonSerializer.SerializeToElement( - ticket, - TicketJsonContext.Default.SupportTicket); -var analyzedFromJson = await client.GetResponseAsync( - stateJson, - metadataDefinition); +TicketAnalysis analysis = response.Result; +DecisionResponse evidence = response.Evidence; ``` -Applications that only need the neutral response can keep the no-binding path: - -```csharp -var evidenceOnly = await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - metadataDefinition.Questions); -``` +`response.Evidence` contains the complete neutral response, including every category probability and the binary observation. It is separate from any chat usage or business action. The mapped result is not a hidden threshold or calibration claim. -The JSON metadata setup is separate from the business call: +For an explicit JSON boundary, supply source-generated metadata to both state and result contracts: ```csharp -using System.Text.Json.Serialization; +DecisionDefinition metadataDefinition = + DecisionDefinition.Create( + TicketJsonContext.Default.TicketAnalysis, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + }); -[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +DecisionResponse metadataResponse = + await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + metadataDefinition); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] [JsonSerializable(typeof(SupportTicket))] [JsonSerializable(typeof(TicketCategory))] [JsonSerializable(typeof(TicketAnalysis))] public partial class TicketJsonContext : JsonSerializerContext; ``` -`TicketCategory` is a normal caller-owned enum. `DecisionDefinition` is an explicit declaration of which result members participate; it is not an enum generator, ORM, arbitrary POCO/union inference engine, or LINQ query provider. The candidate IDs come from the configured JSON enum contract and are exact ordinal wire-correlation identities. They are stable simple strings reused through the question definition; no pipe/version syntax is required. If a domain needs dynamic IDs instead of a closed enum, use the lower-level `ChoiceDecisionQuestion` and explicit mapping APIs so stale or unknown IDs remain rejected. - -Attributes are an ergonomic fallback for instructions and enum candidate descriptions. A fluent instruction supplied to `Choice`, `BinaryProbability`, or `Score` wins over an attribute. The metadata reader checks an attributed property first, then an associated positional-record constructor parameter, then a type-level fallback. JSON enum serialization controls the candidate IDs, but JSON conversion's permissive/case-insensitive behavior does not replace exact decision correlation validation. - -`JsonTypeInfo` describes the serialized shape of state or a result. It may come from source generation or a configured reflection resolver; it is not handwritten JSON schema and it does not replace decision instructions, exact IDs, complete distributions, or stale-domain checks. The older `DecisionResultBinding` API still requires result metadata even though a delegate mapper may not use it; simplifying that requirement is an API review candidate, not an implicit behavior of this convenience layer. - -The returned `Evidence` is the complete `DecisionResponse`, including every category probability and the binary observation. The mapped `TicketAnalysis` is therefore not a claim that a hidden threshold or calibration step established a business fact. Applications can retain or inspect the complete evidence for reporting, review, or later training. Feature projection is an optional follow-on: callers may define a versioned `DecisionFeatureSchema` of named native-`double` coordinates over complete observations when a downstream feature export is useful. That schema is a downstream column/coordinate contract, distinct from JSON metadata and not a prerequisite for typed classification. - -## .NET platform reuse / ergonomics under review - -Layer 1 intentionally reuses existing .NET and MEAI primitives rather than adding `IQueryable`, an ORM, or a new source-generator dependency: - -- [`JsonSchemaExporter`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) was introduced in .NET 9. The decision contracts use `JsonTypeInfo` and serializer options for explicit metadata; they do not add another schema exporter, and `AIJsonUtilities` provides the existing MEAI compatibility helpers where applicable. -- [System.Text.Json source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) is the AOT-friendly way to supply `JsonTypeInfo`. `JsonTypeInfo` itself is not synonymous with generated code; ordinary JIT reflection through a configured resolver remains a platform option. -- [`JsonStringEnumMemberNameAttribute`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/customize-properties#custom-enum-member-names) and the generic [`JsonStringEnumConverter`](https://learn.microsoft.com/dotnet/api/system.text.json.serialization.jsonstringenumconverter-1) are useful for JSON enum representation. They do not replace decision ID validation: enum conversion is case-insensitive and permits integer values by default, while decision IDs are exact ordinal identities. -- [`AIFunctionFactory`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.aifunctionfactory) remains the existing MEAI primitive for inspecting an explicitly declared delegate, producing a tool schema, and marshalling inputs and outputs. Tool interoperability is a later layer, not a prerequisite for this decision foundation. -- [Structured output](https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output) already supports enum/record-shaped chat results. If an application only needs a label or JSON result, `IChatClient.GetResponseAsync` may be the simpler choice; that path does not by itself establish a provider-reported probability distribution. -- [EF Core model conventions and explicit overrides](https://learn.microsoft.com/ef/core/modeling/) are useful precedent for bounded opt-in conventions, not a reason to add an EF dependency or `DbContext`. [EF Core query providers](https://learn.microsoft.com/ef/core/querying/) likewise do not justify an `IQueryable` decision API for explicit paid asynchronous model inference; ordinary LINQ projection over returned probabilities remains appropriate. +`JsonTypeInfo` describes a serialized state or result shape. It does not replace decision instructions, exact IDs, complete distributions, or stale-domain checks. JSON enum naming controls candidate IDs, while decision correlation remains exact and rejects unknown or changed IDs. -Automated arbitrary semantic inference is out. This layer already uses bounded opt-in conventions for JSON enum naming and standard member descriptions; future conventions for known enum/member mappings remain reasonable only when explicit, source-generation/AOT-compatible, and unable to hide provider-facing identities or application-owned result mapping. +## Layer 2 interoperability: two independent paths -Provider evidence for the package-level contract is bounded and documented in the [upstream provider-validation report](https://github.com/luisquintanilla/typesafe-meai/blob/13adb58c7d7479bc8c73bf31960c08349b7c3185/docs/upstream-provider-validation.md): Julia/Laya provide CPU binary/choice/score proof, Qwen provides choice-only proof, and the dedicated `tev1:0.8b` Ollama/OllamaSharp path provides live binary/choice/score proof. Other adapters use native HTTP fixtures rather than live executions; this is not a claim of universal provider accuracy. +Layer 2 deliberately adds no decision-specific function wrapper and no public routing type. The two useful compositions are independent consumer-owned uses of existing MEAI primitives. -This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. +### 1. Annotated application method to `AIFunctionFactory` -## Layer 2 interoperability - -### A useful decision tool needs no analytics contract - -When a tool only needs the business result, the simplest composition is the existing `AIFunctionFactory.Create` plus an ordinary host-owned delegate. With the `client` and `definition` from the business example above, the tool keeps the complete response in the host and returns only the mapped result: +An ordinary annotated method can close over a host-injected client and definition. The client, definition, endpoint, model, and options are not model-supplied tool arguments: ```csharp -Func> analyze = - async (ticket, cancellationToken) => +public sealed class SupportTicketTools( + IDecisionClient client, + DecisionDefinition definition, + Action? recordEvidence = null) +{ + [DisplayName("analyze_support_ticket")] + [Description("Annotate one support ticket.")] + public async Task AnalyzeAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) { - DecisionResponse response = - await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); + DecisionResponse response = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + recordEvidence?.Invoke(response.Evidence); return response.Result; - }; - -AIFunction function = AIFunctionFactory.Create( - analyze, + } +} +SupportTicketTools host = new(client, definition, recordEvidence); +AIFunction analyzeTicket = AIFunctionFactory.Create( + host.AnalyzeAsync, new AIFunctionFactoryOptions { Name = "analyze_support_ticket", - Description = "Annotate a support ticket for reporting and human review.", + Description = "Annotate a support ticket for review.", }); + +IChatClient chatClient = configuredChatClient + .AsBuilder() + .UseFunctionInvocation() + .Build(); + +ChatResponse chatResponse = await chatClient.GetResponseAsync( + messages, + new ChatOptions { Tools = [analyzeTicket] }, + cancellationToken); ``` -The delegate returns only `response.Result`; it does not expose the complete `DecisionResponse`, request, provider extensions, raw state beyond the bounded input parameter, or reasoning text as tool output. The complete evidence is available inside the delegate; retaining it requires an explicit host callback such as the metadata example below. `AIFunctionFactory` supplies the normal MEAI JSON tool contract, while the host retains ownership of the `IDecisionClient`, endpoint/model/options, and any reporting or human-review action. A useful decision tool needs no analytics contract: this path needs no handwritten question IDs, enum dictionary, result mapper, `DecisionTask`, `DecisionFunctionResult`, or feature schema. The ordinary `DecisionDefinition.Create(builder => ...)` path uses the library's normal JSON metadata behavior and is intended for reflection-enabled JIT applications. +The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. The decision call is one logical operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. -If the tool should return selected decision metadata as well as the business result, keep that output type application-owned and retain the complete evidence separately: +`AIFunctionFactory` options can override method attributes. Its schema contains only the application input (`ticket` here); `client`, `definition`, and `CancellationToken` are infrastructure, not model-facing parameters. The middleware example above exercises normal function invocation; direct `AIFunction.InvokeAsync` is sufficient for a platform-only proof and should not be described as a chat-loop proof. -```csharp -public sealed record TicketToolOutput( - TicketAnalysis Result, - DecisionProvenance? Provenance, - UsageDetails? Usage); -``` +For reflection-disabled or trimmed deployments, provide source-generated metadata at both boundaries: use `DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, ...)`, call the overload accepting `TicketJsonContext.Default.SupportTicket`, and set `AIFunctionFactoryOptions.SerializerOptions = TicketJsonContext.Default.Options`. Function metadata alone does not configure decision state binding. This is a source-generation-compatible deployment variant, not a Native AOT certification. + +### 2. Decision-driven chat routing + +Routing is a separate provider/client-selection problem. Construct one provider-neutral decision client and multiple configured, caller-owned `IChatClient` instances. Project meaningful bounded request state, use a typed `DecisionDefinition`, and compose with the existing `RoutingChatClient.Create`: ```csharp -public static class SupportTicketTools +public enum RouteKind { - public static AIFunction CreateMetadataFunction( - IDecisionClient client, - DecisionDefinition definition, - Action recordEvidence) - { - Func> analyzeWithMetadata = - async (ticket, cancellationToken) => - { - DecisionResponse response = await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); - - recordEvidence(response.Evidence); - return new( - response.Result, - response.Evidence.Provenance, - response.Evidence.Usage); - }; - - return AIFunctionFactory.Create( - analyzeWithMetadata, - new AIFunctionFactoryOptions - { - Name = "analyze_support_ticket_with_metadata", - }); - } + Fast, + Reasoning, } -``` - -Here `recordEvidence` is an application-owned `Action` supplied by the host and invoked once for each response; it is not shared production state holding the last response. The DTO remains bounded and excludes raw evidence from tool output. -Decision provenance and usage describe the decision operation; they do not execute or authorize a business action. The host decides separately whether to persist evidence, perform an action, or record an outcome. +public sealed record RoutingDecision(RouteKind Route); +public sealed record RoutingState(string RequestText, bool HasTools); -
-Reflection-disabled/source-generated JSON variant +DecisionDefinition routeDefinition = + DecisionDefinition.Create( + RoutingJsonContext.Default.RoutingDecision, + builder => builder.Choice(result => result.Route)); -When reflection metadata is disabled or trimming is required, supply source-generated metadata to both the decision definition and the typed state call. `AIFunctionFactoryOptions.SerializerOptions` controls the tool's JSON contract; it does not provide the decision definition or state contract: - -```csharp -DecisionDefinition definition = - DecisionDefinition.Create( - TicketJsonContext.Default.TicketAnalysis, - builder => - { - builder.Choice(result => result.Category); - builder.BinaryProbability(result => result.RefundRequestProbability); - }); +IReadOnlyDictionary clients = + new Dictionary + { + [RouteKind.Fast] = fastConfiguredClient, + [RouteKind.Reasoning] = reasoningConfiguredClient, + }; -Func> analyze = - async (ticket, cancellationToken) => +RoutingChatClient router = RoutingChatClient.Create( + async (context, cancellationToken) => { - DecisionResponse response = - await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - definition, + RoutingState state = new( + string.Join("\n", context.Messages.Select(message => message.Text)), + context.ChatOptions?.Tools is not null); + + DecisionResponse decision = + await decisionClient.GetResponseAsync( + state, + RoutingJsonContext.Default.RoutingState, + routeDefinition, cancellationToken: cancellationToken); - return response.Result; - }; - -AIFunction function = AIFunctionFactory.Create( - analyze, + if (!clients.TryGetValue(decision.Result.Route, out IChatClient? selected)) + { + throw new InvalidOperationException("The decision selected an unconfigured route."); + } - new AIFunctionFactoryOptions - { - Name = "analyze_support_ticket", - SerializerOptions = TicketJsonContext.Default.Options, + return selected; }); ``` -This is still ordinary delegate composition; the extra metadata is the deployment-safe JSON boundary, not a new decision or tool abstraction. - -
- -
-Optional probability-feature export with the proposed helper +Each configured client owns its route-specific model identity and options. The router forwards the original messages and a cloned request-options object to exactly one selected client, including streaming calls. The caller owns the configured clients and remains responsible for disposing them; the router does not dispose borrowed clients. Invalid or wrong-domain decision responses, cancellation, and provider failures stop before chat forwarding. There is no retry, failover, conversation affinity, mid-stream switching, outcome learning, or automatic calibration. Tool loops run inside the selected client after routing; the first example starts a fresh decision per router call. Decision probabilities are task observations, not calibrated task-success guarantees. -For a host that explicitly needs portable probability features, provenance, and separate decision usage in the tool result, the existing Layer 2 `DecisionTask.AsAIFunction` proof remains available. Its `featureSchema` argument is optional: omitting it returns the mapped result, provenance, and usage without invoking feature projection, while supplying it is an explicit extension that returns bounded named native-`double` features. The helper closes over explicit questions, mapper, source-generated state/result contracts, and the caller-owned `IDecisionClient`, performs one decision call, verifies that the response corresponds to the outgoing state and ordered question definitions before mapping or projection, and returns safe output. The advanced feature path is optional and is not required by `AIFunctionFactory`, `JsonTypeInfo`, or the .NET platform. +### Semantic ingestion proof -
+The MEDI proof remains consumer-owned and uses the existing document-reader -> chunker -> semantic decision chunk processor -> collecting/serializing writer flow. It preserves chunk content, document identity/context, unrelated metadata, bounded streaming, cancellation, and stable feature schema identity/version/provenance. It does not add `IngestionDocument.Metadata`, alter `ClassificationEnricher` or batching, fabricate chat responses, or add an upstream ML.NET/Arrow dependency. -When a `DecisionTask` has no feature schema, `DecisionFunctionResult.Features` is null and the normal JSON contract omits the `features` property. No empty vector or fallback projection is fabricated. Supplying a schema preserves the existing coordinate validation, native `double` values, ordering, and failure behavior; a strict reported-score coordinate still fails when the provider did not report that scalar. +## Alternative Designs -The same explicit feature projection can be used as optional downstream MEDI enrichment. A consumer-owned semantic chunk processor can run after the existing document reader and chunker, ask a host-defined decision question for each bounded chunk, and attach versioned named `double` coordinates, provenance, document identity/context, and unrelated metadata before the existing collecting or serializing writer. The proof uses the current reader -> chunker -> processor -> writer primitives and preserves bounded streaming, cancellation, and failure propagation; it does not add a reusable MEDI processor or change `ClassificationEnricher` or batching. +- A decision-specific `AIFunction` wrapper was removed: ordinary annotated methods plus `AIFunctionFactory.Create` already provide the safe schema, host closure, cancellation, and standard middleware composition. +- A reusable decision router was not added: typed `DecisionDefinition` plus `RoutingChatClient.Create` is enough for application-specific eligibility and client maps. +- A reusable MEDI processor was not added: the real pipeline proof does not establish a separately justified public ingestion API. -Routing remains a secondary compatibility and lifetime proof rather than the headline scenario. A consumer-owned selector can use the existing `RoutingChatClient.Create` callback to project bounded state into a Choice question, validate the exact selected candidate against a configured client map, and return the configured caller-owned `IChatClient` while preserving message/options forwarding, cancellation, and selected-client lifetime. It does not add a parallel router, retry, failover, quality cascading, learning, persistence, or outcome policy. +## Risks and limitations -This layer intentionally does not define provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. Those concerns require provider-owned or later-layer work and are not implied by these abstractions. +These contracts are experimental (`MEAI001`). Hosts own client lifetime, safe output DTOs, evidence retention, route policy, and business actions. Provider adapters and external proof packages are not shipped or repinned here. No live provider/model execution, package-release availability, Native AOT certification, calibration guarantee, or universal provider accuracy is claimed. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 505cbf9afc9..b422e38fe80 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5368,76 +5368,6 @@ } ] }, - { - "Type": "static class Microsoft.Extensions.AI.DecisionFunctionExtensions", - "Stage": "Experimental", - "Methods": [ - { - "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DecisionFunctionExtensions.AsAIFunction(this Microsoft.Extensions.AI.DecisionTask task, Microsoft.Extensions.AI.IDecisionClient client, Microsoft.Extensions.AI.DecisionOptions? decisionOptions = null, Microsoft.Extensions.AI.AIFunctionFactoryOptions? functionOptions = null);", - "Stage": "Experimental" - } - ] - }, - { - "Type": "sealed class Microsoft.Extensions.AI.DecisionFunctionResult", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.DecisionFunctionResult.DecisionFunctionResult(TResult result, Microsoft.Extensions.AI.DecisionFeatureVector? features = null, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "Microsoft.Extensions.AI.DecisionFeatureVector? Microsoft.Extensions.AI.DecisionFunctionResult.Features { get; }", - "Stage": "Experimental" - }, - { - "Member": "Microsoft.Extensions.AI.DecisionProvenance? Microsoft.Extensions.AI.DecisionFunctionResult.Provenance { get; }", - "Stage": "Experimental" - }, - { - "Member": "TResult Microsoft.Extensions.AI.DecisionFunctionResult.Result { get; }", - "Stage": "Experimental" - }, - { - "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.DecisionFunctionResult.Usage { get; }", - "Stage": "Experimental" - } - ] - }, - { - "Type": "sealed class Microsoft.Extensions.AI.DecisionTask", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.DecisionTask.DecisionTask(System.Collections.Generic.IEnumerable questions, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, Microsoft.Extensions.AI.DecisionResultBinding resultBinding, System.Text.Json.Serialization.Metadata.JsonTypeInfo> resultTypeInfo, Microsoft.Extensions.AI.DecisionFeatureSchema? featureSchema = null);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema? Microsoft.Extensions.AI.DecisionTask.FeatureSchema { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionTask.Questions { get; }", - "Stage": "Experimental" - }, - { - "Member": "Microsoft.Extensions.AI.DecisionResultBinding Microsoft.Extensions.AI.DecisionTask.ResultBinding { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo> Microsoft.Extensions.AI.DecisionTask.ResultTypeInfo { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo Microsoft.Extensions.AI.DecisionTask.StateTypeInfo { get; }", - "Stage": "Experimental" - } - ] - }, { "Type": "enum Microsoft.Extensions.AI.DecisionKind", "Stage": "Experimental", diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs deleted file mode 100644 index c9aa905e0ad..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionFunctionTests.cs +++ /dev/null @@ -1,634 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#pragma warning disable SA1402 // Test models and their source-generated context are co-located. -#pragma warning disable SA1118 // Long generic constructor arguments are kept readable. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public sealed class DecisionFunctionTests -{ - [Fact] - public async Task AsAIFunction_UsesBoundedSchemaAndPortableResult() - { - using RecordingDecisionClient client = new(); - DecisionTask task = CreateTask( - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => new() - { - Selected = answers.GetChoice("route").SelectedCandidateId, - })); - - DecisionOptions options = new() { ModelId = "decision-model" }; - AIFunction function = task.AsAIFunction( - client, - options, - new AIFunctionFactoryOptions - { - Name = "choose_route", - Description = "Choose one route.", - SerializerOptions = DecisionFunctionJsonContext.Default.Options, - }); - options.ModelId = "changed-after-creation"; - - Assert.Equal("choose_route", function.Name); - Assert.Equal("Choose one route.", function.Description); - Assert.Equal(["state"], function.JsonSchema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); - Assert.True(function.ReturnJsonSchema!.Value.GetProperty("properties").TryGetProperty("features", out _)); - Assert.False(function.ReturnJsonSchema.Value.GetProperty("properties").TryGetProperty("request", out _)); - - JsonElement serializedState = JsonSerializer.SerializeToElement( - new DecisionToolState { Payload = "sensitive request" }, - DecisionFunctionJsonContext.Default.DecisionToolState); - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["state"] = serializedState })); - Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); - Assert.Equal("route.primary", resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("name").GetString()); - Assert.Equal(0.75, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); - Assert.Equal("test-provider", resultJson.GetProperty("provenance").GetProperty("providerName").GetString()); - Assert.Equal(12, resultJson.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); - Assert.Equal(1, client.CallCount); - Assert.Equal("decision-model", client.LastOptions!.ModelId); - Assert.DoesNotContain("sensitive request", resultJson.GetRawText(), StringComparison.Ordinal); - Assert.DoesNotContain("raw-secret", resultJson.GetRawText(), StringComparison.Ordinal); - Assert.Equal(0, client.DisposeCount); - } - - [Fact] - public async Task AsAIFunction_ReturnsMappedResultAndMetadataWithoutFeatureSchema() - { - using RecordingDecisionClient client = new(); - DecisionTask task = CreateTask(includeFeatureSchema: false); - DecisionOptions options = new() { ModelId = "decision-model" }; - AIFunction function = task.AsAIFunction( - client, - options, - new AIFunctionFactoryOptions - { - SerializerOptions = DecisionFunctionJsonContext.Default.Options, - }); - options.ModelId = "changed-after-creation"; - - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); - - Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); - Assert.False(resultJson.TryGetProperty("features", out _)); - Assert.Equal("test-provider", resultJson.GetProperty("provenance").GetProperty("providerName").GetString()); - Assert.Equal(12, resultJson.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); - Assert.Null(task.FeatureSchema); - Assert.Equal(1, client.CallCount); - Assert.Equal("decision-model", client.LastOptions!.ModelId); - Assert.Equal(0, client.DisposeCount); - } - - [Fact] - public void DecisionTask_UsesFourArgumentConstructorWhenFeatureSchemaIsOmitted() - { - DecisionTask task = CreateTask(omitFeatureSchema: true); - - Assert.Null(task.FeatureSchema); - } - - [Theory] - [InlineData(true, true)] - [InlineData(false, true)] - [InlineData(false, false)] - public async Task AsAIFunction_SourceGeneratedResultPreservesFeaturesAndRoundTripsMetadataOnlyOutput( - bool includeFeatureSchema, - bool includeMetadata) - { - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateResponse(request, includeMetadata), - }; - DecisionTask task = CreateTask(includeFeatureSchema: includeFeatureSchema); - DecisionFunctionResult? captured = null; - AIFunction function = task.AsAIFunction( - client, - functionOptions: new() - { - SerializerOptions = DecisionFunctionJsonContext.Default.Options, - MarshalResult = (value, _, _) => - { - captured = Assert.IsType>(value); - return new(value); - }, - }); - - object? output = await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }); - Assert.Same(captured, output); - Assert.NotNull(captured); - DecisionFunctionResult result = captured!; - JsonElement resultJson = JsonSerializer.SerializeToElement( - result, - DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); - - if (includeFeatureSchema) - { - DecisionFeatureVector? features = result.Features; - Assert.NotNull(features); - Assert.Equal("decision-tool-v1", features.Schema.Id); - Assert.Equal("route.primary", features.Values[0].Name); - Assert.Equal(0.75, features.Values[0].Value); - Assert.Equal("route.primary", resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("name").GetString()); - Assert.Equal(0.75, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); - } - else - { - DecisionFunctionResult? roundTripped = JsonSerializer.Deserialize( - resultJson, - DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); - Assert.NotNull(roundTripped); - Assert.Equal("primary", roundTripped!.Result.Selected); - Assert.Null(roundTripped!.Features); - Assert.False(resultJson.TryGetProperty("features", out _)); - - if (includeMetadata) - { - Assert.Equal("test-provider", roundTripped.Provenance!.ProviderName); - Assert.Equal("test-model", roundTripped.Provenance.ModelId); - Assert.Equal(12, roundTripped.Usage!.InputTokenCount); - Assert.Equal(3, roundTripped.Usage.OutputTokenCount); - } - else - { - Assert.Null(roundTripped.Provenance); - Assert.Null(roundTripped.Usage); - Assert.False(resultJson.TryGetProperty("provenance", out _)); - Assert.False(resultJson.TryGetProperty("usage", out _)); - } - } - - if (includeMetadata) - { - DecisionProvenance? provenance = result.Provenance; - UsageDetails? usage = result.Usage; - Assert.NotNull(provenance); - Assert.NotNull(usage); - Assert.Equal("test-provider", provenance.ProviderName); - Assert.Equal("test-model", provenance.ModelId); - Assert.Equal(12, usage.InputTokenCount); - Assert.Equal(3, usage.OutputTokenCount); - } - else - { - Assert.Null(result.Provenance); - Assert.Null(result.Usage); - Assert.False(resultJson.TryGetProperty("provenance", out _)); - Assert.False(resultJson.TryGetProperty("usage", out _)); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task AsAIFunction_PropagatesCancellationProviderAndMapperFailures(bool includeFeatureSchema) - { - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - using RecordingDecisionClient cancelledClient = new() - { - Cancellation = true, - }; - AIFunction cancelledFunction = CreateTask(includeFeatureSchema: includeFeatureSchema).AsAIFunction( - cancelledClient, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - await Assert.ThrowsAnyAsync( - () => cancelledFunction.InvokeAsync( - new AIFunctionArguments { ["state"] = new DecisionToolState() }, - cancellation.Token).AsTask()); - Assert.Equal(1, cancelledClient.CallCount); - - using RecordingDecisionClient failedClient = new() - { - Failure = new DecisionClientException("provider failed", isTransient: true), - }; - AIFunction failedFunction = CreateTask(includeFeatureSchema: includeFeatureSchema).AsAIFunction( - failedClient, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - DecisionClientException providerFailure = await Assert.ThrowsAsync( - () => failedFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); - Assert.True(providerFailure.IsTransient); - Assert.Equal(1, failedClient.CallCount); - - using RecordingDecisionClient mapperClient = new(); - AIFunction mapperFunction = CreateTask( - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - _ => throw new InvalidOperationException("mapper failed")), - includeFeatureSchema).AsAIFunction( - mapperClient, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - InvalidOperationException mapperFailure = await Assert.ThrowsAsync( - () => mapperFunction.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); - Assert.Equal("mapper failed", mapperFailure.Message); - Assert.Equal(1, mapperClient.CallCount); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task AsAIFunction_RejectsResponseForDifferentStateBeforeMapper(bool includeFeatureSchema) - { - int mapperCalls = 0; - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateResponse( - new DecisionRequest( - JsonSerializer.SerializeToElement( - new DecisionToolState { Payload = "different state" }, - DecisionFunctionJsonContext.Default.DecisionToolState), - request.Questions)), - }; - AIFunction function = CreateTask( - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => - { - mapperCalls++; - return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - }), - includeFeatureSchema).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - DecisionProtocolException exception = await Assert.ThrowsAsync( - () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); - - Assert.Contains("does not correspond", exception.Message, StringComparison.Ordinal); - Assert.Equal(1, client.CallCount); - Assert.Equal(0, mapperCalls); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task AsAIFunction_RejectsResponseWithChangedQuestionContractBeforeMapper(bool includeFeatureSchema) - { - int mapperCalls = 0; - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateResponse( - new DecisionRequest( - request.State, - [ - new ChoiceDecisionQuestion( - "route", - "Use the alternate instructions.", - [ - new DecisionCandidate("secondary", "Alternate secondary route."), - new DecisionCandidate("primary", "Alternate primary route."), - ]), - ])), - }; - AIFunction function = CreateTask( - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => - { - mapperCalls++; - return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - }), - includeFeatureSchema).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - DecisionProtocolException exception = await Assert.ThrowsAsync( - () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); - - Assert.Contains("does not correspond", exception.Message, StringComparison.Ordinal); - Assert.Equal(1, client.CallCount); - Assert.Equal(0, mapperCalls); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task AsAIFunction_AcceptsEquivalentOwnedRequestSnapshot(bool includeFeatureSchema) - { - int mapperCalls = 0; - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateResponse( - new DecisionRequest( - request.State.Clone(), - [ - new ChoiceDecisionQuestion( - "route", - "Choose one route.", - [ - new DecisionCandidate("primary", "Use the primary route."), - new DecisionCandidate("secondary", "Use the secondary route."), - ]), - ])), - }; - AIFunction function = CreateTask( - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => - { - mapperCalls++; - return new() { Selected = answers.GetChoice("route").SelectedCandidateId }; - }), - includeFeatureSchema).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); - - Assert.Equal("primary", resultJson.GetProperty("result").GetProperty("selected").GetString()); - Assert.Equal(1, client.CallCount); - Assert.Equal(1, mapperCalls); - } - - [Fact] - public async Task AsAIFunction_ProjectsExpectedScoreWithoutReportedScoreOrSecondOperation() - { - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateScoreResponse( - request, - reportedScore: null, - [0, 0, 0.01, 1]), - }; - AIFunction function = CreateScoreTask(DecisionFeatureValueKind.ExpectedScore).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments - { - ["state"] = new DecisionToolState { Payload = "sensitive request" }, - })); - - Assert.Equal(3.02, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); - Assert.Equal(1, client.CallCount); - Assert.DoesNotContain("sensitive request", resultJson.GetRawText(), StringComparison.Ordinal); - Assert.DoesNotContain("raw-secret", resultJson.GetRawText(), StringComparison.Ordinal); - } - - [Fact] - public async Task AsAIFunction_SkipsFeatureProjectionWhenSchemaIsOmitted() - { - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateScoreResponse( - request, - reportedScore: null, - [0, 0, 0.01, 1]), - }; - AIFunction function = CreateScoreTask(featureValueKind: null).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); - - Assert.Equal(3.02, resultJson.GetProperty("result").GetProperty("score").GetDouble()); - Assert.False(resultJson.TryGetProperty("features", out _)); - Assert.Equal(1, client.CallCount); - } - - [Fact] - public async Task AsAIFunction_RejectsMissingReportedScoreForStrictScoreFeature() - { - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateScoreResponse( - request, - reportedScore: null, - [0, 0, 0.01, 1]), - }; - AIFunction function = CreateScoreTask(DecisionFeatureValueKind.Score).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - await Assert.ThrowsAsync( - () => function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() }).AsTask()); - Assert.Equal(1, client.CallCount); - } - - [Fact] - public async Task AsAIFunction_ProjectsReportedZeroAsDistinctFromMissingScore() - { - using RecordingDecisionClient client = new() - { - ResponseFactory = request => CreateScoreResponse( - request, - reportedScore: 0, - [1, 0, 0, 0]), - }; - AIFunction function = CreateScoreTask(DecisionFeatureValueKind.Score).AsAIFunction( - client, - functionOptions: new() { SerializerOptions = DecisionFunctionJsonContext.Default.Options }); - - JsonElement resultJson = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["state"] = new DecisionToolState() })); - - Assert.Equal(0, resultJson.GetProperty("features").GetProperty("values")[0].GetProperty("value").GetDouble()); - Assert.Equal(1, client.CallCount); - } - - private static DecisionTask CreateTask( - DecisionResultBinding? binding = null, - bool includeFeatureSchema = true, - bool omitFeatureSchema = false) - { - ChoiceDecisionQuestion question = new( - "route", - "Choose one route.", - [ - new DecisionCandidate("primary", "Use the primary route."), - new DecisionCandidate("secondary", "Use the secondary route."), - ]); - - DecisionResultBinding effectiveBinding = binding - ?? new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionToolResult, - answers => new() { Selected = answers.GetChoice("route").SelectedCandidateId }); - - if (omitFeatureSchema) - { - return new( - [question], - DecisionFunctionJsonContext.Default.DecisionToolState, - effectiveBinding, - DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult); - } - - return new( - [question], - DecisionFunctionJsonContext.Default.DecisionToolState, - effectiveBinding, - DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionToolResult, - includeFeatureSchema - ? new DecisionFeatureSchema( - "decision-tool-v1", - 1, - [new("route.primary", "route", DecisionFeatureValueKind.ChoiceProbability, "primary")]) - : null); - } - - private static DecisionTask CreateScoreTask( - DecisionFeatureValueKind? featureValueKind) - { - ScoreDecisionQuestion question = new( - "score", - "Score the state using the ordered rubric.", - [ - new DecisionScoreLevel("zero", "Zero"), - new DecisionScoreLevel("one", "One"), - new DecisionScoreLevel("two", "Two"), - new DecisionScoreLevel("three", "Three"), - ]); - - return new( - [question], - DecisionFunctionJsonContext.Default.DecisionToolState, - new DecisionResultBinding( - DecisionFunctionJsonContext.Default.DecisionScoreToolResult, - answers => new() { Score = answers.GetScore("score").ExpectedScore }), - DecisionFunctionJsonContext.Default.DecisionFunctionResultDecisionScoreToolResult, - featureValueKind is null - ? null - : new DecisionFeatureSchema( - "decision-score-tool-v1", - 1, - [new("score.value", "score", featureValueKind.Value)])); - } - - private static DecisionResponse CreateResponse(DecisionRequest request, bool includeMetadata = true) - { - ChoiceDecisionQuestion question = Assert.IsType(request.Questions[0]); - double firstProbability = 0.75; - double remainingProbability = (1 - firstProbability) / (question.Candidates.Count - 1); - DecisionProbability[] probabilities = question.Candidates - .Select((candidate, index) => new DecisionProbability( - candidate.Id, - index == 0 ? firstProbability : remainingProbability)) - .ToArray(); - - return new DecisionResponse( - request, - [ - new ChoiceDecisionAnswer( - question.Id, - question.Candidates[0].Id, - probabilities), - ], - includeMetadata ? new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response") : null, - includeMetadata ? new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 } : null, - rawRepresentation: "raw-secret", - additionalProperties: new Dictionary { ["secret"] = "raw-secret" }); - } - - private static DecisionResponse CreateScoreResponse( - DecisionRequest request, - double? reportedScore, - double[] values) - { - ScoreDecisionQuestion question = Assert.IsType(request.Questions[0]); - DecisionProbability[] probabilities = question.Levels - .Select((level, index) => new DecisionProbability(level.Id, values[index])) - .ToArray(); - - return new DecisionResponse( - request, - [ - new ScoreDecisionAnswer( - question.Id, - reportedScore, - probabilities, - DecisionPrecision.TwoDecimalPlaces), - ], - new DecisionProvenance(providerName: "test-provider", modelId: "test-model", responseId: "response"), - new UsageDetails { InputTokenCount = 12, OutputTokenCount = 3 }, - rawRepresentation: "raw-secret", - additionalProperties: new Dictionary { ["secret"] = "raw-secret" }); - } - - private sealed class RecordingDecisionClient : IDecisionClient - { - public int CallCount { get; private set; } - - public int DisposeCount { get; private set; } - - public DecisionOptions? LastOptions { get; private set; } - - public bool Cancellation { get; init; } - - public Exception? Failure { get; init; } - - public Func? ResponseFactory { get; init; } - - public Task GetResponseAsync( - DecisionRequest request, - DecisionOptions? options = null, - CancellationToken cancellationToken = default) - { - CallCount++; - LastOptions = options; - - if (Failure is not null) - { - return Task.FromException(Failure); - } - - if (Cancellation) - { - return Task.FromCanceled(cancellationToken); - } - - return Task.FromResult((ResponseFactory ?? (request => CreateResponse(request)))(request)); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() => DisposeCount++; - } - - internal sealed class DecisionToolState - { - public string? Payload { get; set; } - } - - internal sealed class DecisionToolResult - { - public string? Selected { get; set; } - } - - internal sealed class DecisionScoreToolResult - { - public double Score { get; set; } - } -} - -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -[JsonSerializable(typeof(DecisionFunctionTests.DecisionToolState))] -[JsonSerializable(typeof(DecisionFunctionTests.DecisionToolResult))] -[JsonSerializable(typeof(DecisionFunctionTests.DecisionScoreToolResult))] -[JsonSerializable(typeof(DecisionFunctionResult))] -[JsonSerializable(typeof(DecisionFunctionResult))] -[JsonSerializable(typeof(DecisionFeatureVector))] -[JsonSerializable(typeof(DecisionFeatureSchema))] -[JsonSerializable(typeof(DecisionFeatureCoordinate))] -[JsonSerializable(typeof(DecisionFeatureValue))] -[JsonSerializable(typeof(DecisionProvenance))] -[JsonSerializable(typeof(UsageDetails))] -internal sealed partial class DecisionFunctionJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs index 48b272a8fc1..ce4ef5f5bef 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -14,148 +16,200 @@ namespace Microsoft.Extensions.AI; public sealed class DecisionRoutingProofTests { - private static readonly ChoiceDecisionQuestion _routeQuestion = new( - "route", - "Choose the configured chat route.", - [ - new DecisionCandidate("primary", "Use the primary route."), - new DecisionCandidate("secondary", "Use the secondary route."), - ]); - - [Fact] - public async Task ConsumerOwnedDecisionSelectorPreservesForwardingAndLifetime() + [Theory] + [InlineData(RouteKind.Fast, "fast-model")] + [InlineData(RouteKind.Reasoning, "reasoning-model")] + public async Task ConsumerOwnedTypedDecisionSelectorSelectsConfiguredClientAndPreservesRequest( + RouteKind expectedRoute, + string expectedModel) { - ChatMessage[] messages = [new(ChatRole.User, "hello")]; - ChatOptions options = new() { ModelId = "request-model" }; + ChatMessage[] messages = + [ + new(ChatRole.User, "Compare the retry behavior of these two designs."), + new(ChatRole.Assistant, "The first design retries immediately."), + ]; + ChatOptions options = new() { ModelId = "caller-model", Temperature = 0.2f }; using CancellationTokenSource cancellation = new(); - using RecordingDecisionClient decisionClient = new("primary"); - using CountingChatClient selectedClient = new(); - Dictionary clients = new(StringComparer.Ordinal) + using RecordingDecisionClient decisionClient = new(expectedRoute); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + IReadOnlyDictionary clients = new Dictionary { - ["primary"] = selectedClient, - }; - IEnumerable? forwardedMessages = null; - ChatOptions? forwardedOptions = null; - ChatResponse expectedResponse = new(new ChatMessage(ChatRole.Assistant, "selected")); - selectedClient.GetResponseAsyncCallback = (forwarded, forwardedChatOptions, token) => - { - forwardedMessages = forwarded; - forwardedOptions = forwardedChatOptions; - Assert.Equal(cancellation.Token, token); - return Task.FromResult(expectedResponse); + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, }; - using RoutingChatClient router = CreateConsumerOwnedRouter( + using RoutingChatClient router = CreateRouter(decisionClient, clients); + ChatResponse response = await router.GetResponseAsync(messages, options, cancellation.Token); + + ConfiguredChatClient selected = expectedRoute == RouteKind.Fast ? fastClient : reasoningClient; + ConfiguredChatClient unused = expectedRoute == RouteKind.Fast ? reasoningClient : fastClient; + Assert.Equal($"{expectedRoute switch { RouteKind.Fast => "fast", _ => "reasoning" }} response", response.Text); + Assert.Same(messages, selected.ForwardedMessages); + Assert.NotSame(options, selected.ForwardedOptions); + Assert.Equal(expectedModel, selected.ForwardedOptions!.ModelId); + Assert.Equal(0.2f, selected.ForwardedOptions.Temperature); + Assert.Equal("caller-model", options.ModelId); + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(1, selected.CallCount); + Assert.Equal(0, unused.CallCount); + Assert.Equal(cancellation.Token, decisionClient.LastCancellationToken); + Assert.Equal(cancellation.Token, selected.LastCancellationToken); + + router.Dispose(); + Assert.Equal(0, selected.DisposeCount); + } + + [Fact] + public async Task ConsumerOwnedTypedDecisionSelectorForwardsStreamingResponse() + { + ChatMessage[] messages = [new(ChatRole.User, "Stream the reasoning route.")]; + ChatOptions options = new() { ModelId = "caller-model" }; + using RecordingDecisionClient decisionClient = new(RouteKind.Reasoning); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( decisionClient, - clients, - context => + new Dictionary { - Assert.Same(messages, context.Messages); - context.ChatOptions!.ModelId = "configured-primary"; - return new RoutingDecisionState( - context.Messages.Count(), - context.ChatOptions.ModelId, - context.ChatOptions.Tools is not null); + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, }); - ChatResponse actual = await router.GetResponseAsync(messages, options, cancellation.Token); + List updates = []; + await foreach (ChatResponseUpdate update in router.GetStreamingResponseAsync(messages, options)) + { + updates.Add(update); + } - Assert.Same(expectedResponse, actual); - Assert.Same(messages, forwardedMessages); - Assert.NotSame(options, forwardedOptions); - Assert.Equal("configured-primary", forwardedOptions!.ModelId); - Assert.Equal("request-model", options.ModelId); + Assert.Equal("reasoning response", Assert.Single(updates).Text); + Assert.Same(messages, reasoningClient.ForwardedMessages); + Assert.Equal("reasoning-model", reasoningClient.ForwardedOptions!.ModelId); Assert.Equal(1, decisionClient.CallCount); - Assert.Equal(cancellation.Token, decisionClient.LastCancellationToken); - - router.Dispose(); - Assert.Equal(0, selectedClient.DisposeCount); + Assert.Equal(1, reasoningClient.StreamingCallCount); + Assert.Equal(0, fastClient.StreamingCallCount); } [Fact] - public async Task ConsumerOwnedDecisionSelectorRequiresConfiguredCandidate() + public async Task ConsumerOwnedTypedDecisionSelectorRequiresConfiguredCandidate() { - using RecordingDecisionClient decisionClient = new("secondary"); - using CountingChatClient primaryClient = new(); - using RoutingChatClient router = CreateConsumerOwnedRouter( + using RecordingDecisionClient decisionClient = new(RouteKind.Fast) { ReturnUnconfigured = true }; + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( decisionClient, - new Dictionary(StringComparer.Ordinal) + new Dictionary { - ["primary"] = primaryClient, + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, }); - InvalidOperationException exception = await Assert.ThrowsAsync( - () => router.GetResponseAsync([new(ChatRole.User, "hello")])); + await Assert.ThrowsAsync( + () => router.GetResponseAsync([new(ChatRole.User, "select a route")])); - Assert.Contains("secondary", exception.Message, StringComparison.Ordinal); Assert.Equal(1, decisionClient.CallCount); - Assert.Equal(0, primaryClient.CallCount); + Assert.Equal(0, fastClient.CallCount); + Assert.Equal(0, reasoningClient.CallCount); + } + + [Theory] + [InlineData(RouteResponseFailure.Provider)] + [InlineData(RouteResponseFailure.WrongQuestion)] + public async Task ConsumerOwnedTypedDecisionSelectorPropagatesDecisionFailureBeforeChat( + RouteResponseFailure failure) + { + using RecordingDecisionClient decisionClient = new(RouteKind.Fast) { Failure = failure }; + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( + decisionClient, + new Dictionary + { + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, + }); + + Type exceptionType = failure == RouteResponseFailure.Provider + ? typeof(DecisionClientException) + : typeof(DecisionProtocolException); + Exception exception = await Assert.ThrowsAsync( + exceptionType, + () => router.GetResponseAsync([new(ChatRole.User, "select a route")])); + + Assert.NotNull(exception); + Assert.Equal(0, fastClient.CallCount); + Assert.Equal(0, reasoningClient.CallCount); } [Fact] - public async Task ConsumerOwnedDecisionSelectorPropagatesCancellation() + public async Task ConsumerOwnedTypedDecisionSelectorPropagatesCancellationBeforeChat() { using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - using RecordingDecisionClient decisionClient = new("primary"); - using CountingChatClient selectedClient = new(); - using RoutingChatClient router = CreateConsumerOwnedRouter( + using RecordingDecisionClient decisionClient = new(RouteKind.Fast); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using RoutingChatClient router = CreateRouter( decisionClient, - new Dictionary(StringComparer.Ordinal) + new Dictionary { - ["primary"] = selectedClient, + [RouteKind.Fast] = fastClient, }); await Assert.ThrowsAnyAsync( - () => router.GetResponseAsync([new(ChatRole.User, "hello")], cancellationToken: cancellation.Token)); + () => router.GetResponseAsync( + [new(ChatRole.User, "select a route")], + cancellationToken: cancellation.Token)); Assert.Equal(1, decisionClient.CallCount); - Assert.Equal(0, selectedClient.CallCount); + Assert.Equal(0, fastClient.CallCount); } - private static RoutingChatClient CreateConsumerOwnedRouter( + private static RoutingChatClient CreateRouter( IDecisionClient decisionClient, - IReadOnlyDictionary clients, - Func? projectState = null) - { - projectState ??= static context => new( - context.Messages.Count(), - context.ChatOptions?.ModelId, - context.ChatOptions?.Tools is not null); - - return RoutingChatClient.Create(async (context, cancellationToken) => + IReadOnlyDictionary clients) => + RoutingChatClient.Create(async (context, cancellationToken) => { - RoutingDecisionState state = projectState(context); - DecisionResponse response = await decisionClient.GetResponseAsync( + string requestText = string.Join( + "\n", + context.Messages.Select(static message => message.Text)); + RoutingState state = new(requestText, context.ChatOptions?.Tools is not null); + DecisionResponse response = await decisionClient.GetResponseAsync( state, - DecisionRoutingJsonContext.Default.RoutingDecisionState, - [_routeQuestion], + DecisionRoutingJsonContext.Default.RoutingState, + CreateDefinition(), cancellationToken: cancellationToken); - string selectedId = ((ChoiceDecisionAnswer)response.GetAnswer("route")).SelectedCandidateId; - if (!_routeQuestion.Candidates.Any(candidate => string.Equals(candidate.Id, selectedId, StringComparison.Ordinal)) || - !clients.TryGetValue(selectedId, out IChatClient? selected)) + RouteKind selectedRoute = response.Result.Route; + if (!clients.TryGetValue(selectedRoute, out IChatClient? selected)) { - throw new InvalidOperationException($"Decision selected unconfigured route '{selectedId}'."); + throw new InvalidOperationException($"Decision selected unconfigured route '{selectedRoute}'."); } return selected; }); - } + + private static DecisionDefinition CreateDefinition() => + DecisionDefinition.Create( + DecisionRoutingJsonContext.Default.RoutingDecision, + definition => definition.Choice(result => result.Route)); private sealed class RecordingDecisionClient : IDecisionClient { - private readonly string _selectedId; + private readonly RouteKind _selectedRoute; - public RecordingDecisionClient(string selectedId) + public RecordingDecisionClient(RouteKind selectedRoute) { - _selectedId = selectedId; + _selectedRoute = selectedRoute; } public int CallCount { get; private set; } public CancellationToken LastCancellationToken { get; private set; } + public RouteResponseFailure Failure { get; init; } + + public bool ReturnUnconfigured { get; init; } + public Task GetResponseAsync( DecisionRequest request, DecisionOptions? options = null, @@ -164,21 +218,46 @@ public Task GetResponseAsync( CallCount++; LastCancellationToken = cancellationToken; + if (Failure == RouteResponseFailure.Provider) + { + return Task.FromException( + new DecisionClientException("route provider failed", isTransient: true)); + } + if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } + DecisionQuestion question = request.Questions[0]; + if (Failure == RouteResponseFailure.WrongQuestion) + { + question = new ChoiceDecisionQuestion( + "wrong-route-question", + question.Instructions, + ((ChoiceDecisionQuestion)question).Candidates); + } + + ChoiceDecisionQuestion choice = Assert.IsType(question); + string selectedId = ReturnUnconfigured + ? "unconfigured" + : _selectedRoute switch + { + RouteKind.Fast => choice.Candidates[0].Id, + RouteKind.Reasoning => choice.Candidates[1].Id, + _ => throw new InvalidOperationException(), + }; + DecisionRequest responseRequest = new(request.State, [question]); return Task.FromResult( new DecisionResponse( - request, + responseRequest, [ new ChoiceDecisionAnswer( - "route", - _selectedId, + question.Id, + selectedId, [ - new("primary", _selectedId == "primary" ? 0.8 : 0.2), - new("secondary", _selectedId == "secondary" ? 0.8 : 0.2), + new(choice.Candidates[0].Id, selectedId == choice.Candidates[0].Id ? 0.9 : 0.1), + new(choice.Candidates[1].Id, selectedId == choice.Candidates[1].Id ? 0.9 : 0.1), ]), ])); } @@ -190,13 +269,28 @@ public void Dispose() } } - private sealed class CountingChatClient : IChatClient + private sealed class ConfiguredChatClient : IChatClient { + private readonly string _configuredModel; + private readonly string _responseText; + + public ConfiguredChatClient(string configuredModel, string responseText) + { + _configuredModel = configuredModel; + _responseText = responseText; + } + public int CallCount { get; private set; } + public int StreamingCallCount { get; private set; } + public int DisposeCount { get; private set; } - public Func, ChatOptions?, CancellationToken, Task>? GetResponseAsyncCallback { get; set; } + public IEnumerable? ForwardedMessages { get; private set; } + + public ChatOptions? ForwardedOptions { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } public Task GetResponseAsync( IEnumerable messages, @@ -204,25 +298,58 @@ public Task GetResponseAsync( CancellationToken cancellationToken = default) { CallCount++; - return GetResponseAsyncCallback?.Invoke(messages, options, cancellationToken) ?? - Task.FromResult(new ChatResponse()); + ForwardedMessages = messages; + ChatOptions forwardedOptions = options ?? new ChatOptions(); + ForwardedOptions = forwardedOptions; + LastCancellationToken = cancellationToken; + forwardedOptions.ModelId = _configuredModel; + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _responseText))); } - public IAsyncEnumerable GetStreamingResponseAsync( + public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + StreamingCallCount++; + ForwardedMessages = messages; + ChatOptions forwardedOptions = options ?? new ChatOptions(); + ForwardedOptions = forwardedOptions; + LastCancellationToken = cancellationToken; + forwardedOptions.ModelId = _configuredModel; + + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, _responseText); + } public object? GetService(Type serviceType, object? serviceKey = null) => null; public void Dispose() => DisposeCount++; } - internal sealed record RoutingDecisionState(int MessageCount, string? ModelId, bool HasTools); + public enum RouteResponseFailure + { + None, + Provider, + WrongQuestion, + } + + public enum RouteKind + { + Fast, + Reasoning, + } + + internal sealed record RoutingDecision(RouteKind Route); + + internal sealed record RoutingState(string RequestText, bool HasTools); } -[System.Text.Json.Serialization.JsonSourceGenerationOptions( - PropertyNamingPolicy = System.Text.Json.Serialization.JsonKnownNamingPolicy.CamelCase)] -[System.Text.Json.Serialization.JsonSerializable(typeof(DecisionRoutingProofTests.RoutingDecisionState))] -internal sealed partial class DecisionRoutingJsonContext : System.Text.Json.Serialization.JsonSerializerContext; +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionRoutingProofTests.RoutingDecision))] +[JsonSerializable(typeof(DecisionRoutingProofTests.RoutingState))] +internal sealed partial class DecisionRoutingJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs index 18a7ad1034e..98a2767830e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -18,105 +18,70 @@ namespace Microsoft.Extensions.AI; public sealed class DecisionTypedFunctionTests { [Fact] - public async Task AIFunctionFactory_RoundTripsSourceGeneratedTypedBusinessResultAndEvidence() + public async Task AIFunctionFactory_UsesAnnotatedMethodAndTypedDecisionDefinition() { using SupportTicketDecisionClient client = new(); DecisionDefinition definition = CreateDefinition(); - DecisionResponse? observed = null; + SupportTicketFunction host = new(client, definition); AIFunction function = AIFunctionFactory.Create( - async (SupportTicket ticket, CancellationToken cancellationToken) => - { - observed = await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); - return observed.Result; - }, + host.AnalyzeAsync, new AIFunctionFactoryOptions { - Name = "analyze_support_ticket", SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, }); - JsonElement input = JsonSerializer.SerializeToElement( - new SupportTicket("The product is unavailable and I need help."), - DecisionTypedFunctionJsonContext.Default.SupportTicket); + Assert.Equal("analyze_support_ticket", function.Name); + Assert.Equal("Annotate one support ticket.", function.Description); + JsonElement schema = function.JsonSchema; + JsonElement properties = schema.GetProperty("properties"); + Assert.Equal(["ticket"], properties.EnumerateObject().Select(static property => property.Name)); + Assert.Equal( + "The ticket to classify.", + properties.GetProperty("ticket").GetProperty("description").GetString()); + Assert.Contains("ticket", schema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); + Assert.DoesNotContain("client", properties.EnumerateObject().Select(static property => property.Name)); + Assert.DoesNotContain("definition", properties.EnumerateObject().Select(static property => property.Name)); + Assert.DoesNotContain("cancellationToken", properties.EnumerateObject().Select(static property => property.Name)); + JsonElement output = Assert.IsType( - await function.InvokeAsync(new AIFunctionArguments { ["ticket"] = input })); + await function.InvokeAsync(new AIFunctionArguments + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + })); - Assert.Equal("analyze_support_ticket", function.Name); Assert.Equal("Technical", output.GetProperty("category").GetString()); Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); - - Assert.NotNull(observed); - Assert.Equal(new TicketAnalysis(TicketCategory.Technical, 0.25), observed.Result); Assert.Equal(1, client.CallCount); Assert.Equal( "The product is unavailable and I need help.", client.LastRequest!.State.GetProperty("message").GetString()); - - ChoiceDecisionQuestion category = Assert.IsType( - client.LastRequest.Questions[0]); - Assert.Equal(["Billing", "Technical", "Account"], category.Candidates.Select(static candidate => candidate.Id)); - Assert.Equal( - [ - "Payment, invoice, or charge concerns.", - "Product defects, errors, outages, or troubleshooting.", - "Sign-in, profile, or account-access concerns.", - ], - category.Candidates.Select(static candidate => candidate.Description)); - - Assert.Equal( - new Dictionary - { - [TicketCategory.Billing] = 0.2, - [TicketCategory.Technical] = 0.5, - [TicketCategory.Account] = 0.3, - }, - observed.GetDistribution(result => result.Category)); - Assert.Equal( - 0.25, - ((BinaryDecisionAnswer)observed.Evidence.GetAnswer("refundRequestProbability")).TrueProbability); - Assert.Equal("test-provider", observed.Evidence.Provenance!.ProviderName); - Assert.Equal("provider-evidence", observed.Evidence.RawRepresentation); - Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); } [Fact] - public async Task AIFunctionFactory_CanReturnMetadataWithoutAnAnalyticsContract() + public async Task AIFunctionFactory_CanReturnBusinessResultAndRecordEvidencePerInvocation() { using SupportTicketDecisionClient client = new(); DecisionDefinition definition = CreateDefinition(); - DecisionResponse? observed = null; + List evidence = []; + SupportTicketFunction host = new(client, definition, evidence.Add); AIFunction function = AIFunctionFactory.Create( - async (SupportTicket ticket, CancellationToken cancellationToken) => - { - observed = await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); - - return new MetadataOnlyToolResult( - observed.Result, - observed.Evidence.Provenance, - observed.Evidence.Usage); - }, + host.AnalyzeWithMetadataAsync, new AIFunctionFactoryOptions { - Name = "analyze_support_ticket", SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, }); JsonElement output = Assert.IsType( - await function.InvokeAsync( - new AIFunctionArguments - { - ["ticket"] = JsonSerializer.SerializeToElement( - new SupportTicket("The product is unavailable and I need help."), - DecisionTypedFunctionJsonContext.Default.SupportTicket), - })); + await function.InvokeAsync(new AIFunctionArguments + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + })); Assert.Equal("Technical", output.GetProperty("result").GetProperty("category").GetString()); Assert.Equal(0.25, output.GetProperty("result").GetProperty("refundRequestProbability").GetDouble()); @@ -124,49 +89,114 @@ await function.InvokeAsync( Assert.Equal("test-model", output.GetProperty("provenance").GetProperty("modelId").GetString()); Assert.Equal(7, output.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); Assert.Equal(2, output.GetProperty("usage").GetProperty("outputTokenCount").GetInt32()); - Assert.False(output.TryGetProperty("features", out _)); Assert.DoesNotContain("provider-evidence", output.GetRawText(), StringComparison.Ordinal); Assert.DoesNotContain("retained", output.GetRawText(), StringComparison.Ordinal); Assert.DoesNotContain("The product is unavailable", output.GetRawText(), StringComparison.Ordinal); - Assert.NotNull(observed); - Assert.Equal("provider-evidence", observed.Evidence.RawRepresentation); - Assert.Equal("retained", observed.Evidence.AdditionalProperties!["evidence"]); - Assert.Equal(1, client.CallCount); + Assert.Single(evidence); + Assert.Equal("provider-evidence", evidence[0].RawRepresentation); + Assert.Equal("retained", evidence[0].AdditionalProperties!["evidence"]); } [Fact] - public async Task AIFunctionFactory_RejectsWrongDomainResponseWithoutToolResult() + public void AIFunctionFactory_OptionsOverrideAnnotatedMetadata() { - using SupportTicketDecisionClient client = new() { ReturnWrongDomain = true }; - DecisionResponse? observed = null; + using SupportTicketDecisionClient client = new(); + SupportTicketFunction host = new(client, CreateDefinition()); AIFunction function = AIFunctionFactory.Create( - async (SupportTicket ticket, CancellationToken cancellationToken) => - { - observed = await client.GetResponseAsync( - ticket, - CreateDefinition(), - cancellationToken: cancellationToken); - return observed.Result; - }, + host.AnalyzeAsync, new AIFunctionFactoryOptions { - Name = "analyze_support_ticket", + Name = "override_name", + Description = "Override description.", SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, }); - JsonElement input = JsonSerializer.SerializeToElement( - new SupportTicket("Please review this account issue."), - DecisionTypedFunctionJsonContext.Default.SupportTicket); + Assert.Equal("override_name", function.Name); + Assert.Equal("Override description.", function.Description); + } + + [Fact] + public async Task AIFunctionFactory_PropagatesCancellationProviderAndHostFailures() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using SupportTicketDecisionClient cancelledClient = new(); + SupportTicketFunction cancelledHost = new(cancelledClient, CreateDefinition()); + AIFunction cancelledFunction = CreateFunction(cancelledHost.AnalyzeAsync); + + await Assert.ThrowsAnyAsync( + () => cancelledFunction.InvokeAsync( + CreateArguments(), + cancellation.Token).AsTask()); + Assert.Equal(1, cancelledClient.CallCount); + + DecisionClientException providerFailure = new("provider failed", isTransient: true); + using SupportTicketDecisionClient failedClient = new() { Failure = providerFailure }; + AIFunction failedFunction = CreateFunction(new SupportTicketFunction(failedClient, CreateDefinition()).AnalyzeAsync); + + DecisionClientException actualProviderFailure = await Assert.ThrowsAsync( + () => failedFunction.InvokeAsync(CreateArguments()).AsTask()); + Assert.Same(providerFailure, actualProviderFailure); + + InvalidOperationException hostFailure = new("host evidence failed"); + using SupportTicketDecisionClient hostClient = new(); + SupportTicketFunction failingHost = new(hostClient, CreateDefinition(), _ => throw hostFailure); + AIFunction hostFunction = CreateFunction(failingHost.AnalyzeWithMetadataAsync); + + InvalidOperationException actualHostFailure = await Assert.ThrowsAsync( + () => hostFunction.InvokeAsync(CreateArguments()).AsTask()); + Assert.Same(hostFailure, actualHostFailure); + } + + [Theory] + [InlineData(ResponseMismatch.State)] + [InlineData(ResponseMismatch.Question)] + [InlineData(ResponseMismatch.Candidate)] + public async Task AIFunctionFactory_RejectsResponseCorrelationBeforeResultMapping(ResponseMismatch mismatch) + { + int mapperCalls = 0; + using SupportTicketDecisionClient client = new() { Mismatch = mismatch }; + DecisionDefinition definition = CreateDefinition(); + SupportTicketFunction host = new(client, definition, _ => mapperCalls++); + AIFunction function = CreateFunction(host.AnalyzeWithMetadataAsync); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateArguments()).AsTask()); + + Assert.Equal(1, client.CallCount); + Assert.Equal(0, mapperCalls); + } + + [Fact] + public async Task AIFunctionFactory_RejectsInvalidSelectedCandidateDuringTypedBinding() + { + using SupportTicketDecisionClient client = new() { InvalidSelectedCandidate = true }; + AIFunction function = CreateFunction(new SupportTicketFunction(client, CreateDefinition()).AnalyzeAsync); - DecisionProtocolException exception = await Assert.ThrowsAsync( - () => function.InvokeAsync(new AIFunctionArguments { ["ticket"] = input }).AsTask()); + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateArguments()).AsTask()); - Assert.NotNull(exception); Assert.Equal(1, client.CallCount); - Assert.Null(observed); } + private static AIFunction CreateFunction(Delegate method) => + AIFunctionFactory.Create( + method, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + private static AIFunctionArguments CreateArguments() => + new() + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("Please review this account issue."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + }; + private static DecisionDefinition CreateDefinition() => DecisionDefinition.Create( DecisionTypedFunctionJsonContext.Default.TicketAnalysis, @@ -176,11 +206,65 @@ private static DecisionDefinition CreateDefinition() => definition.BinaryProbability(result => result.RefundRequestProbability); }); + private sealed class SupportTicketFunction + { + private readonly IDecisionClient _client; + private readonly DecisionDefinition _definition; + private readonly Action? _recordEvidence; + + public SupportTicketFunction( + IDecisionClient client, + DecisionDefinition definition, + Action? recordEvidence = null) + { + _client = client; + _definition = definition; + _recordEvidence = recordEvidence; + } + + [DisplayName("analyze_support_ticket")] + [Description("Annotate one support ticket.")] + public async Task AnalyzeAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + ticket, + DecisionTypedFunctionJsonContext.Default.SupportTicket, + _definition, + cancellationToken: cancellationToken); + _recordEvidence?.Invoke(response.Evidence); + return response.Result; + } + + [DisplayName("analyze_support_ticket_with_metadata")] + [Description("Annotate one support ticket and return selected decision metadata.")] + public async Task AnalyzeWithMetadataAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + ticket, + DecisionTypedFunctionJsonContext.Default.SupportTicket, + _definition, + cancellationToken: cancellationToken); + _recordEvidence?.Invoke(response.Evidence); + return new( + response.Result, + response.Evidence.Provenance, + response.Evidence.Usage); + } + } + private sealed class SupportTicketDecisionClient : IDecisionClient { public int CallCount { get; private set; } - public bool ReturnWrongDomain { get; init; } + public DecisionClientException? Failure { get; init; } + + public ResponseMismatch Mismatch { get; init; } + + public bool InvalidSelectedCandidate { get; init; } public DecisionRequest? LastRequest { get; private set; } @@ -192,50 +276,56 @@ public Task GetResponseAsync( CallCount++; LastRequest = request; + if (Failure is not null) + { + return Task.FromException(Failure); + } + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + DecisionQuestion[] questions = request.Questions.ToArray(); - if (ReturnWrongDomain) + JsonElement state = request.State; + if (Mismatch == ResponseMismatch.State) + { + state = JsonSerializer.SerializeToElement( + new SupportTicket("different state"), + DecisionTypedFunctionJsonContext.Default.SupportTicket); + } + + if (Mismatch == ResponseMismatch.Question) + { + ChoiceDecisionQuestion choice = Assert.IsType(questions[0]); + questions[0] = new ChoiceDecisionQuestion("different-question", choice.Instructions, choice.Candidates); + } + else if (Mismatch == ResponseMismatch.Candidate) { ChoiceDecisionQuestion choice = Assert.IsType(questions[0]); questions[0] = new ChoiceDecisionQuestion( choice.Id, choice.Instructions, - [new DecisionCandidate("Other", "An unrelated domain.")]); + [new DecisionCandidate("unrelated", "Unrelated candidate.")]); } - DecisionRequest responseRequest = new(request.State, questions); + DecisionRequest responseRequest = new(state, questions); ChoiceDecisionQuestion responseChoice = Assert.IsType(responseRequest.Questions[0]); BinaryDecisionQuestion responseBinary = Assert.IsType(responseRequest.Questions[1]); - string selectedCandidateId = responseChoice.Candidates.Count > 1 - ? responseChoice.Candidates[1].Id - : responseChoice.Candidates[0].Id; - DecisionProbability[] probabilities = responseChoice.Candidates.Select((candidate, index) => - { - double probability; - if (responseChoice.Candidates.Count == 1) - { - probability = 1; - } - else - { - probability = index switch - { - 0 => 0.2, - 1 => 0.5, - _ => 0.3, - }; - } - - return new DecisionProbability(candidate.Id, probability); - }).ToArray(); + string selectedCandidateId = InvalidSelectedCandidate + ? "unknown" + : responseChoice.Candidates[Math.Min(1, responseChoice.Candidates.Count - 1)].Id; + DecisionProbability[] probabilities = responseChoice.Candidates + .Select((candidate, index) => new DecisionProbability(candidate.Id, GetProbability( + responseChoice.Candidates.Count, + index))) + .ToArray(); return Task.FromResult( new DecisionResponse( responseRequest, [ - new ChoiceDecisionAnswer( - responseChoice.Id, - selectedCandidateId, - probabilities), + new ChoiceDecisionAnswer(responseChoice.Id, selectedCandidateId, probabilities), new BinaryDecisionAnswer(responseBinary.Id, 0.25), ], new DecisionProvenance(providerName: "test-provider", modelId: "test-model"), @@ -244,6 +334,16 @@ public Task GetResponseAsync( additionalProperties: new Dictionary { ["evidence"] = "retained" })); } + private static double GetProbability(int candidateCount, int index) => + candidateCount == 1 + ? 1 + : index switch + { + 0 => 0.2, + 1 => 0.5, + _ => 0.3, + }; + public object? GetService(Type serviceType, object? serviceKey = null) => null; public void Dispose() @@ -251,6 +351,14 @@ public void Dispose() } } + public enum ResponseMismatch + { + None, + State, + Question, + Candidate, + } + internal sealed record SupportTicket(string Message); internal enum TicketCategory From 47725b10a9029aa9ef1fe5fcb07952c2272db442 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 13:09:18 -0400 Subject: [PATCH 10/12] Clarify one-operation function semantics Document that each ordinary function invocation performs one decision operation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Microsoft.Extensions.AI.Abstractions/Decisions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 600143c593a..3b88c49d103 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -147,7 +147,7 @@ ChatResponse chatResponse = await chatClient.GetResponseAsync( cancellationToken); ``` -The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. The decision call is one logical operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. +The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. Each invocation performs one logical decision operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. `AIFunctionFactory` options can override method attributes. Its schema contains only the application input (`ticket` here); `client`, `definition`, and `CancellationToken` are infrastructure, not model-facing parameters. The middleware example above exercises normal function invocation; direct `AIFunction.InvokeAsync` is sufficient for a platform-only proof and should not be described as a chat-loop proof. From 3d80867348306a13aec7bf23cd8c0ff94473e126 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 14:36:04 -0400 Subject: [PATCH 11/12] Add decision definition function helper Add the thin definition-based AIFunction convenience member while preserving stock factory semantics, source-generated state metadata, caller-owned clients, and per-invocation decision option cloning. Extend focused proofs and align the Layer 2 function and routing examples with the approved provider and ownership boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionDefinitions.cs | 94 +++++++ .../Decisions/README.md | 167 +++++++++--- .../Microsoft.Extensions.AI.Abstractions.json | 4 + .../Decisions/DecisionTypedFunctionTests.cs | 241 ++++++++++++++++++ ...ft.Extensions.AI.Abstractions.Tests.csproj | 1 + 5 files changed, 467 insertions(+), 40 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 926f0fa8812..887426af88b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -19,6 +19,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -92,6 +94,63 @@ public static DecisionDefinition Create( return builder.Build(); } + private static AIFunctionFactoryOptions GetFunctionOptions( + AIFunctionFactoryOptions? functionOptions, + JsonSerializerOptions serializerOptions) + { + if (functionOptions is null) + { + return new AIFunctionFactoryOptions { SerializerOptions = serializerOptions }; + } + + if (functionOptions.SerializerOptions is not null) + { + return functionOptions; + } + + return new AIFunctionFactoryOptions + { + SerializerOptions = serializerOptions, + JsonSchemaCreateOptions = functionOptions.JsonSchemaCreateOptions, + Name = functionOptions.Name, + Description = functionOptions.Description, + AdditionalProperties = functionOptions.AdditionalProperties, + ConfigureParameterBinding = functionOptions.ConfigureParameterBinding, + MarshalResult = functionOptions.MarshalResult, + ExcludeResultSchema = functionOptions.ExcludeResultSchema, + }; + } + + /// Creates an that evaluates this definition for a caller-owned state type. + /// The application-owned state type exposed as the function's input. + /// The caller-owned decision client. + /// Optional standard function factory options. + /// + /// Optional decision options shallow-copied during construction and cloned for each invocation. Nested values and + /// the raw-options factory remain caller/provider-owned. + /// + /// + /// Optional explicit JSON contract for . When omitted, the definition's serializer + /// options must contain the state contract. + /// + /// A function whose only model-facing input is and whose result is . + public AIFunction AsAIFunction( + IDecisionClient client, + AIFunctionFactoryOptions? functionOptions = null, + DecisionOptions? decisionOptions = null, + JsonTypeInfo? stateTypeInfo = null) + { + _ = Throw.IfNull(client); + + stateTypeInfo ??= GetStateTypeInfo(); + DecisionOptions? decisionOptionsSnapshot = decisionOptions?.Clone(); + DecisionFunctionInvoker invoker = new(client, this, stateTypeInfo, decisionOptionsSnapshot); + + return AIFunctionFactory.Create( + invoker.InvokeAsync, + GetFunctionOptions(functionOptions, stateTypeInfo.Options)); + } + internal DecisionResponse Bind(DecisionResponse response, DecisionRequest expectedRequest) { _ = Throw.IfNull(response); @@ -186,6 +245,41 @@ internal DecisionResponse Bind(DecisionResponse response, DecisionReque return new DecisionResponse(result, response, this); } + private JsonTypeInfo GetStateTypeInfo() + { + return SerializerOptions.GetTypeInfo(typeof(TState)) is JsonTypeInfo stateTypeInfo + ? stateTypeInfo + : throw new NotSupportedException( + $"The configured JSON options do not contain a contract for state type '{typeof(TState)}'."); + } + + private sealed class DecisionFunctionInvoker( + IDecisionClient client, + DecisionDefinition definition, + JsonTypeInfo stateTypeInfo, + DecisionOptions? decisionOptionsSnapshot) + { + private readonly IDecisionClient _client = client; + private readonly DecisionDefinition _definition = definition; + private readonly JsonTypeInfo _stateTypeInfo = stateTypeInfo; + private readonly DecisionOptions? _decisionOptionsSnapshot = decisionOptionsSnapshot; + + [DisplayName("evaluate_decision")] + [Description("Evaluate the declared decision for one application state.")] + public async Task InvokeAsync( + [Description("The application state for the declared decision.")] TState state, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + state, + _stateTypeInfo, + _definition, + _decisionOptionsSnapshot?.Clone(), + cancellationToken).ConfigureAwait(false); + return response.Result; + } + } + internal IReadOnlyDictionary GetDistribution( DecisionResponse response, Expression> selector) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 3b88c49d103..d2e80a86379 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -32,19 +32,9 @@ public sealed record TicketAnalysis( [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); ``` -The host explicitly supplies an `IDecisionClient`. It is provider-neutral; MEAI does not create a default implementation and an arbitrary `IChatClient` is not automatically a decision client. In the external, unpublished provider proof, acquisition is separate from platform composition: +The host explicitly supplies an `IDecisionClient`. It is provider-neutral; MEAI does not create a default implementation and an arbitrary `IChatClient` is not automatically a decision client. Provider acquisition is intentionally separate from platform composition: the provider owner supplies the configured client entry point, while these examples consume only the interface. Jev/OllamaSharp and the Ollaya, Laya, Julia, and Qwen adapters remain provider-owned and are not shipped, repinned, or executed by this repository. A deterministic test double is used for the platform proofs. -```csharp -// External proof/provider packages only; not shipped by Microsoft.Extensions.AI. -using DecisionInference.Julia; -using DecisionInference.MEAI; - -JuliaDecisionGenerator generator = - JuliaDecisionGenerator.LoadFromDirectory(assetDirectory); -IDecisionClient client = generator.AsDecisionClient(); -``` - -The Julia/MEAI bridge above is historical implementation evidence, not a released dependency or a claim that this repository repins or executes a provider. Jev/OllamaSharp and the Ollaya, Laya, Julia, and Qwen adapters remain provider-owned. A deterministic test double is used for the platform proofs. +An external, unpublished Julia/MEAI bridge was used as historical implementation evidence. It is not a released dependency or a runnable setup promised by this README. The provider owner's desired native shape is a separate, prospective entry point such as `JuliaDecisionClient.LoadFromDirectory(assetDirectory)`; that client is not implemented or available in this package. The reusable typed declaration is ordinary application code: @@ -99,37 +89,35 @@ public partial class TicketJsonContext : JsonSerializerContext; ## Layer 2 interoperability: two independent paths -Layer 2 deliberately adds no decision-specific function wrapper and no public routing type. The two useful compositions are independent consumer-owned uses of existing MEAI primitives. +Layer 2 keeps function composition and chat routing independent. The function examples use either the stock `AIFunctionFactory` directly or the thin definition-based `AsAIFunction` convenience member. Neither path discovers a provider or takes a client from model-supplied arguments. ### 1. Annotated application method to `AIFunctionFactory` An ordinary annotated method can close over a host-injected client and definition. The client, definition, endpoint, model, and options are not model-supplied tool arguments: ```csharp -public sealed class SupportTicketTools( - IDecisionClient client, - DecisionDefinition definition, - Action? recordEvidence = null) +// Inside an existing application type, these are ordinary host-owned fields: +// IDecisionClient client; +// DecisionDefinition definition; +// Action? recordEvidence; + +[DisplayName("analyze_support_ticket")] +[Description("Annotate one support ticket.")] +public async Task AnalyzeSupportTicketAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) { - [DisplayName("analyze_support_ticket")] - [Description("Annotate one support ticket.")] - public async Task AnalyzeAsync( - [Description("The ticket to classify.")] SupportTicket ticket, - CancellationToken cancellationToken) - { - DecisionResponse response = await client.GetResponseAsync( - ticket, - definition, - cancellationToken: cancellationToken); - - recordEvidence?.Invoke(response.Evidence); - return response.Result; - } + DecisionResponse response = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + recordEvidence?.Invoke(response.Evidence); + return response.Result; } -SupportTicketTools host = new(client, definition, recordEvidence); AIFunction analyzeTicket = AIFunctionFactory.Create( - host.AnalyzeAsync, + AnalyzeSupportTicketAsync, new AIFunctionFactoryOptions { Name = "analyze_support_ticket", @@ -147,11 +135,60 @@ ChatResponse chatResponse = await chatClient.GetResponseAsync( cancellationToken); ``` -The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. Each invocation performs one logical decision operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. +The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. Each invocation performs one logical decision operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. The callback must retain or process each invocation itself; the platform does not retain a last response. `AIFunctionFactory` options can override method attributes. Its schema contains only the application input (`ticket` here); `client`, `definition`, and `CancellationToken` are infrastructure, not model-facing parameters. The middleware example above exercises normal function invocation; direct `AIFunction.InvokeAsync` is sufficient for a platform-only proof and should not be described as a chat-loop proof. -For reflection-disabled or trimmed deployments, provide source-generated metadata at both boundaries: use `DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, ...)`, call the overload accepting `TicketJsonContext.Default.SupportTicket`, and set `AIFunctionFactoryOptions.SerializerOptions = TicketJsonContext.Default.Options`. Function metadata alone does not configure decision state binding. This is a source-generation-compatible deployment variant, not a Native AOT certification. +### 1a. Definition shorthand + +When the application does not need a custom evidence callback, the definition can create the same ordinary `AIFunction` directly. The generic state type is explicit, while the result type remains the `TResult` from the definition: + +```csharp +AIFunction analyzeTicket = definition.AsAIFunction( + client, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + Description = "Annotate one support ticket for review.", + SerializerOptions = TicketJsonContext.Default.Options, + }); +``` + +The helper exposes only one model-facing `state` parameter and returns `TicketAnalysis`. It does not return `DecisionResponse`, evidence, provenance, usage, raw provider data, or a credentials/options object. Use the annotated-method path when the host needs an explicit per-invocation evidence callback. + +For reflection-disabled or trimmed deployments, provide source-generated metadata at both boundaries. The state contract can also provide genuine generic input inference: + +```csharp +AIFunction generatedAnalyzeTicket = definition.AsAIFunction( + client, + functionOptions: new AIFunctionFactoryOptions + { + SerializerOptions = TicketJsonContext.Default.Options, + }, + stateTypeInfo: TicketJsonContext.Default.SupportTicket); +``` + +`TicketJsonContext.Default.Options` covers both `SupportTicket` and `TicketAnalysis`; `stateTypeInfo` supplies the `TState` contract while `TResult` comes from `definition`. This is source-generation-compatible validation, not a Native AOT certification. Standard factory options remain standard: name and description overrides, schema options, parameter binding, result marshalling, additional properties, and result-schema exclusion are passed through. `DecisionOptions`, when supplied, are copied during construction and cloned again for each invocation. The clone is deliberately shallow: the options dictionary is copied, but nested values and a raw-options factory remain caller/provider-owned. + +When registering a function with DI, resolve the caller-owned decision client once while constructing the ordinary function. Do not resolve services from model-supplied arguments or from an ambient service provider during invocation: + +```csharp +services.AddScoped(serviceProvider => +{ + IDecisionClient decisions = + serviceProvider.GetRequiredService(); + + return definition.AsAIFunction( + decisions, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = TicketJsonContext.Default.Options, + }); +}); +``` + +The registration's lifetime must be compatible with the registered client. The helper never disposes the client; the owner (for example, the DI scope) does. ### 2. Decision-driven chat routing @@ -160,18 +197,29 @@ Routing is a separate provider/client-selection problem. Construct one provider- ```csharp public enum RouteKind { + [Description("Fast: use for short, routine requests where lower latency is preferred and deep multi-step reasoning is not needed.")] Fast, + + [Description("Reasoning: use for multi-step analysis, comparisons, or requests that benefit from deeper deliberation.")] Reasoning, } public sealed record RoutingDecision(RouteKind Route); -public sealed record RoutingState(string RequestText, bool HasTools); +public sealed record RoutingMessage(string Role, string Text); +public sealed record RoutingState(IReadOnlyList Messages, bool HasTools); DecisionDefinition routeDefinition = DecisionDefinition.Create( RoutingJsonContext.Default.RoutingDecision, - builder => builder.Choice(result => result.Route)); - + builder => builder.Choice( + result => result.Route, + "Choose Fast for short, routine requests where latency is the priority. " + + "Choose Reasoning for multi-step analysis, comparisons, or requests that benefit from deeper deliberation.")); + +// Each provider-owned client must enforce its route identity on every request: +// Fast -> "fast-model"; Reasoning -> "reasoning-model". A provider's default +// model ID alone is not sufficient because RoutingChatClient forwards a clone +// of caller ChatOptions, including ModelId. IReadOnlyDictionary clients = new Dictionary { @@ -182,8 +230,47 @@ IReadOnlyDictionary clients = RoutingChatClient router = RoutingChatClient.Create( async (context, cancellationToken) => { + const int maxMessages = 32; + const int maxMessageTextCharacters = 4_096; + const int maxTotalTextCharacters = 64_000; + List projectedMessages = []; + int totalTextCharacters = 0; + foreach (ChatMessage message in context.Messages) + { + if (projectedMessages.Count == maxMessages) + { + throw new InvalidOperationException( + $"Route selection accepts at most {maxMessages} messages; no history is truncated."); + } + + if (message.Role != ChatRole.System && + message.Role != ChatRole.User && + message.Role != ChatRole.Assistant && + message.Role != ChatRole.Tool) + { + throw new NotSupportedException( + $"Role '{message.Role}' is not supported by this route selector."); + } + + if (message.Contents.Any(static content => content is not TextContent)) + { + throw new NotSupportedException( + "This route selector accepts text content only; no content may be silently dropped."); + } + + if (message.Text.Length > maxMessageTextCharacters || + totalTextCharacters > maxTotalTextCharacters - message.Text.Length) + { + throw new InvalidOperationException( + "Route selection text budgets were exceeded; no text is truncated."); + } + + projectedMessages.Add(new(message.Role.Value, message.Text)); + totalTextCharacters += message.Text.Length; + } + RoutingState state = new( - string.Join("\n", context.Messages.Select(message => message.Text)), + projectedMessages, context.ChatOptions?.Tools is not null); DecisionResponse decision = @@ -202,7 +289,7 @@ RoutingChatClient router = RoutingChatClient.Create( }); ``` -Each configured client owns its route-specific model identity and options. The router forwards the original messages and a cloned request-options object to exactly one selected client, including streaming calls. The caller owns the configured clients and remains responsible for disposing them; the router does not dispose borrowed clients. Invalid or wrong-domain decision responses, cancellation, and provider failures stop before chat forwarding. There is no retry, failover, conversation affinity, mid-stream switching, outcome learning, or automatic calibration. Tool loops run inside the selected client after routing; the first example starts a fresh decision per router call. Decision probabilities are task observations, not calibrated task-success guarantees. +Each configured client must enforce its route-specific model identity and options on every forwarded call (for example, by overwriting a caller-supplied `ChatOptions.ModelId` in the provider-owned client adapter or rejecting a conflicting value); merely setting a provider default is not enough. The router forwards the original messages and a cloned request-options object to exactly one selected client, including streaming calls. The caller owns the configured clients and remains responsible for disposing them; the router does not dispose borrowed clients. Invalid or wrong-domain decision responses, cancellation, and provider failures stop before chat forwarding. There is no retry, failover, conversation affinity, mid-stream switching, outcome learning, or automatic calibration. Routing selects before each ordinary or streaming call. If function middleware wraps the router, a later tool-loop round can re-enter the router; if a selected configured client owns its own loop, that client remains selected. This example does not add either behavior automatically. Decision probabilities are task observations, not calibrated task-success guarantees. ### Semantic ingestion proof diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index b422e38fe80..9e86807dd13 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5707,6 +5707,10 @@ { "Member": "static Microsoft.Extensions.AI.DecisionDefinition Microsoft.Extensions.AI.DecisionDefinition.Create(System.Text.Json.Serialization.Metadata.JsonTypeInfo resultTypeInfo, System.Action.Builder> configure);", "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DecisionDefinition.AsAIFunction(Microsoft.Extensions.AI.IDecisionClient client, Microsoft.Extensions.AI.AIFunctionFactoryOptions? functionOptions = null, Microsoft.Extensions.AI.DecisionOptions? decisionOptions = null, System.Text.Json.Serialization.Metadata.JsonTypeInfo? stateTypeInfo = null);", + "Stage": "Experimental" } ], "Properties": [ diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs index 98a2767830e..4886c0879d9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -11,6 +11,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Microsoft.Extensions.AI; @@ -60,6 +61,195 @@ await function.InvokeAsync(new AIFunctionArguments client.LastRequest!.State.GetProperty("message").GetString()); } + [Fact] + public async Task DecisionDefinition_AsAIFunction_UsesOneTypeSyntaxAndReturnsOnlyTheBusinessResult() + { + SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); + AIFunction function = definition.AsAIFunction( + client, + new AIFunctionFactoryOptions + { + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + Assert.Equal("evaluate_decision", function.Name); + Assert.Equal("Evaluate the declared decision for one application state.", function.Description); + JsonElement properties = function.JsonSchema.GetProperty("properties"); + Assert.Equal(["state"], properties.EnumerateObject().Select(static property => property.Name)); + Assert.Equal( + "The application state for the declared decision.", + properties.GetProperty("state").GetProperty("description").GetString()); + Assert.Contains("state", function.JsonSchema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(CreateDecisionArguments())); + + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + Assert.DoesNotContain("provenance", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("usage", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("provider-evidence", output.GetRawText(), StringComparison.Ordinal); + Assert.Equal(1, client.CallCount); + Assert.Equal( + "Please review this account issue.", + client.LastRequest!.State.GetProperty("message").GetString()); + Assert.Equal(0, client.DisposeCount); + + client.Dispose(); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_PreservesFactoryOptionsAndClonesDecisionOptionsPerInvocation() + { + SupportTicketDecisionClient client = new() { MutateOptions = true }; + DecisionOptions decisionOptions = new() { ModelId = "configured-model" }; + AIFunction function = CreateDefinition().AsAIFunction( + client, + new AIFunctionFactoryOptions + { + Name = "custom_decision", + Description = "Custom decision description.", + ExcludeResultSchema = true, + MarshalResult = static (value, _, _) => + { + TicketAnalysis result = Assert.IsType(value); + Assert.Equal(TicketCategory.Technical, result.Category); + Assert.Equal(0.25, result.RefundRequestProbability); + return new ValueTask("custom-marshaled"); + }, + }, + decisionOptions); + decisionOptions.ModelId = "caller-mutated"; + + Assert.Equal("custom_decision", function.Name); + Assert.Equal("Custom decision description.", function.Description); + Assert.Null(function.ReturnJsonSchema); + + Assert.Equal("custom-marshaled", await function.InvokeAsync(CreateDecisionArguments())); + Assert.Equal("custom-marshaled", await function.InvokeAsync(CreateDecisionArguments("A second request."))); + + Assert.Equal(["configured-model", "configured-model"], client.SeenModelIds); + Assert.Equal(2, client.SeenOptions.Count); + Assert.NotSame(client.SeenOptions[0], client.SeenOptions[1]); + Assert.Equal("caller-mutated", decisionOptions.ModelId); + + client.Dispose(); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_InfersStateFromSourceGeneratedMetadata() + { + SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateResultOnlyDefinition(); + + AIFunction function = definition.AsAIFunction( + client, + functionOptions: new AIFunctionFactoryOptions + { + Name = "source_generated_decision", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }, + stateTypeInfo: DecisionTypedFunctionJsonContext.Default.SupportTicket); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(CreateRawDecisionArguments())); + + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + Assert.Equal(1, client.CallCount); + Assert.Equal("Source-generated state.", client.LastRequest!.State.GetProperty("message").GetString()); + + client.Dispose(); + } + + [Fact] + public void DecisionDefinition_AsAIFunction_RequiresStateMetadataWhenDefinitionOptionsDoNotContainIt() + { + using SupportTicketDecisionClient client = new(); + + Assert.Throws( + () => CreateResultOnlyDefinition().AsAIFunction(client)); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_PropagatesProviderAndCancellationFailures() + { + DecisionClientException providerFailure = new("provider failed", isTransient: true); + using SupportTicketDecisionClient failedClient = new() { Failure = providerFailure }; + AIFunction failedFunction = CreateDefinition().AsAIFunction(failedClient); + + DecisionClientException actualProviderFailure = await Assert.ThrowsAsync( + () => failedFunction.InvokeAsync(CreateDecisionArguments()).AsTask()); + Assert.Same(providerFailure, actualProviderFailure); + + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using SupportTicketDecisionClient cancelledClient = new(); + AIFunction cancelledFunction = CreateDefinition().AsAIFunction(cancelledClient); + + await Assert.ThrowsAnyAsync( + () => cancelledFunction.InvokeAsync(CreateDecisionArguments(), cancellation.Token).AsTask()); + Assert.Equal(1, cancelledClient.CallCount); + } + + [Theory] + [InlineData(ResponseMismatch.State)] + [InlineData(ResponseMismatch.Question)] + [InlineData(ResponseMismatch.Candidate)] + public async Task DecisionDefinition_AsAIFunction_RejectsMismatchedResponsesBeforeReturningAResult(ResponseMismatch mismatch) + { + using SupportTicketDecisionClient client = new() { Mismatch = mismatch }; + AIFunction function = CreateDefinition().AsAIFunction(client); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateDecisionArguments()).AsTask()); + + Assert.Equal(1, client.CallCount); + } + + [Fact] + public void DecisionDefinition_AsAIFunction_RejectsNullClient() + { + Assert.Throws( + "client", + () => CreateDefinition().AsAIFunction(null!)); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_CanBeRegisteredPerScopeWithOneClientResolution() + { + using SupportTicketDecisionClient client = new(); + ServiceCollection services = new(); + int clientFactoryCalls = 0; + services.AddScoped(_ => + { + clientFactoryCalls++; + return client; + }); + services.AddScoped(serviceProvider => + CreateDefinition().AsAIFunction( + serviceProvider.GetRequiredService(), + new AIFunctionFactoryOptions + { + Name = "scoped_decision", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + })); + + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + AIFunction first = scope.ServiceProvider.GetRequiredService(); + AIFunction second = scope.ServiceProvider.GetRequiredService(); + + Assert.Same(first, second); + Assert.Equal(1, clientFactoryCalls); + + await first.InvokeAsync(CreateDecisionArguments()); + + Assert.Equal(1, client.CallCount); + Assert.Equal(0, client.DisposeCount); + } + [Fact] public async Task AIFunctionFactory_CanReturnBusinessResultAndRecordEvidencePerInvocation() { @@ -197,6 +387,23 @@ private static AIFunctionArguments CreateArguments() => DecisionTypedFunctionJsonContext.Default.SupportTicket), }; + private static AIFunctionArguments CreateDecisionArguments(string message = "Please review this account issue.") => + new() + { + ["state"] = JsonSerializer.SerializeToElement( + new SupportTicket(message), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + }; + + private static AIFunctionArguments CreateRawDecisionArguments() + { + using JsonDocument document = JsonDocument.Parse("{\"message\":\"Source-generated state.\"}"); + return new() + { + ["state"] = document.RootElement.Clone(), + }; + } + private static DecisionDefinition CreateDefinition() => DecisionDefinition.Create( DecisionTypedFunctionJsonContext.Default.TicketAnalysis, @@ -206,6 +413,15 @@ private static DecisionDefinition CreateDefinition() => definition.BinaryProbability(result => result.RefundRequestProbability); }); + private static DecisionDefinition CreateResultOnlyDefinition() => + DecisionDefinition.Create( + DecisionResultOnlyJsonContext.Default.TicketAnalysis, + definition => + { + definition.Choice(result => result.Category); + definition.BinaryProbability(result => result.RefundRequestProbability); + }); + private sealed class SupportTicketFunction { private readonly IDecisionClient _client; @@ -268,6 +484,14 @@ private sealed class SupportTicketDecisionClient : IDecisionClient public DecisionRequest? LastRequest { get; private set; } + public bool MutateOptions { get; init; } + + public List SeenOptions { get; } = []; + + public List SeenModelIds { get; } = []; + + public int DisposeCount { get; private set; } + public Task GetResponseAsync( DecisionRequest request, DecisionOptions? options = null, @@ -275,6 +499,15 @@ public Task GetResponseAsync( { CallCount++; LastRequest = request; + if (options is not null) + { + SeenOptions.Add(options); + SeenModelIds.Add(options.ModelId); + if (MutateOptions) + { + options.ModelId = "provider-mutated"; + } + } if (Failure is not null) { @@ -348,6 +581,7 @@ private static double GetProbability(int candidateCount, int index) => public void Dispose() { + DisposeCount = 1; } } @@ -393,3 +627,10 @@ internal sealed record MetadataOnlyToolResult( [JsonSerializable(typeof(DecisionProvenance))] [JsonSerializable(typeof(UsageDetails))] internal sealed partial class DecisionTypedFunctionJsonContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketCategory))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketAnalysis))] +internal sealed partial class DecisionResultOnlyJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index f2b8c40018a..896c246d179 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -29,6 +29,7 @@ + From e87f1126cf22108fb27f6391c7826fb91c25138e Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Fri, 2 Oct 2026 14:36:34 -0400 Subject: [PATCH 12/12] Normalize Layer 2 commit trailers Keep the bounded helper revision unchanged while recording the required contiguous metadata trailers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083