Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
595 changes: 595 additions & 0 deletions docs/decisions/0042-decision-model-inference.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/specs/feature-usage-bit-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ only to approved first-party endpoints.
| 72 | `local_codeact` | Local Python CodeAct provider | `Microsoft.Agents.AI.LocalCodeAct.LocalCodeActProvider` |
| 73 | `hosting.a2a` | A2A hosting endpoints | `Microsoft.AspNetCore.Builder.A2AEndpointRouteBuilderExtensions.MapA2AJsonRpc` |
| 74 | `hosting.openai` | OpenAI-compatible hosting endpoints | `Microsoft.AspNetCore.Builder.MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses` |
| 75–127 | _reserved_ | future packages | — |
| 75 | `typesafe` | TypeSafe System One decision client | `Microsoft.Agents.AI.TypeSafe.TypeSafeDecisionClient` |
| 76–127 | _reserved_ | future packages | — |

## Opt-out

Expand Down
3 changes: 3 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step06_DecisionLoop/Harness_Step06_DecisionLoop.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
Expand Down Expand Up @@ -632,6 +633,7 @@
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Tools.Shell/Microsoft.Agents.AI.Tools.Shell.csproj" />
<Project Path="src/Microsoft.Agents.AI.TypeSafe/Microsoft.Agents.AI.TypeSafe.csproj" />
<Project Path="src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
Expand Down Expand Up @@ -664,6 +666,7 @@
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.TypeSafe.UnitTests/Microsoft.Agents.AI.TypeSafe.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AgentHooks.UnitTests/Microsoft.Agents.AI.AgentHooks.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.

#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.

using System.Text.Json;
using Microsoft.Agents.AI;

namespace Harness_Step06_DecisionLoop;

/// <summary>
/// A delegating <see cref="IDecisionClient"/> that prints every binary answer it sees, so the loop's decisions are
/// visible on the console. It is the decision-model counterpart of a logging chat client: the same shape works for
/// shadow-mode recording, calibration capture, or metrics.
/// </summary>
internal sealed class ConsoleObservingDecisionClient(IDecisionClient inner) : IDecisionClient
{
public async Task<DecisionResponse> GetResponseAsync(DecisionRequest request, DecisionOptions? options = null, CancellationToken cancellationToken = default)
{
DecisionResponse response = await inner.GetResponseAsync(request, options, cancellationToken);

int iteration = request.State.ValueKind == JsonValueKind.Object && request.State.TryGetProperty("iteration", out JsonElement it) ? it.GetInt32() : 0;
foreach (DecisionQuestion question in request.Questions)
{
if (response.Answers.TryGetValue(question.Id, out DecisionAnswer? answer) && answer is BinaryDecisionAnswer binary && iteration > 0)
{
Console.WriteLine($"\n [Jev] iteration {iteration}: P({question.Id}) = {binary.TrueProbability:F3} (model {response.ModelId}, {response.Usage?.InputTokenCount} input tokens)");
}
}

return response;
}

public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType.IsInstanceOfType(this) ? this : inner.GetService(serviceType, serviceKey);

public void Dispose() => inner.Dispose();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>

<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.TypeSafe\Microsoft.Agents.AI.TypeSafe.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Copyright (c) Microsoft. All rights reserved.

// This sample demonstrates the DecisionLoopEvaluator: a LoopEvaluator that asks a decision-oriented model, through the
// provider-neutral IDecisionClient, for the probability that the original request has been fully addressed, instead of
// asking a generative chat model for a verdict plus prose. TypeSafe's Jev (System One) is the decision model, reached
// through the Microsoft.Agents.AI.TypeSafe provider package; the core framework has no dependency on it.
//
// 1. Decision-only loop — Jev judges completion after every iteration; the loop stops once the model-reported
// probability reaches the completion threshold (DecisionLoopEvaluator). Because this agent answers one
// sub-question per turn, the demo supplies a StateFactory that sends Jev every response so far instead of the
// default latest-response-only projection.
// 2. Cheap-then-strong cascade — Jev runs first; while it says "incomplete" the expensive AI judge is skipped and
// the agent runs again; once Jev says "complete" the AI judge verifies (and can still send the loop back with
// a gap analysis). This uses existing LoopAgent ordering semantics: the first evaluator that asks to continue
// wins, and the loop stops only when every evaluator stops (DecisionLoopEvaluator + AIJudgeLoopEvaluator).
// 3. Direct decisions — one IDecisionClient call with a mixed batch of binary, choice, and score questions, to
// show the three primitives outside of a loop.
//
// The agent is deliberately instructed to answer only one sub-question per response so that the loop visibly needs
// several iterations to fully address a multi-part request.

#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.

using System.ClientModel;
using System.Text.Json;
using Harness_Step06_DecisionLoop;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.TypeSafe;
using Microsoft.Extensions.AI;
using OpenAI;

// Primary agent + generative judge: OpenAI, or any OpenAI-compatible chat-completions endpoint (for example Bitdeer
// with zai-org/GLM-5.3-Flash) when OPENAI_COMPATIBLE_ENDPOINT is set.
// The key is tied to the endpoint: a compatible endpoint uses only OPENAI_COMPATIBLE_API_KEY, and OpenAI uses only
// OPENAI_API_KEY, so a credential for one service is never sent to the other.
var chatEndpoint = Environment.GetEnvironmentVariable("OPENAI_COMPATIBLE_ENDPOINT");
var chatApiKey = chatEndpoint is null
? Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set (or set OPENAI_COMPATIBLE_ENDPOINT and OPENAI_COMPATIBLE_API_KEY).")
: Environment.GetEnvironmentVariable("OPENAI_COMPATIBLE_API_KEY") ?? throw new InvalidOperationException("OPENAI_COMPATIBLE_ENDPOINT is set but OPENAI_COMPATIBLE_API_KEY is not.");
var chatModel = Environment.GetEnvironmentVariable("OPENAI_COMPATIBLE_MODEL")
?? Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME")
?? "gpt-5.4-mini";

// Decision model: TypeSafe System One (Jev). TYPESAFE_API_KEY is required; TYPESAFE_MODEL optionally pins a version.
var typeSafeApiKey = Environment.GetEnvironmentVariable("TYPESAFE_API_KEY") ?? throw new InvalidOperationException("TYPESAFE_API_KEY is not set.");
var typeSafeModel = Environment.GetEnvironmentVariable("TYPESAFE_MODEL") ?? TypeSafeDecisionClientOptions.DefaultModelId;

// The HarnessAgent pre-configures function invocation, per-service-call chat history persistence, and
// context-window compaction. These bounds size the in-loop compaction window.
const int MaxContextWindowTokens = 400_000;
const int MaxOutputTokens = 16_000;

var openAIClient = chatEndpoint is null
? new OpenAIClient(chatApiKey)
: new OpenAIClient(new ApiKeyCredential(chatApiKey), new OpenAIClientOptions { Endpoint = new Uri(chatEndpoint) });
IChatClient CreateChatClient() => openAIClient.GetChatClient(chatModel).AsIChatClient();

// The provider client implements IDecisionClient. It is wrapped in a small delegating client (defined at the bottom)
// that prints every binary answer, so the loop's decisions are visible on the console.
using var jev = new ConsoleObservingDecisionClient(new TypeSafeDecisionClient(typeSafeApiKey, new() { ModelId = typeSafeModel }));
Console.WriteLine($"Primary model: {chatModel} ({chatEndpoint ?? "OpenAI"}) · Decision model: {typeSafeModel} (TypeSafe System One)");

const string Task = "Explain why the sky is blue, why sunsets are red, and why clouds are white.";

await DecisionOnlyLoopAsync();
await CascadeLoopAsync();
await DirectDecisionsAsync();

// Pattern 1: Jev alone decides whether the request has been fully addressed.
async Task DecisionOnlyLoopAsync()
{
Console.WriteLine("\n=== 1. Decision-only loop — Jev judges completion over all responses so far (threshold 0.90, max 5) ===");

AIAgent loopAgent = new LoopAgent(
CreateAnswerer(),
new DecisionLoopEvaluator(
jev,
new DecisionLoopEvaluatorOptions
{
// A false stop returns an incomplete answer while a false continue only costs one more iteration,
// so the threshold is deliberately high. It is application policy, not a property of the model.
CompletionThreshold = 0.90,

// The default projection judges only the latest response (like AIJudgeLoopEvaluator). This agent
// spreads its answer across turns, so judge the accumulated responses instead. The factory keeps its
// per-run list on the LoopContext, which is what keeps the evaluator itself stateless and shareable.
StateFactory = CreateCumulativeState,
}),
new LoopAgentOptions { MaxIterations = 5 });

AgentResponse response = await StreamLoopAsync(loopAgent, Task);
Console.WriteLine($"\nFinal response:\n{response.Text}");
}

// Pattern 2: Jev first, strong generative judge only when Jev believes the work is complete.
async Task CascadeLoopAsync()
{
Console.WriteLine("\n=== 2. Cheap-then-strong cascade — Jev, then AI judge verifies (max 6) ===");

int judgeCalls = 0;
var strongJudge = new AIJudgeLoopEvaluator(CreateChatClient());
var countingJudge = new DelegateLoopEvaluator(async (context, cancellationToken) =>
{
judgeCalls++;
Console.WriteLine(" [AI judge] Jev believes the work is complete; verifying with the generative judge...");
LoopEvaluation verdict = await strongJudge.EvaluateAsync(context, cancellationToken);
Console.WriteLine(verdict.ShouldReinvoke ? " [AI judge] Not complete — sending gap analysis back to the agent." : " [AI judge] Confirmed complete.");
return verdict;
});

AIAgent loopAgent = new LoopAgent(
CreateAnswerer(),
[
// Order matters: Jev goes first. When it says "incomplete" the loop continues immediately and the AI judge
// is never consulted. When it says "complete" it returns Stop, which only means "no continuation requested",
// so the AI judge runs next. A transient Jev failure (rate limit, overload, outage) defers to the AI judge;
// a permanent one (rejected key, invalid request) still surfaces, because hiding it would only cost money.
new DecisionLoopEvaluator(
jev,
new DecisionLoopEvaluatorOptions
{
CompletionThreshold = 0.90,
TransientFailureBehavior = DecisionLoopFailureBehavior.DeferToNextEvaluator,
}),
countingJudge,
],
new LoopAgentOptions { MaxIterations = 6 });

AgentResponse response = await StreamLoopAsync(loopAgent, Task);
Console.WriteLine($"\nFinal response:\n{response.Text}");
Console.WriteLine($"\nGenerative judge calls: {judgeCalls} (a plain AI-judge loop would have called it after every iteration).");
}

// Pattern 3: the three decision primitives in one batched call, outside of any loop.
async Task DirectDecisionsAsync()
{
Console.WriteLine("\n=== 3. Direct decisions — one call, three primitives ===");

JsonElement state = JsonSerializer.SerializeToElement(new
{
ticket = "My flight was cancelled and nobody told me. I'm really frustrated. Can I get a refund?",
policy = "Cancelled flights are eligible for a full refund.",
});

var request = new DecisionRequest(
state,
[
new BinaryDecisionQuestion("refund_requested", "Does the customer request a refund?"),
new ChoiceDecisionQuestion("request_type", "What is the customer's main request?",
[
new("refund", "The customer wants money returned."),
new("rebooking", "The customer wants another flight."),
new("information", "The customer only wants information."),
]),
new ScoreDecisionQuestion("frustration", "How frustrated does the customer appear?",
[
new("Calm and neutral."),
new("Concerned but civil."),
new("Very angry or using strong language."),
]),
]);

DecisionResponse response = await jev.GetResponseAsync(request);

var refund = (BinaryDecisionAnswer)response.Answers["refund_requested"];
var requestType = (ChoiceDecisionAnswer)response.Answers["request_type"];
var frustration = (ScoreDecisionAnswer)response.Answers["frustration"];

Console.WriteLine($" model: {response.ModelId}, input tokens: {response.Usage?.InputTokenCount}");
Console.WriteLine($" refund_requested: P(true) = {refund.TrueProbability:F3}");
Console.WriteLine($" request_type: {requestType.SelectedChoice} (confidence {requestType.Confidence:F2}) — " +
string.Join(", ", requestType.Probabilities.Select(p => $"{p.Key} {p.Value:F2}")));
Console.WriteLine($" frustration: score {frustration.Score:F2} on 0..2 (confidence {frustration.Confidence:F2}) — " +
string.Join(", ", frustration.Probabilities.OrderBy(p => p.Key).Select(p => $"level {p.Key} {p.Value:F2}")));
}

// Builds the state Jev judges in pattern 1: the original request plus every response produced so far in this run.
// Per-run state lives in LoopContext.AdditionalProperties (owned by the run), never on the evaluator. Whatever the
// factory returns is sent to the decision provider, so keep it to what the question needs.
static JsonElement CreateCumulativeState(LoopContext context)
{
const string ResponsesKey = "Harness_Step06_DecisionLoop.ResponsesSoFar";
if (context.AdditionalProperties.TryGetValue(ResponsesKey, out object? existing) && existing is List<string> responses)
{
responses.Add(context.LastResponse.Text);
}
else
{
responses = [context.LastResponse.Text];
context.AdditionalProperties[ResponsesKey] = responses;
}

return JsonSerializer.SerializeToElement(new
{
originalRequest = context.InitialMessages.Select(m => new { role = m.Role.Value, text = m.Text }),
responsesSoFar = responses,
iteration = context.Iteration,
});
}

// Creates a lean HarnessAgent that answers one sub-question per turn so the loop has visible work to do.
AIAgent CreateAnswerer() =>
CreateChatClient().AsHarnessAgent(new HarnessAgentOptions
{
Name = "answerer",
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
DisableAgentModeProvider = true,
DisableTodoProvider = true,
DisableFileMemory = true,
DisableWebSearch = true,
ChatOptions = new ChatOptions
{
Instructions =
"""
You are a helpful science explainer. The user may ask several questions at once. Answer exactly ONE
of the not-yet-answered questions per response, in two or three sentences, and then stop. When you
are asked to continue, answer the next unanswered question. Never answer more than one per response.
""",
MaxOutputTokens = MaxOutputTokens,
},
});

// Streams a loop run to the console, marking each new inner run with a "--- run N ---" header so you can see when
// the LoopAgent re-invokes the inner agent. Each message is prefixed with "User:" or "Agent:" based on its role, so
// the loop's on-behalf-of feedback (User) is visually distinct from the agent's responses (Agent).
static async Task<AgentResponse> StreamLoopAsync(AIAgent loopAgent, string input)
{
string? currentResponseId = null;
ChatRole? currentRole = null;
var runCount = 0;
var updates = new List<AgentResponseUpdate>();

await foreach (var update in loopAgent.RunStreamingAsync(input))
{
if (update.ResponseId is { } responseId && responseId != currentResponseId)
{
currentResponseId = responseId;
currentRole = null;
Console.WriteLine($"\n--- run {++runCount} ---");
}

if (update.Role is { } role && role != currentRole)
{
currentRole = role;
var prefix = role == ChatRole.User ? "User" : role == ChatRole.Assistant ? "Agent" : role.Value;
Console.Write($"\n{prefix}: ");
}

Console.Write(update.Text);
updates.Add(update);
}

Console.WriteLine();
return updates.ToAgentResponse();
}
Loading
Loading