Compare commits

..
Author SHA1 Message Date
Tao Chen 3fb7a03e05 Improve samples 2026-04-20 16:29:53 -07:00
Tao ChenandGitHub 0fcd71dbeb Python: Add special handling for workflows (#5298)
* Add special handling for workflows

* Address comments
2026-04-16 17:55:45 -07:00
Tao Chen 55e0705923 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-16 13:55:04 -07:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
ca580a8316 .NET: Add error checking to workflow samples (#5175)
* Initial plan

* Add WorkflowErrorEvent and ExecutorFailedEvent error checking to all workflow samples

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5d77400-d7ed-4fbe-9103-f5d74aabcf2b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix if/else if consistency for error event handlers per code review feedback

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5d77400-d7ed-4fbe-9103-f5d74aabcf2b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Address PR comments

* fixup: PR comments

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-04-16 20:03:16 +00:00
Jacob AlberandGitHub 101e07b061 .NET: Add Handoff sample (#5245)
* feat: Add Handoff sample

* docs: Add Handoff sample to readme
2026-04-16 20:02:31 +00:00
aee1acbf8b .NET: Foundry Evals integration for .NET (#4914)
* Foundry Evals integration for .NET

- Core evaluation framework: EvalItem, LocalEvaluator, FunctionEvaluator, EvalChecks
- IAgentEvaluator interface with MeaiEvaluatorAdapter bridge
- AgentEvaluationExtensions for agent.EvaluateAsync() overloads
- FoundryEvals wrapping MEAI quality/safety evaluators
- ConversationSplitters (LastTurn, Full) and IConversationSplitter
- EvalItem.PerTurnItems() for multi-turn decomposition
- HasImageContent for multimodal content detection
- WorkflowEvaluationExtensions for per-agent workflow evaluation
- 7 eval samples mirroring Python parity:
  02-agents/Evaluation: SimpleEval, ExpectedOutputs, Multimodal
  03-workflows/Evaluation: WorkflowEval
  05-end-to-end/Evaluation: FoundryQuality, MixedProviders, ConversationSplits
- Comprehensive unit tests (1958 passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Rewrite FoundryEvals to use real Foundry Evals API

Replace MEAI evaluator shim with actual OpenAI EvaluationClient protocol
methods. FoundryEvals now creates eval definitions, submits runs, polls
for completion, and fetches per-item results server-side.

- New constructor: FoundryEvals(AIProjectClient, model, evaluators)
- Add FoundryEvalConverter for MEAI ChatMessage -> Foundry JSON format
- Add EvalId, RunId, ReportUrl to AgentEvaluationResults
- All 20 built-in evaluator constants now work (agent, tool, quality, safety)
- Remove Microsoft.Extensions.AI.Evaluation.Quality/Safety dependencies
- Update all samples for new constructor (no more ChatConfiguration)
- Replace BuildEvaluators tests with ResolveEvaluator tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add response output to CustomEvals and ExpectedOutputs samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: pagination, validation, error handling, tests

FoundryEvals fixes:
- Add pagination for output items (has_more/after cursor)
- Add guard clauses for pollIntervalSeconds/timeoutSeconds <= 0
- Fix double TryGetProperty for passed field parsing
- Throw on all-tool-evaluators with no tool definitions
- Fix XML doc (default 300s, not 180s)

New tests (30 added, 1989 total):
- EvalChecks: NonEmpty, ContainsExpected (pass/fail/skip/case),
  HasImageContent, ToolCallsPresent
- FoundryEvalConverter: ConvertMessage (text, image, function call,
  function results fan-out, empty fallback, mixed content),
  ConvertEvalItem, BuildTestingCriteria (quality/agent/tool/groundedness
  data mappings), BuildItemSchema

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix review: null-refs, Data.ToString() bug, ContainsExpected, add tests

- Fix NullReferenceException in sample Response display (pattern matching)
- Fix WorkflowEvaluationExtensions Data?.ToString() producing type names
  instead of message text (pattern-match ChatMessage/AgentResponse/list)
- Change EvalChecks.ContainsExpected to return Passed=false when no
  ExpectedOutput (was silently passing, masking misconfiguration)
- Add EvalItem constructor tests with LastTurn/Full/null splitters
- Add FoundryEvalConverter.ConvertMessage DataContent (base64 image) test
- Add ExtractAgentData tests with ChatMessage, list, and AgentResponse data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix review: conversation fidelity, eval caching, fallback tests

- WorkflowEvaluationExtensions: preserve full response messages (tool calls,
  intermediate) instead of synthetic 2-message conversation. Cast completed
  Data to AgentResponse and use Messages when available, fallback to text.
- FoundryEvals: cache evalId per schema shape (hasContext, hasTools) so
  subsequent EvaluateAsync calls create runs under the same eval definition.
- MeaiEvaluatorAdapter: code already correctly passes queryMessages (not full
  conversation) to IEvaluator — no change needed, verified by inspection.
- Add tests: AgentResponse full messages preservation, unknown object
  ToString() fallback for ExtractAgentData.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Rename AzureAI→Foundry: move eval files, update references

- Move FoundryEvals.cs and FoundryEvalConverter.cs from
  Microsoft.Agents.AI.AzureAI to Microsoft.Agents.AI.Foundry
- Update namespace from AzureAI to Foundry in both files
- Add explicit usings required by Foundry project (no implicit usings)
- Move FoundryEvalConverter tests to Foundry.UnitTests project
  (avoids ReplacingRedactor type conflict from dual project refs)
- Update all sample csproj references and using statements
- Remove Foundry project reference from AI UnitTests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR review round 4: wire up tool extraction, remove eval cache, fix null safety

- BuildEvalItem: extract tools from agent via GetService<ChatOptions>() into EvalItem.Tools (Python parity)
- FoundryEvals: remove eval ID cache - each call creates fresh definition (matches Python behavior)
- FoundryEvals: replace null-forgiving operators with descriptive InvalidOperationException
- MixedProviders sample: remove unnecessary explicit PackageReferences (transitively provided)
- FoundryEvalConverter: document that tool results take precedence over text content
- Add LocalEvaluator zero-checks test documenting 0 metrics = failed behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python-dotnet parity: 9 feature gaps filled

New checks:
- ToolCallArgsMatch() — verify tool call names + argument subset match
- ToolCalledCheck(ToolCalledMode.Any, ...) — match any of the specified tools
- ToolCalledMode enum (All/Any)

FoundryEvals enhancements:
- Default evaluators now [Relevance, Coherence, TaskAdherence] (was Relevance, Coherence)
- Auto-add ToolCallAccuracy when items have tool definitions
- EvaluateTracesAsync — evaluate by response_ids, trace_ids, or agent_id
- EvaluateFoundryTargetAsync — evaluate deployed Foundry targets

Result type enrichment:
- AgentEvaluationResults: added Status, Error, PerEvaluator, DetailedItems
- New EvalItemResult/EvalScoreResult/PerEvaluatorResult types
- FoundryEvals populates all new fields from API responses

Workflow fix:
- Skip internal executors (_*, input-conversation, end-conversation, end)

Tests: 8 new tests covering ToolCallArgsMatch, ToolCalledMode.Any, internal executor filtering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add MeaiEvaluatorAdapter and PerTurnItems edge case tests

- 3 tests for MeaiEvaluatorAdapter: query message forwarding, synthetic
  response fallback, multiple items aggregation
- 3 tests for EvalItem.PerTurnItems: empty conversation, no user messages,
  system+assistant only
- StubEvaluator and StubChatClient test helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Blocking link check for outdated package in DevUI.

* Replace Dictionary<string, object> payloads with typed wire models

Introduce internal FoundryEvalWireModels.cs with compile-time-safe types
for the OpenAI Evals API wire format. The OpenAI .NET SDK (2.9.1) only
provides protocol-level methods with BinaryContent/ClientResult — no
typed request models. These internal models replace scattered dictionary
literals with [JsonPropertyName]-annotated classes, giving:

- Compile-time safety (typos become build errors)
- Single point of change when the API evolves
- IntelliSense discoverability
- Cleaner serialization via JsonPolymorphic for content items

Models: WireContentItem hierarchy (text, image, tool_call, tool_result),
WireMessage, WireEvalItemPayload, WireTestingCriterion, WireItemSchema,
WireCreateEvalRequest, WireCreateRunRequest, and data source variants.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Skip metric when Foundry returns neither score nor passed

When an evaluator returns no score and no passed value, the previous
code created BooleanMetric(name, false), which falsely failed items
via ItemPassed. Now we skip the MEAI metric entirely for indeterminate
results — the raw data remains available in DetailedItems for diagnostics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #4914 review comments: fix tool evaluator bug and add tests

- Fix duplicate ToolCallAccuracy: resolve evaluator names before checking
  against ToolEvaluators set (Comment 2)
- Make FilterToolEvaluators internal for testability; add tests for the
  ArgumentException edge case when all evaluators are tool-type (Comment 3)
- Add CancellationToken test for LocalEvaluator (Comment 4)
- Add EvaluateAsync integration test on Run with sequential workflow and
  per-agent SubResults verification (Comment 5)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address Peter's review comments on PR #4914

- Add trailing newline to Evaluation_FoundryQuality.csproj (Comment 6)
- Make evaluator name lookups case-insensitive: switch BuiltinEvaluators,
  ToolEvaluators, AgentEvaluators, and ResolveEvaluator's StartsWith check
  from Ordinal to OrdinalIgnoreCase (Comment 7)
- Add Trace.TraceWarning when Foundry returns fewer results than submitted
  items, indicating expected vs actual count before padding (Comment 8)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Microsoft.Extensions.AI.Evaluation packages to Directory.Packages.props

These were removed in #5269 as unused, but are needed by the Foundry
and core evaluation integration added in this PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 19:40:07 +00:00
L. Elaine DazzioandGitHub 91e34358eb Python: Feat: Add finish_reason support to AgentResponse and AgentResponseUpdate (#5211)
* feat: add finish_reason support to AgentResponse and AgentResponseUpdate

Add finish_reason field to AgentResponse and AgentResponseUpdate classes,
propagate it through _process_update() and map_chat_to_agent_update(),
and add comprehensive unit tests.

Fixes #4622

* feat: add finish_reason to AgentResponse and AgentResponseUpdate

* style: add copyright header to test_finish_reason.py

* docs: add finish_reason to AgentResponse and AgentResponseUpdate docstrings

* refactor: move finish_reason tests into test_types.py per review feedback

Move all finish_reason test cases from the separate test_finish_reason.py
file into test_types.py as requested by eavanvalkenburg. Tests are placed
in a new '# region finish_reason' section at the end of the file.

* fix: use model instead of model_id in _process_update

Address PR review feedback from @eavanvalkenburg — ChatResponse and
ChatResponseUpdate both use 'model', not 'model_id'.

* fix: resolve SIM102 lint error in _process_update

Combine nested if statements for AgentResponse finish_reason check
to satisfy ruff SIM102 rule, with line wrapping to stay under 120 chars.

* fix: resolve pyright reportArgumentType in map_chat_to_agent_update

Add type: ignore[arg-type] for FinishReason NewType widening when
passing ChatResponseUpdate.finish_reason to AgentResponseUpdate.
Matches existing patterns in the codebase (40+ similar ignores).
2026-04-16 19:39:09 +00:00
90a633967c Python: Fix Gemini client support for Gemini API and Vertex AI (#5258)
* Add Gemini and Vertex AI client support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address Gemini PR review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* removed sample run readme part

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-04-16 19:38:50 +00:00
Tao Chen 892d88df28 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-15 20:59:51 -07:00
Tao ChenandGitHub 3225a59fd3 Python: Upgrade agentserver packages (#5284)
* Upgrade agentserver packages

* Fix new types
2026-04-15 14:16:37 -07:00
Tao ChenandGitHub 9e3983e547 Move samples (#5281) 2026-04-15 11:33:15 -07:00
Tao ChenandGitHub 383a2afca2 Python: Refine samples and upgrade packages (#5261)
* Refine samples and upgrade pacakges

* Upgrade to a new package that fixes a bug

* Update model env var
2026-04-15 10:46:19 -07:00
Tao Chen 0402b1aac4 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-14 10:32:14 -07:00
Tao Chen 448f46aff2 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-13 16:47:46 -07:00
Tao ChenandGitHub 9ce2aafff7 Add tests and more content types (#5235)
* Add tests

* fix tests and sample

* Fix formatting

* Remove function approval contents
2026-04-13 16:12:02 -07:00
Tao ChenandGitHub a98a585afb Update dependency (#5215) 2026-04-10 16:10:35 -07:00
Tao ChenandGitHub 615ef9049f Python: Wrapper + Samples 1st (#5177)
* Experiment

* Update dependency and add non streaming

* Add more samples

* Rename samples

* Add invocations

* Comments 1

* Comments 2

* Comments 3

* Improve README

* Add local shell sample

* WIP: Add eval and memory samples

* Update user agent prefix

* Update user agent prefix doc
2026-04-10 10:18:32 -07:00
159 changed files with 10001 additions and 1245 deletions
+1
View File
@@ -21,6 +21,7 @@ ignorePatterns:
- pattern: "http://host.docker.internal"
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
- pattern: "https:\/\/dotnet.microsoft.com\/download"
- pattern: "https://github.com/Rel1cx/eslint-react"
# excludedDirs:
# Folders which include links to localhost, since it's not ignored with regular expressions
baseUrl: https://github.com/microsoft/agent-framework/
+3
View File
@@ -65,6 +65,9 @@
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
+18 -1
View File
@@ -153,6 +153,12 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithMemory/">
<File Path="samples/02-agents/AgentWithMemory/README.md" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
@@ -243,6 +249,9 @@
<Folder Name="/Samples/03-workflows/HumanInTheLoop/">
<Project Path="samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Orchestration/">
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Observability/">
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
<Project Path="samples/03-workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
@@ -260,6 +269,9 @@
<Project Path="samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/06_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/03-workflows/_StartHere/07_WriterCriticWorkflow/07_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Evaluation/">
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/" />
<Folder Name="/Samples/04-hosting/DurableAgents/" />
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
@@ -288,11 +300,16 @@
<File Path="samples/04-hosting/A2A/README.md" />
<Project Path="samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
</Folder>
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/Evaluation/">
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
<Project Path="samples/05-end-to-end/A2AClientServer/A2AClient/A2AClient.csproj" />
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates writing custom evaluation functions for domain-specific
// checks. Custom evaluators run locally — no cloud evaluator service needed.
// For LLM-based quality scoring (relevance, coherence), see Evaluation_SimpleEval.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent agent = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a customer support agent. Help users resolve their issues "
+ "politely and provide clear, actionable steps.",
name: "SupportAgent");
// Custom check: the agent should not refuse to help.
EvalCheck noRefusal = FunctionEvaluator.Create("no_refusal", (string response) =>
!response.Contains("I can't help", StringComparison.OrdinalIgnoreCase)
&& !response.Contains("I'm unable to", StringComparison.OrdinalIgnoreCase)
&& !response.Contains("outside my scope", StringComparison.OrdinalIgnoreCase));
// Custom check: response should include actionable guidance (numbered steps or bullet points).
EvalCheck hasActionableSteps = FunctionEvaluator.Create("has_actionable_steps", (string response) =>
response.Contains("1.", StringComparison.Ordinal)
|| response.Contains("- ", StringComparison.Ordinal)
|| response.Contains("• ", StringComparison.Ordinal));
// Custom check: response should be substantial but not excessively long.
EvalCheck reasonableLength = FunctionEvaluator.Create("reasonable_length", (string response) =>
response.Length >= 50 && response.Length <= 2000);
// Combine all custom checks into a local evaluator.
LocalEvaluator evaluator = new(noRefusal, hasActionableSteps, reasonableLength);
string[] queries =
[
"My order hasn't arrived after two weeks. What should I do?",
"I was charged twice for the same item. Can you help?",
"How do I return a damaged product?",
];
AgentEvaluationResults results = await agent.EvaluateAsync(queries, evaluator);
Console.WriteLine($"Passed: {results.Passed}/{results.Total}");
Console.WriteLine();
for (int i = 0; i < results.Items.Count; i++)
{
Console.WriteLine($"Query: {queries[i]}");
Console.WriteLine($"Response: {(results.InputItems?[i].Response is { } resp ? resp.Substring(0, Math.Min(50, resp.Length)) : "N/A")}...");
foreach (var metric in results.Items[i].Metrics)
{
string status = metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
Console.WriteLine($" [{status}] {metric.Key}");
}
Console.WriteLine();
}
@@ -0,0 +1,36 @@
# Evaluation - Custom Evals
This sample demonstrates writing custom domain-specific evaluation functions using `FunctionEvaluator.Create`. Custom evaluators run locally with no cloud evaluator service needed — useful for enforcing business rules, format requirements, or safety guardrails.
## What this sample demonstrates
- Writing custom checks with `FunctionEvaluator.Create` for domain-specific logic
- Checking that a customer support agent doesn't refuse to help
- Verifying responses contain actionable steps (numbered lists or bullet points)
- Enforcing response length constraints
- Combining multiple custom checks into a `LocalEvaluator`
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/02-agents/Evaluation
dotnet run --project .\Evaluation_CustomEvals
```
## See also
- [Evaluation_SimpleEval](../Evaluation_SimpleEval/) — Simplest evaluation using Foundry quality evaluators (Relevance, Coherence)
- [Evaluation_ExpectedOutputs](../Evaluation_ExpectedOutputs/) — Evaluating against ground-truth expected outputs
- [Evaluation_MixedProviders](../../../05-end-to-end/Evaluation/Evaluation_MixedProviders/) — Combining custom + Foundry evaluators in one call
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates evaluating agent responses against expected outputs.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Create a math tutor agent.
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(
model: deploymentName,
instructions: "You are a math tutor. Answer concisely with the numeric result.",
name: "MathTutor");
// Combine built-in checks.
LocalEvaluator localEvaluator = new(
EvalChecks.ContainsExpected(), // response must contain the expected answer
EvalChecks.NonEmpty()); // response must not be empty
// Queries and expected outputs.
string[] queries = ["What is 2 + 2?", "What is the square root of 144?"];
string[] expectedOutputs = ["4", "12"];
// Run the agent and evaluate with expected outputs.
AgentEvaluationResults results = await agent.EvaluateAsync(
queries,
localEvaluator,
expectedOutput: expectedOutputs);
// Print results.
Console.WriteLine($"Evaluation: {results.ProviderName}");
Console.WriteLine($" Passed: {results.Passed}/{results.Total}");
Console.WriteLine($" All passed: {results.AllPassed}");
Console.WriteLine();
for (int i = 0; i < results.Items.Count; i++)
{
Console.WriteLine($"Query: {queries[i]} | Expected: {expectedOutputs[i]}");
Console.WriteLine($"Response: {(results.InputItems?[i].Response is { } resp ? resp.Substring(0, Math.Min(50, resp.Length)) : "N/A")}");
foreach (var metric in results.Items[i].Metrics)
{
string status = metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
Console.WriteLine($" [{status}] {metric.Key}: {metric.Value.Interpretation?.Reason}");
}
Console.WriteLine();
}
@@ -0,0 +1,33 @@
# Evaluation - Expected Outputs
This sample demonstrates evaluating agent responses against expected outputs using built-in checks.
## What this sample demonstrates
- Using `EvalChecks.ContainsExpected` for ground-truth comparison
- Using `EvalChecks.NonEmpty` for basic response validation
- Passing `expectedOutput` to `agent.EvaluateAsync()` so checks can access ground truth
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/02-agents/Evaluation
dotnet run --project .\Evaluation_ExpectedOutputs
```
## See also
- [Evaluation_SimpleEval](../Evaluation_SimpleEval/) — Simplest evaluation with built-in and custom checks
- [Evaluation_FoundryQuality](../../../05-end-to-end/Evaluation/Evaluation_FoundryQuality/) — Cloud-based quality evaluation with Foundry evaluators
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates that the evaluation pipeline preserves multimodal content.
// When an agent conversation includes images, EvalChecks.HasImageContent() can verify
// they survived into the EvalItem — useful for testing vision-capable agents.
//
// No Azure credentials needed: this sample builds EvalItems locally to show the pattern.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// Simulate a vision agent conversation where the user sends an image.
// Just pass the conversation — query/response are derived automatically.
// For cloud-based quality evaluation of multimodal conversations, see the
// 05-end-to-end/Evaluation samples (FoundryQuality, ConversationSplits).
EvalItem imageItem = new(
conversation:
[
new(ChatRole.User,
[
new TextContent("What do you see in this image?"),
new UriContent(new Uri("https://example.com/mountain.png"), "image/png"),
]),
new(ChatRole.Assistant, "The image shows a mountain landscape with snow-capped peaks."),
]);
// Simulate a text-only conversation (no image).
EvalItem textItem = new(
query: "Tell me about mountains.",
response: "Mountains are large landforms that rise above the surrounding terrain.");
// HasImageContent() passes when the conversation contains an image, fails otherwise.
// This lets you verify that your vision agent actually received the image.
LocalEvaluator evaluator = new(
EvalChecks.HasImageContent(),
EvalChecks.NonEmpty());
AgentEvaluationResults results = await evaluator.EvaluateAsync([imageItem, textItem]);
Console.WriteLine($"Evaluation: {results.Passed}/{results.Total} passed");
Console.WriteLine();
Console.WriteLine($"Image conversation: has_image_content = {imageItem.HasImageContent}"); // true
Console.WriteLine($"Text conversation: has_image_content = {textItem.HasImageContent}"); // false
Console.WriteLine();
for (int i = 0; i < results.Items.Count; i++)
{
Console.WriteLine($"Item {i + 1}: {results.InputItems![i].Query}");
foreach (var metric in results.Items[i].Metrics)
{
string status = metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
Console.WriteLine($" [{status}] {metric.Key}: {metric.Value.Interpretation?.Reason}");
}
Console.WriteLine();
}
@@ -0,0 +1,29 @@
# Evaluation - Multimodal
This sample demonstrates that the evaluation pipeline preserves multimodal content. When conversations include images, `EvalChecks.HasImageContent` can verify they survived into the `EvalItem`.
## What this sample demonstrates
- Building `EvalItem` objects with `UriContent` image content
- Using built-in `EvalChecks.HasImageContent` to detect images in conversations
- Comparing image vs. text-only conversations to show when the check passes/fails
- Evaluating directly with `LocalEvaluator.EvaluateAsync()` (no agent needed)
## Prerequisites
- .NET 10 SDK or later
No Azure credentials or environment variables are required for this sample since it evaluates locally without calling an agent.
## Run the sample
```powershell
cd dotnet/samples/02-agents/Evaluation
dotnet run --project .\Evaluation_Multimodal
```
## See also
- [Evaluation_SimpleEval](../Evaluation_SimpleEval/) — Simplest evaluation with built-in checks and `agent.EvaluateAsync()`
- [Evaluation_FoundryQuality](../../../05-end-to-end/Evaluation/Evaluation_FoundryQuality/) — Cloud-based quality evaluation with Foundry evaluators
- [Evaluation_ConversationSplits](../../../05-end-to-end/Evaluation/Evaluation_ConversationSplits/) — Multi-turn conversation split strategies
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
// Simplest possible agent evaluation: create a Foundry agent, run it against
// test questions, and use Foundry quality evaluators to score the responses.
// For custom domain-specific checks, see the Evaluation_CustomEvals sample.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI.Evaluation;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent agent = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a helpful assistant. Provide clear, accurate answers.",
name: "SimpleAgent");
// Configure Foundry quality evaluators — runs evaluations server-side via the Foundry Evals API.
FoundryEvals evaluator = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence);
// Run the agent against test queries and evaluate in one call.
string[] queries = ["What is photosynthesis?", "How do vaccines work?"];
AgentEvaluationResults results = await agent.EvaluateAsync(queries, evaluator);
// Print results.
Console.WriteLine($"Passed: {results.Passed}/{results.Total}");
if (results.ReportUrl is not null)
{
Console.WriteLine($"Report: {results.ReportUrl}");
}
Console.WriteLine();
for (int i = 0; i < results.Items.Count; i++)
{
Console.WriteLine($"Query: {queries[i]}");
Console.WriteLine($"Response: {(results.InputItems?[i].Response is { } resp ? resp.Substring(0, Math.Min(50, resp.Length)) : "N/A")}...");
foreach (var metric in results.Items[i].Metrics)
{
string score = metric.Value is NumericMetric nm && nm.Value.HasValue
? nm.Value.Value.ToString("F1")
: "N/A";
Console.WriteLine($" {metric.Key}: {score}");
}
Console.WriteLine();
}
@@ -0,0 +1,35 @@
# Evaluation - Simple Eval
The simplest agent evaluation: create a Foundry agent, run it against test questions, and use Foundry quality evaluators (Relevance, Coherence) to score the responses.
## What this sample demonstrates
- Creating an agent with `AIProjectClient.AsAIAgent()`
- Using `FoundryEvals` with Relevance and Coherence quality evaluators
- Running evaluation with `agent.EvaluateAsync()` — runs the agent and evaluates in one call
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
- A deployed model in your Azure AI Foundry project
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/02-agents/Evaluation
dotnet run --project .\Evaluation_SimpleEval
```
## See also
- [Evaluation_CustomEvals](../Evaluation_CustomEvals/) — Writing custom domain-specific evaluation checks
- [Evaluation_ExpectedOutputs](../Evaluation_ExpectedOutputs/) — Evaluating against ground-truth expected outputs
- [Evaluation_MixedProviders](../../../05-end-to-end/Evaluation/Evaluation_MixedProviders/) — Combining local + Foundry evaluators in one call
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates evaluating a multi-agent workflow with per-agent breakdown.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create two agents: a planner and an executor.
AIAgent planner = aiProjectClient.AsAIAgent(
model: deploymentName,
instructions: "You plan trips. Output a concise bullet-point plan.",
name: "planner");
AIAgent executor = aiProjectClient.AsAIAgent(
model: deploymentName,
instructions: "You execute travel plans. Confirm the bookings listed in the plan.",
name: "executor");
// Build a simple planner -> executor workflow.
Workflow workflow = new WorkflowBuilder(planner)
.AddEdge(planner, executor)
.Build();
// Run the workflow to completion (RunAsync returns Run which supports EvaluateAsync).
await using Run run = await InProcessExecution.RunAsync(
workflow,
new ChatMessage(ChatRole.User, "Plan a weekend trip to Paris"));
// Print the events from the run.
foreach (WorkflowEvent evt in run.OutgoingEvents)
{
if (evt is AgentResponseEvent response)
{
Console.WriteLine($" {response.ExecutorId}: {response.Response.Text[..Math.Min(80, response.Response.Text.Length)]}...");
}
}
// Evaluate with per-agent breakdown.
EvalCheck isNonempty = FunctionEvaluator.Create("is_nonempty", (string response) => response.Trim().Length > 5);
EvalCheck hasKeywords = EvalChecks.KeywordCheck("plan", "trip");
LocalEvaluator local = new(isNonempty, hasKeywords);
AgentEvaluationResults results = await run.EvaluateAsync(local);
Console.WriteLine();
Console.WriteLine($"Overall: {results.Passed}/{results.Total} passed");
if (results.SubResults is not null)
{
foreach (var (agentName, sub) in results.SubResults)
{
Console.WriteLine($" {agentName}: {sub.Passed}/{sub.Total} passed");
for (int i = 0; i < sub.Items.Count; i++)
{
foreach (var metric in sub.Items[i].Metrics)
{
string status = metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
Console.WriteLine($" [{status}] {metric.Key}");
}
}
}
}
@@ -0,0 +1,30 @@
# Evaluation - Workflow Eval
This sample demonstrates evaluating a multi-agent workflow with per-agent breakdown.
## What this sample demonstrates
- Building a two-agent workflow (planner → executor)
- Running the workflow and collecting events
- Using `run.EvaluateAsync()` to evaluate the completed run
- Per-agent sub-results via `results.SubResults`
- Combining `FunctionEvaluator.Create` with `EvalChecks.KeywordCheck`
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/03-workflows/Evaluation
dotnet run --project .\Evaluation_WorkflowEval
```
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
/// <summary>
/// The registry of agents used in the workflow.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use as the agent backend.</param>
internal sealed class AgentRegistry(IChatClient chatClient)
{
internal const string IntakeAgentName = "Assistant";
public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You receive a user request and are responsible for routing to the correct initial expert agent.
""",
IntakeAgentName
);
internal const string LiquidityAnalysisAgentName = "Liquidity Analysis";
public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Liquidity Analysis.
""",
LiquidityAnalysisAgentName
);
internal const string TaxAnalysisAgentName = "Tax Analysis";
public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Tax Analysis.
""",
TaxAnalysisAgentName
);
internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis";
public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Foreign Exchange Analysis.
""",
ForeignExchangeAgentName
);
internal const string EquityAgentName = "Equity Analysis";
public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(
instructions:
"""
You are responsible for Equity Analysis.
""",
EquityAgentName
);
public IEnumerable<AIAgent> Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent];
public HashSet<AIAgent> All
{
get
{
if (field == null)
{
field = [this.IntakeAgent, .. this.Experts];
}
return field;
}
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>MAAIW001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<!-- Include Workflows source generator when using [MessageHandler] attribute -->
<ProjectReference Include="$(RepoRoot)/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
GlobalPropertiesToRemove="TargetFramework" />
</ItemGroup>
</Project>
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
IChatClient chatClient = projectClient.ProjectOpenAIClient
.GetChatClient(deploymentName)
.AsIChatClient();
Workflow workflow = CreateWorkflow(chatClient);
await RunWorkflowAsync(workflow).ConfigureAwait(false);
static Workflow CreateWorkflow(IChatClient chatClient)
{
AgentRegistry agents = new(chatClient);
HandoffWorkflowBuilder handoffBuilder = AgentWorkflowBuilder.CreateHandoffBuilderWith(agents.IntakeAgent);
// Add a handoff to each of the experts from every agent in the registry (experts + Intake)
foreach (AIAgent expert in agents.Experts)
{
handoffBuilder.WithHandoffs(agents.All.Except([expert]), expert);
}
// Let agents request more user information and return to the asking agent (rather than going back to the intake agent)
handoffBuilder.EnableReturnToPrevious();
return handoffBuilder.Build();
}
static async Task RunWorkflowAsync(Workflow workflow)
{
using CancellationTokenSource cts = CreateConsoleCancelKeySource();
await using StreamingRun run = await InProcessExecution.OpenStreamingAsync(workflow, cancellationToken: cts.Token)
.ConfigureAwait(false);
bool hadError = false;
do
{
Console.Write("> ");
string userInput = Console.ReadLine() ?? string.Empty;
if (userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
await run.TrySendMessageAsync(userInput);
string? speakingAgent = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token))
{
switch (evt)
{
case AgentResponseUpdateEvent update:
{
if (speakingAgent == null || speakingAgent != update.Update.AuthorName)
{
speakingAgent = update.Update.AuthorName;
Console.Write($"\n{speakingAgent}: ");
}
Console.Write(update.Update.Text);
break;
}
case WorkflowErrorEvent workflowError:
{
Console.ForegroundColor = ConsoleColor.Red;
if (workflowError.Exception != null)
{
Console.WriteLine($"\nWorkflow error: {workflowError.Exception}");
}
else
{
Console.WriteLine("\nUnknown workflow error occurred.");
}
Console.ResetColor();
hadError = true;
break;
}
case WorkflowWarningEvent workflowWarning when workflowWarning.Data is string message:
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(message);
Console.ResetColor();
break;
}
}
}
} while (!hadError);
}
static CancellationTokenSource CreateConsoleCancelKeySource()
{
CancellationTokenSource cts = new();
// Normally, support a way to detach events, but in this case this is a termination signal, so cleanup will happen
// as part of application shutdown.
Console.CancelKeyPress += (s, args) =>
{
cts.Cancel();
// We handle cleanup + termination ourselves
args.Cancel = true;
};
return cts;
}
+6
View File
@@ -56,3 +56,9 @@ Once completed, please proceed to the other samples listed below.
| [Edge Conditions](./ConditionalEdges/01_EdgeCondition) | Introduces conditional edges for dynamic routing based on executor outputs |
| [Switch-Case Routing](./ConditionalEdges/02_SwitchCase) | Extends conditional edges with switch-case routing for multiple paths |
| [Multi-Selection Routing](./ConditionalEdges/03_MultiSelection) | Demonstrates multi-selection routing where one executor can trigger multiple downstream executors |
### Orchestration Patterns
| Sample | Concepts |
|--------|----------|
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates multi-turn conversation evaluation with different split strategies.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// A multi-turn conversation with tool calls to evaluate three ways.
List<ChatMessage> conversation =
[
// Turn 1: user asks about weather -> agent calls tool -> responds
new(ChatRole.User, "What's the weather in Seattle?"),
new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather", new Dictionary<string, object?> { ["location"] = "seattle" }),
]),
new(ChatRole.Tool,
[
new FunctionResultContent("c1", "62\u00b0F, cloudy with a chance of rain"),
]),
new(ChatRole.Assistant, "Seattle is 62\u00b0F, cloudy with a chance of rain."),
// Turn 2: user asks about Paris -> agent calls tool -> responds
new(ChatRole.User, "And Paris?"),
new(ChatRole.Assistant,
[
new FunctionCallContent("c2", "get_weather", new Dictionary<string, object?> { ["location"] = "paris" }),
]),
new(ChatRole.Tool,
[
new FunctionResultContent("c2", "Paris is 68\u00b0F, partly sunny"),
]),
new(ChatRole.Assistant, "Paris is 68\u00b0F, partly sunny."),
// Turn 3: user asks for comparison -> agent synthesizes without tool
new(ChatRole.User, "Can you compare them?"),
new(ChatRole.Assistant,
"Seattle is cooler at 62\u00b0F with rain likely, while Paris is warmer " +
"at 68\u00b0F and partly sunny. Paris is the better choice for outdoor activities."),
];
// =========================================================================
// Strategy 1: LastTurn (default)
// "Given all context, was the last response good?"
// =========================================================================
Console.WriteLine(new string('=', 70));
Console.WriteLine("Strategy 1: LastTurn \u2014 evaluate the final response");
Console.WriteLine(new string('=', 70));
EvalItem lastTurnItem = new(
query: "Can you compare them?",
response: "Seattle is cooler at 62\u00b0F with rain likely, while Paris is warmer at 68\u00b0F and partly sunny.",
conversation: conversation);
FoundryEvals lastTurnEvals = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence);
AgentEvaluationResults lastTurnResults = await lastTurnEvals.EvaluateAsync(
[lastTurnItem],
"Split Strategy: LastTurn");
PrintResults("LastTurn", lastTurnResults);
// =========================================================================
// Strategy 2: Full
// "Given the original request, did the whole conversation serve the user?"
// =========================================================================
Console.WriteLine(new string('=', 70));
Console.WriteLine("Strategy 2: Full \u2014 evaluate the entire conversation trajectory");
Console.WriteLine(new string('=', 70));
EvalItem fullItem = new(
query: "What's the weather in Seattle?",
response: "Seattle is cooler at 62\u00b0F with rain likely, while Paris is warmer at 68\u00b0F and partly sunny.",
conversation: conversation)
{
Splitter = ConversationSplitters.Full,
};
FoundryEvals fullEvals = new(projectClient, deploymentName, ConversationSplitters.Full, FoundryEvals.Relevance, FoundryEvals.Coherence);
AgentEvaluationResults fullResults = await fullEvals.EvaluateAsync(
[fullItem],
"Split Strategy: Full");
PrintResults("Full", fullResults);
// =========================================================================
// Strategy 3: PerTurnItems
// "Was each individual response appropriate at that point?"
// =========================================================================
Console.WriteLine(new string('=', 70));
Console.WriteLine("Strategy 3: PerTurnItems \u2014 evaluate each turn independently");
Console.WriteLine(new string('=', 70));
IReadOnlyList<EvalItem> perTurnItems = EvalItem.PerTurnItems(conversation);
Console.WriteLine($"Split into {perTurnItems.Count} items from {conversation.Count} messages:");
for (int i = 0; i < perTurnItems.Count; i++)
{
string response = perTurnItems[i].Response;
string truncated = response.Length > 60 ? response[..60] + "..." : response;
Console.WriteLine($" Turn {i + 1}: query=\"{perTurnItems[i].Query}\", response=\"{truncated}\"");
}
Console.WriteLine();
FoundryEvals perTurnEvals = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence);
AgentEvaluationResults perTurnResults = await perTurnEvals.EvaluateAsync(
perTurnItems,
"Split Strategy: Per-Turn");
PrintResults("Per-Turn", perTurnResults);
Console.WriteLine(new string('=', 70));
Console.WriteLine("All strategies complete. Compare results above.");
Console.WriteLine(new string('=', 70));
static void PrintResults(string strategy, AgentEvaluationResults results)
{
Console.WriteLine($"\n Result: {results.Passed}/{results.Total} passed");
if (results.ReportUrl is not null)
{
Console.WriteLine($" Report: {results.ReportUrl}");
}
for (int i = 0; i < results.Items.Count; i++)
{
foreach (var metric in results.Items[i].Metrics)
{
string status = metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS";
string score = metric.Value is NumericMetric nm && nm.Value.HasValue
? nm.Value.Value.ToString("F1")
: "N/A";
Console.WriteLine($" [{status}] {metric.Key}: {score}");
}
}
Console.WriteLine();
}
@@ -0,0 +1,31 @@
# Evaluation - Conversation Splits
This sample demonstrates multi-turn conversation evaluation with different split strategies.
## What this sample demonstrates
- **LastTurn** (default): Evaluates whether the last response was good given all prior context
- **Full**: Evaluates whether the entire conversation trajectory served the original request
- **PerTurnItems**: Splits a conversation into one `EvalItem` per user turn for independent evaluation
- Building multi-turn conversations with `FunctionCallContent` and `FunctionResultContent`
- Using `ConversationSplitters.LastTurn` and `ConversationSplitters.Full`
- Using `EvalItem.PerTurnItems()` to decompose a conversation
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/05-end-to-end/Evaluation
dotnet run --project .\Evaluation_ConversationSplits
```
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates agent evaluation using Foundry quality evaluators
// (Relevance, Coherence) via the Foundry Evals API.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI.Evaluation;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent agent = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a helpful assistant that provides clear, accurate answers.",
name: "QualityTestAgent");
// Configure Foundry evaluators.
FoundryEvals foundryEvals = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence);
// --- Pattern 1: Run agent, then evaluate pre-existing responses ---
string[] queries = ["What is photosynthesis?", "Explain gravity in simple terms."];
AgentResponse[] responses = new AgentResponse[queries.Length];
for (int i = 0; i < queries.Length; i++)
{
responses[i] = await agent.RunAsync(queries[i]);
}
AgentEvaluationResults results1 = await agent.EvaluateAsync(responses, queries, foundryEvals);
Console.WriteLine("=== Pattern 1: Evaluate pre-existing responses ===");
PrintResults(results1, queries);
// --- Pattern 2: Run + evaluate in one call ---
string[] queries2 = ["What causes rain?", "Why is the sky blue?"];
AgentEvaluationResults results2 = await agent.EvaluateAsync(queries2, foundryEvals);
Console.WriteLine("=== Pattern 2: Run + evaluate in one call ===");
PrintResults(results2, queries2);
static void PrintResults(AgentEvaluationResults results, string[] queries)
{
Console.WriteLine($"Provider: {results.ProviderName}");
Console.WriteLine($"Passed: {results.Passed}/{results.Total}");
if (results.ReportUrl is not null)
{
Console.WriteLine($"Report: {results.ReportUrl}");
}
Console.WriteLine();
for (int i = 0; i < results.Items.Count; i++)
{
Console.WriteLine($" Query {i + 1}: {(i < queries.Length ? queries[i] : "N/A")}");
foreach (var metric in results.Items[i].Metrics)
{
string score = metric.Value is NumericMetric nm && nm.Value.HasValue
? nm.Value.Value.ToString("F1")
: "N/A";
Console.WriteLine($" {metric.Key}: {score}");
}
Console.WriteLine();
}
}
@@ -0,0 +1,30 @@
# Evaluation - Foundry Quality
This sample demonstrates agent evaluation using MEAI quality evaluators (Relevance, Coherence) via `FoundryEvals`.
## What this sample demonstrates
- Setting up `ChatConfiguration` for MEAI quality evaluators
- Using `FoundryEvals` with `Relevance` and `Coherence` evaluators
- Pattern 1: Running the agent first, then evaluating pre-existing responses
- Pattern 2: Running and evaluating in a single `agent.EvaluateAsync()` call
- Reading numeric quality scores from evaluation results
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/05-end-to-end/Evaluation
dotnet run --project .\Evaluation_FoundryQuality
```
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates combining local evaluators and Foundry evaluators.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI.Evaluation;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent agent = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a travel advisor. Provide helpful travel recommendations.",
name: "TravelAdvisor");
string[] queries = ["What are the best places to visit in Japan?", "Suggest a 3-day itinerary for Paris."];
// --- Pattern 1: Local-only evaluation ---
EvalCheck isHelpful = FunctionEvaluator.Create("is_helpful", (string response) => response.Length > 20);
EvalCheck keywordCheck = EvalChecks.KeywordCheck("visit");
LocalEvaluator localEvaluator = new(isHelpful, keywordCheck);
AgentEvaluationResults localResults = await agent.EvaluateAsync(queries, localEvaluator);
Console.WriteLine("=== Pattern 1: Local-only ===");
Console.WriteLine($" {localResults.ProviderName}: {localResults.Passed}/{localResults.Total} passed");
Console.WriteLine();
// --- Pattern 2: Foundry-only ---
FoundryEvals foundryEvaluator = new(projectClient, deploymentName, FoundryEvals.Relevance);
AgentEvaluationResults foundryResults = await agent.EvaluateAsync(queries, foundryEvaluator);
Console.WriteLine("=== Pattern 2: Foundry-only ===");
Console.WriteLine($" {foundryResults.ProviderName}: {foundryResults.Passed}/{foundryResults.Total} passed");
Console.WriteLine();
// --- Pattern 3: Mixed -- combine local + foundry in one call ---
IReadOnlyList<AgentEvaluationResults> mixedResults = await agent.EvaluateAsync(
queries,
new IAgentEvaluator[] { localEvaluator, foundryEvaluator });
Console.WriteLine("=== Pattern 3: Mixed (local + Foundry) ===");
foreach (AgentEvaluationResults result in mixedResults)
{
Console.WriteLine($" {result.ProviderName}: {result.Passed}/{result.Total} passed");
for (int i = 0; i < result.Items.Count; i++)
{
Console.WriteLine($" Query {i + 1}: {queries[i]}");
foreach (var metric in result.Items[i].Metrics)
{
string detail = metric.Value is NumericMetric nm && nm.Value.HasValue
? $"score={nm.Value.Value:F1}"
: $"passed={metric.Value.Interpretation?.Failed != true}";
Console.WriteLine($" {metric.Key}: {detail}");
}
}
Console.WriteLine();
}
@@ -0,0 +1,31 @@
# Evaluation - Mixed Providers
This sample demonstrates mixing local and cloud evaluators in a single evaluation run.
## What this sample demonstrates
- **Local-only evaluation**: Fast, API-free checks for inner-loop development
- **Cloud-only evaluation**: Full Foundry evaluators for comprehensive quality assessment
- **Mixed evaluation**: Local + Foundry evaluators in a single `EvaluateAsync()` call
- Using `EvalChecks.KeywordCheck` and `EvalChecks.ToolCalledCheck` for local checks
- Using `FoundryEvals` for cloud-based relevance and coherence evaluation
- Combining both in one call returns one `AgentEvaluationResults` per provider
## Prerequisites
- .NET 10 SDK or later
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/05-end-to-end/Evaluation
dotnet run --project .\Evaluation_MixedProviders
```
@@ -0,0 +1,307 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Converts MEAI <see cref="ChatMessage"/> objects to the Foundry evaluator JSON format.
/// </summary>
/// <remarks>
/// Handles the type gap between MEAI's <see cref="ChatMessage"/> / <see cref="AIContent"/> types
/// and the OpenAI-style agent message schema used by Foundry evaluation providers.
/// </remarks>
internal static class FoundryEvalConverter
{
/// <summary>
/// Converts a single <see cref="ChatMessage"/> to one or more Foundry evaluator wire messages.
/// </summary>
/// <remarks>
/// A single message with multiple <see cref="FunctionResultContent"/> entries produces
/// multiple output messages (one per tool result), matching the Foundry evaluator schema.
/// </remarks>
internal static List<WireMessage> ConvertMessage(ChatMessage message)
{
var role = message.Role.Value;
var contentItems = new List<WireContentItem>();
var toolResults = new List<(string CallId, object Result)>();
foreach (var content in message.Contents)
{
switch (content)
{
case TextContent tc when !string.IsNullOrEmpty(tc.Text):
contentItems.Add(new WireTextContent { Text = tc.Text });
break;
case UriContent uc when uc.HasTopLevelMediaType("image"):
contentItems.Add(new WireImageContent { ImageUrl = uc.Uri.ToString() });
break;
case DataContent dc when dc.HasTopLevelMediaType("image"):
contentItems.Add(new WireImageContent { ImageUrl = dc.Uri });
break;
case FunctionCallContent fc:
contentItems.Add(new WireToolCallContent
{
ToolCallId = fc.CallId ?? string.Empty,
Name = fc.Name ?? string.Empty,
Arguments = fc.Arguments is { Count: > 0 } ? fc.Arguments : null,
});
break;
case FunctionResultContent fr:
toolResults.Add((fr.CallId ?? string.Empty, fr.Result ?? string.Empty));
break;
}
}
var output = new List<WireMessage>();
if (toolResults.Count > 0)
{
// Tool results take precedence — the Foundry Evals API expects tool messages
// to have role=tool with a single tool_result content. Any text content in the
// same message is omitted since the API format doesn't support mixed content.
foreach (var (callId, result) in toolResults)
{
output.Add(new WireMessage
{
Role = "tool",
ToolCallId = callId,
Content = [new WireToolResultContent { ToolResult = result }],
});
}
}
else if (contentItems.Count > 0)
{
output.Add(new WireMessage
{
Role = role,
Content = contentItems,
});
}
else
{
output.Add(new WireMessage
{
Role = role,
Content = [new WireTextContent { Text = string.Empty }],
});
}
return output;
}
/// <summary>
/// Converts a sequence of <see cref="ChatMessage"/> objects to Foundry evaluator format.
/// </summary>
internal static List<WireMessage> ConvertMessages(IEnumerable<ChatMessage> messages)
{
var result = new List<WireMessage>();
foreach (var msg in messages)
{
result.AddRange(ConvertMessage(msg));
}
return result;
}
/// <summary>
/// Converts an <see cref="EvalItem"/> to a wire-format payload for the Foundry Evals API.
/// </summary>
/// <remarks>
/// Produces both string fields (query, response) for quality evaluators and
/// conversation arrays (query_messages, response_messages) for agent evaluators.
/// </remarks>
internal static WireEvalItemPayload ConvertEvalItem(EvalItem item, IConversationSplitter? defaultSplitter = null)
{
var splitter = item.Splitter ?? defaultSplitter ?? ConversationSplitters.LastTurn;
var (queryMessages, responseMessages) = splitter.Split(item.Conversation);
return new WireEvalItemPayload
{
Query = item.Query,
Response = item.Response,
QueryMessages = ConvertMessages(queryMessages),
ResponseMessages = ConvertMessages(responseMessages),
Context = item.Context,
ToolDefinitions = item.Tools is { Count: > 0 }
? item.Tools
.OfType<AIFunction>()
.Select(t => new WireToolDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.JsonSchema,
})
.ToList()
: null,
};
}
/// <summary>
/// Builds the <c>testing_criteria</c> array for <c>evals.create()</c>.
/// </summary>
/// <param name="evaluators">Evaluator names (short or fully-qualified).</param>
/// <param name="model">Model deployment name for the LLM judge.</param>
/// <param name="includeDataMapping">
/// Whether to include field-level data mapping (required for JSONL data source).
/// </param>
internal static List<WireTestingCriterion> BuildTestingCriteria(
IEnumerable<string> evaluators,
string model,
bool includeDataMapping = false)
{
var criteria = new List<WireTestingCriterion>();
foreach (var name in evaluators)
{
var qualified = ResolveEvaluator(name);
var shortName = name.StartsWith("builtin.", StringComparison.Ordinal)
? name.Substring("builtin.".Length)
: name;
Dictionary<string, string>? dataMapping = null;
if (includeDataMapping)
{
dataMapping = new Dictionary<string, string>();
if (AgentEvaluators.Contains(qualified))
{
dataMapping["query"] = "{{item.query_messages}}";
dataMapping["response"] = "{{item.response_messages}}";
}
else
{
dataMapping["query"] = "{{item.query}}";
dataMapping["response"] = "{{item.response}}";
}
if (qualified == "builtin.groundedness")
{
dataMapping["context"] = "{{item.context}}";
}
if (ToolEvaluators.Contains(qualified))
{
dataMapping["tool_definitions"] = "{{item.tool_definitions}}";
}
}
criteria.Add(new WireTestingCriterion
{
Name = shortName,
EvaluatorName = qualified,
InitializationParameters = new WireInitParams { DeploymentName = model },
DataMapping = dataMapping,
});
}
return criteria;
}
/// <summary>
/// Builds the <c>item_schema</c> for custom JSONL eval definitions.
/// </summary>
internal static WireItemSchema BuildItemSchema(bool hasContext = false, bool hasTools = false)
{
var properties = new Dictionary<string, WireSchemaProperty>
{
["query"] = new() { Type = "string" },
["response"] = new() { Type = "string" },
["query_messages"] = new() { Type = "array" },
["response_messages"] = new() { Type = "array" },
};
if (hasContext)
{
properties["context"] = new WireSchemaProperty { Type = "string" };
}
if (hasTools)
{
properties["tool_definitions"] = new WireSchemaProperty { Type = "array" };
}
return new WireItemSchema
{
Properties = properties,
Required = ["query", "response"],
};
}
/// <summary>
/// Resolves a short evaluator name to its fully-qualified <c>builtin.*</c> form.
/// </summary>
internal static string ResolveEvaluator(string name)
{
if (name.StartsWith("builtin.", StringComparison.OrdinalIgnoreCase))
{
return name;
}
if (BuiltinEvaluators.TryGetValue(name, out var qualified))
{
return qualified;
}
throw new ArgumentException(
$"Unknown evaluator '{name}'. Available: {string.Join(", ", BuiltinEvaluators.Keys.Order())}",
nameof(name));
}
// Agent evaluators that accept query/response as conversation arrays.
internal static readonly HashSet<string> AgentEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
"builtin.intent_resolution",
"builtin.task_adherence",
"builtin.task_completion",
"builtin.task_navigation_efficiency",
"builtin.tool_call_accuracy",
"builtin.tool_selection",
"builtin.tool_input_accuracy",
"builtin.tool_output_utilization",
"builtin.tool_call_success",
};
// Evaluators that additionally require tool_definitions.
internal static readonly HashSet<string> ToolEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
"builtin.tool_call_accuracy",
"builtin.tool_selection",
"builtin.tool_input_accuracy",
"builtin.tool_output_utilization",
"builtin.tool_call_success",
};
// Short name → fully-qualified name mapping.
internal static readonly Dictionary<string, string> BuiltinEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
// Agent behavior
["intent_resolution"] = "builtin.intent_resolution",
["task_adherence"] = "builtin.task_adherence",
["task_completion"] = "builtin.task_completion",
["task_navigation_efficiency"] = "builtin.task_navigation_efficiency",
// Tool usage
["tool_call_accuracy"] = "builtin.tool_call_accuracy",
["tool_selection"] = "builtin.tool_selection",
["tool_input_accuracy"] = "builtin.tool_input_accuracy",
["tool_output_utilization"] = "builtin.tool_output_utilization",
["tool_call_success"] = "builtin.tool_call_success",
// Quality
["coherence"] = "builtin.coherence",
["fluency"] = "builtin.fluency",
["relevance"] = "builtin.relevance",
["groundedness"] = "builtin.groundedness",
["response_completeness"] = "builtin.response_completeness",
["similarity"] = "builtin.similarity",
// Safety
["violence"] = "builtin.violence",
["sexual"] = "builtin.sexual",
["self_harm"] = "builtin.self_harm",
["hate_unfairness"] = "builtin.hate_unfairness",
};
}
@@ -0,0 +1,314 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Internal wire-format models for the OpenAI Evals API.
/// </summary>
/// <remarks>
/// <para>
/// The OpenAI .NET SDK (as of 2.9.1) marks its <c>EvaluationClient</c> as experimental
/// and exposes only protocol-level methods that accept <c>BinaryContent</c> and return
/// <c>ClientResult</c> — no strongly typed request or response models are provided.
/// </para>
/// <para>
/// These internal models replace hand-built <c>Dictionary&lt;string, object&gt;</c> payloads
/// with compile-time–safe types that are serialized via <see cref="System.Text.Json"/>.
/// When the SDK ships typed models, these should be replaced.
/// </para>
/// </remarks>
// -----------------------------------------------------------------------
// Message content items (polymorphic by "type" discriminator)
// -----------------------------------------------------------------------
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(WireTextContent), "text")]
[JsonDerivedType(typeof(WireImageContent), "input_image")]
[JsonDerivedType(typeof(WireToolCallContent), "tool_call")]
[JsonDerivedType(typeof(WireToolResultContent), "tool_result")]
internal abstract class WireContentItem
{
}
internal sealed class WireTextContent : WireContentItem
{
[JsonPropertyName("text")]
public required string Text { get; init; }
}
internal sealed class WireImageContent : WireContentItem
{
[JsonPropertyName("image_url")]
public required string ImageUrl { get; init; }
[JsonPropertyName("detail")]
public string Detail { get; init; } = "auto";
}
internal sealed class WireToolCallContent : WireContentItem
{
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("arguments")]
public IDictionary<string, object?>? Arguments { get; init; }
}
internal sealed class WireToolResultContent : WireContentItem
{
[JsonPropertyName("tool_result")]
public required object ToolResult { get; init; }
}
// -----------------------------------------------------------------------
// Message
// -----------------------------------------------------------------------
internal sealed class WireMessage
{
[JsonPropertyName("role")]
public required string Role { get; init; }
[JsonPropertyName("content")]
public required List<WireContentItem> Content { get; init; }
[JsonPropertyName("tool_call_id")]
public string? ToolCallId { get; init; }
}
// -----------------------------------------------------------------------
// Eval item payload (a single JSONL row sent to the Evals API)
// -----------------------------------------------------------------------
internal sealed class WireEvalItemPayload
{
[JsonPropertyName("query")]
public required string Query { get; init; }
[JsonPropertyName("response")]
public required string Response { get; init; }
[JsonPropertyName("query_messages")]
public required List<WireMessage> QueryMessages { get; init; }
[JsonPropertyName("response_messages")]
public required List<WireMessage> ResponseMessages { get; init; }
[JsonPropertyName("context")]
public string? Context { get; init; }
[JsonPropertyName("tool_definitions")]
public List<WireToolDefinition>? ToolDefinitions { get; init; }
}
internal sealed class WireToolDefinition
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("parameters")]
public object? Parameters { get; init; }
}
// -----------------------------------------------------------------------
// Testing criteria (evaluator definitions within an eval)
// -----------------------------------------------------------------------
internal sealed class WireTestingCriterion
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_evaluator";
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("evaluator_name")]
public required string EvaluatorName { get; init; }
[JsonPropertyName("initialization_parameters")]
public required WireInitParams InitializationParameters { get; init; }
[JsonPropertyName("data_mapping")]
public Dictionary<string, string>? DataMapping { get; init; }
}
internal sealed class WireInitParams
{
[JsonPropertyName("deployment_name")]
public required string DeploymentName { get; init; }
}
// -----------------------------------------------------------------------
// Item schema (for custom JSONL data source definitions)
// -----------------------------------------------------------------------
internal sealed class WireItemSchema
{
[JsonPropertyName("type")]
public string Type { get; init; } = "object";
[JsonPropertyName("properties")]
public required Dictionary<string, WireSchemaProperty> Properties { get; init; }
[JsonPropertyName("required")]
public required List<string> Required { get; init; }
}
internal sealed class WireSchemaProperty
{
[JsonPropertyName("type")]
public required string Type { get; init; }
}
// -----------------------------------------------------------------------
// Create evaluation request
// -----------------------------------------------------------------------
internal sealed class WireCreateEvalRequest
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("data_source_config")]
public required object DataSourceConfig { get; init; }
[JsonPropertyName("testing_criteria")]
public required List<WireTestingCriterion> TestingCriteria { get; init; }
}
// Data source configuration variants
internal sealed class WireCustomDataSourceConfig
{
[JsonPropertyName("type")]
public string Type { get; init; } = "custom";
[JsonPropertyName("item_schema")]
public required WireItemSchema ItemSchema { get; init; }
[JsonPropertyName("include_sample_schema")]
public bool IncludeSampleSchema { get; init; } = true;
}
internal sealed class WireAzureAiDataSourceConfig
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_source";
[JsonPropertyName("scenario")]
public required string Scenario { get; init; }
}
// -----------------------------------------------------------------------
// Create evaluation run request
// -----------------------------------------------------------------------
internal sealed class WireCreateRunRequest
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("data_source")]
public required object DataSource { get; init; }
}
// -----------------------------------------------------------------------
// Data source variants (used in run requests)
// -----------------------------------------------------------------------
internal sealed class WireJsonlDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "jsonl";
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
internal sealed class WireFileContentSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "file_content";
[JsonPropertyName("content")]
public required List<WireItemWrapper> Content { get; init; }
}
internal sealed class WireItemWrapper
{
[JsonPropertyName("item")]
public required object Item { get; init; }
}
internal sealed class WireResponsesDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_responses";
[JsonPropertyName("item_generation_params")]
public required WireResponseRetrievalParams ItemGenerationParams { get; init; }
}
internal sealed class WireResponseRetrievalParams
{
[JsonPropertyName("type")]
public string Type { get; init; } = "response_retrieval";
[JsonPropertyName("data_mapping")]
public required Dictionary<string, string> DataMapping { get; init; }
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
internal sealed class WireTracesDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_traces";
[JsonPropertyName("lookback_hours")]
public int LookbackHours { get; init; }
[JsonPropertyName("trace_ids")]
public List<string>? TraceIds { get; init; }
[JsonPropertyName("agent_id")]
public string? AgentId { get; init; }
}
internal sealed class WireTargetCompletionsDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_target_completions";
[JsonPropertyName("target")]
public required IDictionary<string, object> Target { get; init; }
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
// -----------------------------------------------------------------------
// Small item payloads used inside WireItemWrapper
// -----------------------------------------------------------------------
internal sealed class WireResponseIdItem
{
[JsonPropertyName("resp_id")]
public required string RespId { get; init; }
}
internal sealed class WireQueryItem
{
[JsonPropertyName("query")]
public required string Query { get; init; }
}
@@ -0,0 +1,920 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI.Evaluation;
using OpenAI.Evals;
#pragma warning disable OPENAI001 // EvaluationClient is experimental
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Azure AI Foundry evaluator provider that calls the Foundry Evals API.
/// </summary>
/// <remarks>
/// <para>
/// Uses the OpenAI Evals API (<c>evals.create</c> / <c>evals.runs.create</c>) via the
/// project endpoint to run evaluations server-side. All built-in Foundry evaluators
/// (quality, safety, agent behavior, tool usage) are supported.
/// </para>
/// <para>
/// Results appear in the Azure AI Foundry portal with a report URL for detailed analysis.
/// </para>
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing Dictionary<string, object> for eval API payloads.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing Dictionary<string, object> for eval API payloads.")]
public sealed class FoundryEvals : IAgentEvaluator
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
private readonly EvaluationClient _evaluationClient;
private readonly string _model;
private readonly string[] _evaluatorNames;
private readonly IConversationSplitter? _splitter;
private readonly double _pollIntervalSeconds = 5.0;
private readonly double _timeoutSeconds = 300.0;
// -----------------------------------------------------------------------
// Constructors
// -----------------------------------------------------------------------
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="evaluators">
/// Names of evaluators to use (e.g., <see cref="Relevance"/>, <see cref="Coherence"/>).
/// When empty, defaults to relevance and coherence.
/// </param>
public FoundryEvals(AIProjectClient projectClient, string model, params string[] evaluators)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
this._evaluationClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
this._model = model;
this._evaluatorNames = evaluators.Length > 0
? evaluators
: [Relevance, Coherence, TaskAdherence];
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class with a conversation splitter.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="splitter">
/// Default conversation splitter for multi-turn conversations.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="evaluators">
/// Names of evaluators to use (e.g., <see cref="Relevance"/>, <see cref="Coherence"/>).
/// When empty, defaults to relevance and coherence.
/// </param>
public FoundryEvals(
AIProjectClient projectClient,
string model,
IConversationSplitter? splitter,
params string[] evaluators)
: this(projectClient, model, evaluators)
{
this._splitter = splitter;
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class with full configuration.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="splitter">
/// Default conversation splitter for multi-turn conversations.
/// </param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="evaluators">Evaluator names to use.</param>
public FoundryEvals(
AIProjectClient projectClient,
string model,
IConversationSplitter? splitter,
double pollIntervalSeconds,
double timeoutSeconds,
params string[] evaluators)
: this(projectClient, model, splitter, evaluators)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(pollIntervalSeconds, 0);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeoutSeconds, 0);
this._pollIntervalSeconds = pollIntervalSeconds;
this._timeoutSeconds = timeoutSeconds;
}
// -----------------------------------------------------------------------
// IAgentEvaluator
// -----------------------------------------------------------------------
/// <inheritdoc />
public string Name => "FoundryEvals";
/// <inheritdoc />
public async Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Agent Framework Eval",
CancellationToken cancellationToken = default)
{
// 1. Convert EvalItems to typed payloads
var payloads = new List<WireEvalItemPayload>(items.Count);
foreach (var item in items)
{
payloads.Add(FoundryEvalConverter.ConvertEvalItem(item, this._splitter));
}
bool hasContext = payloads.Any(p => p.Context is not null);
bool hasTools = payloads.Any(p => p.ToolDefinitions is { Count: > 0 });
// Filter out tool evaluators if no items have tools; auto-add ToolCallAccuracy if tools present
var evaluators = FilterToolEvaluators(this._evaluatorNames, hasTools);
if (hasTools && !evaluators.Any(e => FoundryEvalConverter.ToolEvaluators.Contains(FoundryEvalConverter.ResolveEvaluator(e))))
{
evaluators = [.. evaluators, ToolCallAccuracy];
}
// 2. Create the evaluation definition
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = new WireCustomDataSourceConfig
{
ItemSchema = FoundryEvalConverter.BuildItemSchema(hasContext, hasTools),
},
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(
evaluators, this._model, includeDataMapping: true),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await this._evaluationClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
// 3. Create the evaluation run with inline JSONL data
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = new WireJsonlDataSource
{
Source = new WireFileContentSource
{
Content = payloads.ConvertAll(p => new WireItemWrapper { Item = p }),
},
},
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await this._evaluationClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
// 4. Poll until complete
var pollResult = await this.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry evaluation run {runId} did not complete within {this._timeoutSeconds}s. " +
"Increase timeoutSeconds or check the run status in the Foundry portal.");
}
// 5. Fetch output items and build results
var fetchResult = await this.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
// Pad MEAI results if we got fewer than items (e.g. partial output)
if (fetchResult.MeaiResults.Count < items.Count)
{
Trace.TraceWarning(
"Foundry returned {0} result(s) but {1} item(s) were submitted. " +
"Padding {2} missing item(s) with empty results — these items will count as failed.",
fetchResult.MeaiResults.Count,
items.Count,
items.Count - fetchResult.MeaiResults.Count);
}
while (fetchResult.MeaiResults.Count < items.Count)
{
fetchResult.MeaiResults.Add(new EvaluationResult());
}
return new AgentEvaluationResults(this.Name, fetchResult.MeaiResults, inputItems: items)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
// -----------------------------------------------------------------------
// Static evaluation methods (traces and targets)
// -----------------------------------------------------------------------
/// <summary>
/// Evaluates agent behavior from Responses API response IDs, OTel traces, or agent activity.
/// </summary>
/// <remarks>
/// <para>
/// Foundry-specific method that works with any agent emitting OTel traces to App Insights.
/// Provide <paramref name="responseIds"/> for specific Responses API responses,
/// <paramref name="traceIds"/> for specific traces, or <paramref name="agentId"/> with
/// <paramref name="lookbackHours"/> to evaluate recent activity.
/// </para>
/// </remarks>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="responseIds">Evaluate specific Responses API response IDs.</param>
/// <param name="traceIds">Evaluate specific OTel trace IDs from App Insights.</param>
/// <param name="agentId">Filter traces by agent ID (used with <paramref name="lookbackHours"/>).</param>
/// <param name="lookbackHours">Hours of trace history to evaluate (default 24).</param>
/// <param name="evaluators">Evaluator names. Defaults to relevance, coherence, and task adherence.</param>
/// <param name="evalName">Display name for the evaluation.</param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with status, report URL, and per-item details.</returns>
public static async Task<AgentEvaluationResults> EvaluateTracesAsync(
AIProjectClient projectClient,
string model,
IEnumerable<string>? responseIds = null,
IEnumerable<string>? traceIds = null,
string? agentId = null,
int lookbackHours = 24,
string[]? evaluators = null,
string evalName = "Agent Framework Trace Eval",
double pollIntervalSeconds = 5.0,
double timeoutSeconds = 300.0,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
var responseIdList = responseIds?.ToList();
var traceIdList = traceIds?.ToList();
if ((responseIdList is null || responseIdList.Count == 0)
&& (traceIdList is null || traceIdList.Count == 0)
&& string.IsNullOrEmpty(agentId))
{
throw new ArgumentException("Provide at least one of: responseIds, traceIds, or agentId.");
}
var evalClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
var resolvedEvaluators = evaluators is { Length: > 0 }
? evaluators
: [Relevance, Coherence, TaskAdherence];
// Create the evaluation definition with the appropriate data source scenario
object dataSourceConfig;
object runDataSource;
if (responseIdList is { Count: > 0 })
{
// Responses API path
dataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "responses" };
runDataSource = new WireResponsesDataSource
{
ItemGenerationParams = new WireResponseRetrievalParams
{
DataMapping = new Dictionary<string, string> { ["response_id"] = "{{item.resp_id}}" },
Source = new WireFileContentSource
{
Content = responseIdList.ConvertAll(id => new WireItemWrapper
{
Item = new WireResponseIdItem { RespId = id },
}),
},
},
};
}
else
{
// Traces path
dataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "traces" };
runDataSource = new WireTracesDataSource
{
LookbackHours = lookbackHours,
TraceIds = traceIdList is { Count: > 0 } ? traceIdList : null,
AgentId = !string.IsNullOrEmpty(agentId) ? agentId : null,
};
}
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = dataSourceConfig,
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(resolvedEvaluators, model),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await evalClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = runDataSource,
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await evalClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
// Poll and fetch
var instance = new FoundryEvals(projectClient, model, null, pollIntervalSeconds, timeoutSeconds, resolvedEvaluators);
var pollResult = await instance.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry trace evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry trace evaluation run {runId} did not complete within {timeoutSeconds}s.");
}
var fetchResult = await instance.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
return new AgentEvaluationResults("FoundryEvals", fetchResult.MeaiResults)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
/// <summary>
/// Evaluates a Foundry-registered agent or model deployment.
/// </summary>
/// <remarks>
/// Foundry invokes the target, captures the output, and evaluates it.
/// Use this for scheduled evaluations, red teaming, and CI/CD quality gates.
/// </remarks>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="target">Target configuration (must include a "type" key, e.g. "azure_ai_agent").</param>
/// <param name="testQueries">Queries for Foundry to send to the target.</param>
/// <param name="evaluators">Evaluator names. Defaults to relevance, coherence, and task adherence.</param>
/// <param name="evalName">Display name for the evaluation.</param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with status, report URL, and per-item details.</returns>
public static async Task<AgentEvaluationResults> EvaluateFoundryTargetAsync(
AIProjectClient projectClient,
string model,
IDictionary<string, object> target,
IEnumerable<string> testQueries,
string[]? evaluators = null,
string evalName = "Agent Framework Target Eval",
double pollIntervalSeconds = 5.0,
double timeoutSeconds = 300.0,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
ArgumentNullException.ThrowIfNull(target);
if (!target.ContainsKey("type"))
{
throw new ArgumentException("Target must include a 'type' key (e.g., 'azure_ai_agent').", nameof(target));
}
var queryList = testQueries.ToList();
if (queryList.Count == 0)
{
throw new ArgumentException("At least one test query is required.", nameof(testQueries));
}
var evalClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
var resolvedEvaluators = evaluators is { Length: > 0 }
? evaluators
: [Relevance, Coherence, TaskAdherence];
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "target_completions" },
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(resolvedEvaluators, model),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await evalClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = new WireTargetCompletionsDataSource
{
Target = target,
Source = new WireFileContentSource
{
Content = queryList.ConvertAll(q => new WireItemWrapper
{
Item = new WireQueryItem { Query = q },
}),
},
},
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await evalClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
var instance = new FoundryEvals(projectClient, model, null, pollIntervalSeconds, timeoutSeconds, resolvedEvaluators);
var pollResult = await instance.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry target evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry target evaluation run {runId} did not complete within {timeoutSeconds}s.");
}
var fetchResult = await instance.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
return new AgentEvaluationResults("FoundryEvals", fetchResult.MeaiResults)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
// -----------------------------------------------------------------------
// Evaluator name constants
// -----------------------------------------------------------------------
// Agent behavior
/// <summary>Evaluates whether the agent correctly resolves user intent.</summary>
public const string IntentResolution = "intent_resolution";
/// <summary>Evaluates whether the agent adheres to its task instructions.</summary>
public const string TaskAdherence = "task_adherence";
/// <summary>Evaluates whether the agent completes the requested task.</summary>
public const string TaskCompletion = "task_completion";
/// <summary>Evaluates the efficiency of the agent's navigation to complete the task.</summary>
public const string TaskNavigationEfficiency = "task_navigation_efficiency";
// Tool usage
/// <summary>Evaluates the accuracy of tool calls made by the agent.</summary>
public const string ToolCallAccuracy = "tool_call_accuracy";
/// <summary>Evaluates whether the agent selects the correct tools.</summary>
public const string ToolSelection = "tool_selection";
/// <summary>Evaluates the accuracy of inputs provided to tools.</summary>
public const string ToolInputAccuracy = "tool_input_accuracy";
/// <summary>Evaluates how well the agent uses tool outputs.</summary>
public const string ToolOutputUtilization = "tool_output_utilization";
/// <summary>Evaluates whether tool calls succeed.</summary>
public const string ToolCallSuccess = "tool_call_success";
// Quality
/// <summary>Evaluates the coherence of the response.</summary>
public const string Coherence = "coherence";
/// <summary>Evaluates the fluency of the response.</summary>
public const string Fluency = "fluency";
/// <summary>Evaluates the relevance of the response to the query.</summary>
public const string Relevance = "relevance";
/// <summary>Evaluates whether the response is grounded in the provided context.</summary>
public const string Groundedness = "groundedness";
/// <summary>Evaluates the completeness of the response.</summary>
public const string ResponseCompleteness = "response_completeness";
/// <summary>Evaluates the similarity between the response and the expected output.</summary>
public const string Similarity = "similarity";
// Safety
/// <summary>Evaluates the response for violent content.</summary>
public const string Violence = "violence";
/// <summary>Evaluates the response for sexual content.</summary>
public const string Sexual = "sexual";
/// <summary>Evaluates the response for self-harm content.</summary>
public const string SelfHarm = "self_harm";
/// <summary>Evaluates the response for hate or unfairness.</summary>
public const string HateUnfairness = "hate_unfairness";
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
private async Task<PollResult> PollEvalRunAsync(
string evalId,
string runId,
CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow.AddSeconds(this._timeoutSeconds);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await this._evaluationClient.GetEvaluationRunAsync(
evalId,
runId,
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
using var runDoc = JsonDocument.Parse(result.GetRawResponse().Content);
var root = runDoc.RootElement;
var status = root.GetProperty("status").GetString()!;
if (status is "completed" or "failed" or "canceled")
{
string? reportUrl = root.TryGetProperty("report_url", out var urlProp) ? urlProp.GetString() : null;
string? errorMessage = root.TryGetProperty("error", out var errProp) ? errProp.ToString() : null;
// Extract per-evaluator breakdown
Dictionary<string, PerEvaluatorResult>? perEvaluator = null;
if (root.TryGetProperty("per_testing_criteria_results", out var criteriaArray)
&& criteriaArray.ValueKind == JsonValueKind.Array)
{
perEvaluator = new Dictionary<string, PerEvaluatorResult>();
foreach (var item in criteriaArray.EnumerateArray())
{
var name = item.TryGetProperty("testing_criteria", out var tcProp)
? tcProp.GetString()
: null;
if (name is not null)
{
int passed = item.TryGetProperty("passed", out var pp) && pp.ValueKind == JsonValueKind.Number
? pp.GetInt32() : 0;
int failed = item.TryGetProperty("failed", out var fp) && fp.ValueKind == JsonValueKind.Number
? fp.GetInt32() : 0;
perEvaluator[name] = new PerEvaluatorResult(passed, failed);
}
}
}
return new PollResult(status, reportUrl, errorMessage, perEvaluator);
}
if (DateTime.UtcNow >= deadline)
{
return new PollResult("timeout", null, null, null);
}
await Task.Delay(TimeSpan.FromSeconds(this._pollIntervalSeconds), cancellationToken).ConfigureAwait(false);
}
}
private sealed record PollResult(
string Status,
string? ReportUrl,
string? ErrorMessage,
Dictionary<string, PerEvaluatorResult>? PerEvaluator);
private async Task<FetchResult> FetchOutputItemResultsAsync(
string evalId,
string runId,
CancellationToken cancellationToken)
{
var meaiResults = new List<EvaluationResult>();
var detailedItems = new List<EvalItemResult>();
string? afterCursor = null;
while (true)
{
var response = await this._evaluationClient.GetEvaluationRunOutputItemsAsync(
evalId,
runId,
limit: 100,
order: null,
after: afterCursor,
outputItemStatus: null,
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(response.GetRawResponse().Content);
if (doc.RootElement.TryGetProperty("data", out var dataArray))
{
foreach (var outputItem in dataArray.EnumerateArray())
{
meaiResults.Add(ParseOutputItem(outputItem));
detailedItems.Add(ParseDetailedItem(outputItem));
}
}
// Check for more pages
bool hasMore = doc.RootElement.TryGetProperty("has_more", out var hasMoreProp)
&& hasMoreProp.ValueKind == JsonValueKind.True;
if (!hasMore)
{
break;
}
// Get cursor for next page — use last_id or last item's id
if (doc.RootElement.TryGetProperty("last_id", out var lastIdProp))
{
afterCursor = lastIdProp.GetString();
}
else if (doc.RootElement.TryGetProperty("data", out var data2) && data2.GetArrayLength() > 0)
{
var lastItem = data2[data2.GetArrayLength() - 1];
afterCursor = lastItem.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
}
if (afterCursor is null)
{
break;
}
}
return new FetchResult(meaiResults, detailedItems);
}
private sealed record FetchResult(
List<EvaluationResult> MeaiResults,
List<EvalItemResult> DetailedItems);
private static EvaluationResult ParseOutputItem(JsonElement outputItem)
{
var evalResult = new EvaluationResult();
if (outputItem.TryGetProperty("results", out var itemResults))
{
foreach (var r in itemResults.EnumerateArray())
{
var metricName = r.TryGetProperty("name", out var nameProp)
? nameProp.GetString() ?? "unknown"
: "unknown";
bool? passed = null;
if (r.TryGetProperty("passed", out var passedProp)
&& passedProp.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
passed = passedProp.ValueKind == JsonValueKind.True;
}
double? score = r.TryGetProperty("score", out var scoreProp) && scoreProp.ValueKind == JsonValueKind.Number
? scoreProp.GetDouble()
: null;
EvaluationMetricInterpretation? interpretation = passed.HasValue
? new EvaluationMetricInterpretation
{
Rating = passed.Value ? EvaluationRating.Good : EvaluationRating.Unacceptable,
Failed = !passed.Value,
}
: null;
if (score.HasValue)
{
evalResult.Metrics[metricName] = new NumericMetric(metricName, score.Value)
{
Interpretation = interpretation,
};
}
else if (passed.HasValue)
{
evalResult.Metrics[metricName] = new BooleanMetric(metricName, passed.Value)
{
Interpretation = interpretation,
};
}
// When neither score nor passed is present, the evaluator returned no
// actionable data (e.g. an error or informational entry). Skip the metric
// so it doesn't falsely influence ItemPassed. The raw data is still
// available in DetailedItems for diagnostics.
}
}
return evalResult;
}
private static EvalItemResult ParseDetailedItem(JsonElement outputItem)
{
var itemId = outputItem.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : "";
var status = outputItem.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : "";
var scores = new List<EvalScoreResult>();
if (outputItem.TryGetProperty("results", out var itemResults))
{
foreach (var r in itemResults.EnumerateArray())
{
var name = r.TryGetProperty("name", out var np) ? np.GetString() ?? "unknown" : "unknown";
double score = r.TryGetProperty("score", out var sp) && sp.ValueKind == JsonValueKind.Number
? sp.GetDouble() : 0.0;
bool? passed = null;
if (r.TryGetProperty("passed", out var pp) && pp.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
passed = pp.ValueKind == JsonValueKind.True;
}
scores.Add(new EvalScoreResult(name, score, passed));
}
}
var result = new EvalItemResult(itemId, status, scores);
// Extract error info from sample
if (outputItem.TryGetProperty("sample", out var sample))
{
if (sample.TryGetProperty("error", out var errObj))
{
result.ErrorCode = errObj.TryGetProperty("code", out var code) ? code.GetString() : null;
result.ErrorMessage = errObj.TryGetProperty("message", out var msg) ? msg.GetString() : null;
}
if (sample.TryGetProperty("usage", out var usage) && usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number)
{
var tokenUsage = new Dictionary<string, int>();
if (usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number)
{
tokenUsage["prompt_tokens"] = pt.GetInt32();
}
if (usage.TryGetProperty("completion_tokens", out var ct) && ct.ValueKind == JsonValueKind.Number)
{
tokenUsage["completion_tokens"] = ct.GetInt32();
}
tokenUsage["total_tokens"] = tt.GetInt32();
result.TokenUsage = tokenUsage;
}
// Extract input/output text
if (sample.TryGetProperty("input", out var inputArr) && inputArr.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var si in inputArr.EnumerateArray())
{
if (si.TryGetProperty("role", out var role) && role.GetString() == "user"
&& si.TryGetProperty("content", out var content))
{
parts.Add(content.GetString() ?? "");
}
}
if (parts.Count > 0)
{
result.InputText = string.Join(" ", parts);
}
}
if (sample.TryGetProperty("output", out var outputArr) && outputArr.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var so in outputArr.EnumerateArray())
{
if (so.TryGetProperty("role", out var role) && role.GetString() == "assistant"
&& so.TryGetProperty("content", out var content))
{
parts.Add(content.GetString() ?? "");
}
}
if (parts.Count > 0)
{
result.OutputText = string.Join(" ", parts);
}
}
}
// Extract response_id from datasource_item
if (outputItem.TryGetProperty("datasource_item", out var dsItem))
{
if (dsItem.TryGetProperty("resp_id", out var respId))
{
result.ResponseId = respId.GetString();
}
else if (dsItem.TryGetProperty("response_id", out var responseId))
{
result.ResponseId = responseId.GetString();
}
}
return result;
}
internal static string[] FilterToolEvaluators(string[] evaluators, bool hasTools)
{
if (hasTools)
{
return evaluators;
}
var filtered = Array.FindAll(evaluators, e =>
!FoundryEvalConverter.ToolEvaluators.Contains(FoundryEvalConverter.ResolveEvaluator(e)));
return filtered.Length > 0
? filtered
: throw new ArgumentException(
"All configured evaluators require tool definitions, but no tool calls were found in the eval items. "
+ $"Tool evaluators: {string.Join(", ", evaluators)}. Either add tool call content to your EvalItems or remove tool-type evaluators.");
}
}
@@ -28,6 +28,18 @@
<PackageReference Include="OpenAI" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="Microsoft.Extensions.AI.Evaluation" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension methods for evaluating workflow runs.
/// </summary>
public static class WorkflowEvaluationExtensions
{
/// <summary>
/// Evaluates a completed workflow run.
/// </summary>
/// <param name="run">The completed workflow run.</param>
/// <param name="evaluator">The evaluator to score results.</param>
/// <param name="includeOverall">Whether to include an overall evaluation.</param>
/// <param name="includePerAgent">Whether to include per-agent breakdowns.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with optional per-agent sub-results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this Run run,
IAgentEvaluator evaluator,
bool includeOverall = true,
bool includePerAgent = true,
string evalName = "Workflow Eval",
IConversationSplitter? splitter = null,
CancellationToken cancellationToken = default)
{
var events = run.OutgoingEvents.ToList();
// Extract per-agent data
var agentData = ExtractAgentData(events, splitter);
// Build overall items from final output
var overallItems = new List<EvalItem>();
if (includeOverall)
{
var finalResponse = events.OfType<AgentResponseEvent>().LastOrDefault();
if (finalResponse is not null)
{
var firstInvoked = events.OfType<ExecutorInvokedEvent>().FirstOrDefault();
var query = firstInvoked?.Data switch
{
ChatMessage cm => cm.Text ?? string.Empty,
IReadOnlyList<ChatMessage> msgs => msgs.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty,
string s => s,
_ => firstInvoked?.Data?.ToString() ?? string.Empty,
};
var conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
};
conversation.AddRange(finalResponse.Response.Messages);
overallItems.Add(new EvalItem(query, finalResponse.Response.Text, conversation)
{
Splitter = splitter,
});
}
}
// Evaluate overall
var overallResult = overallItems.Count > 0
? await evaluator.EvaluateAsync(overallItems, evalName, cancellationToken).ConfigureAwait(false)
: new AgentEvaluationResults(evaluator.Name, Array.Empty<EvaluationResult>());
// Per-agent breakdown
if (includePerAgent && agentData.Count > 0)
{
var subResults = new Dictionary<string, AgentEvaluationResults>();
foreach (var kvp in agentData)
{
subResults[kvp.Key] = await evaluator.EvaluateAsync(
kvp.Value,
$"{evalName} - {kvp.Key}",
cancellationToken).ConfigureAwait(false);
}
overallResult.SubResults = subResults;
}
return overallResult;
}
internal static Dictionary<string, List<EvalItem>> ExtractAgentData(
List<WorkflowEvent> events,
IConversationSplitter? splitter)
{
var invoked = new Dictionary<string, ExecutorInvokedEvent>();
var agentData = new Dictionary<string, List<EvalItem>>();
foreach (var evt in events)
{
if (evt is ExecutorInvokedEvent invokedEvent)
{
if (IsInternalExecutor(invokedEvent.ExecutorId))
{
continue;
}
invoked[invokedEvent.ExecutorId] = invokedEvent;
}
else if (evt is ExecutorCompletedEvent completedEvent
&& invoked.TryGetValue(completedEvent.ExecutorId, out var matchingInvoked))
{
var query = matchingInvoked.Data switch
{
ChatMessage cm => cm.Text ?? string.Empty,
IReadOnlyList<ChatMessage> msgs => msgs.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty,
string s => s,
_ => matchingInvoked.Data?.ToString() ?? string.Empty,
};
var responseText = completedEvent.Data switch
{
AgentResponse ar => ar.Text,
ChatMessage cm => cm.Text ?? string.Empty,
string s => s,
_ => completedEvent.Data?.ToString() ?? string.Empty,
};
var agentResponse = completedEvent.Data as AgentResponse;
var conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
};
if (agentResponse is not null)
{
conversation.AddRange(agentResponse.Messages);
}
else
{
conversation.Add(new(ChatRole.Assistant, responseText));
}
var item = new EvalItem(query, responseText, conversation)
{
Splitter = splitter,
};
if (!agentData.TryGetValue(completedEvent.ExecutorId, out var items))
{
items = new List<EvalItem>();
agentData[completedEvent.ExecutorId] = items;
}
items.Add(item);
invoked.Remove(completedEvent.ExecutorId);
}
}
return agentData;
}
private static bool IsInternalExecutor(string executorId)
{
return executorId.StartsWith('_')
|| executorId is "input-conversation" or "end-conversation" or "end";
}
}
@@ -55,4 +55,9 @@
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,369 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Extension methods for evaluating agents, responses, and workflow runs.
/// </summary>
public static partial class AgentEvaluationExtensions
{
private const string DefaultEvalName = "AgentFrameworkEval";
/// <summary>
/// Evaluates an agent by running it against test queries and scoring the responses.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluator">The evaluator to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query. When provided,
/// must be the same length as <paramref name="queries"/>. Each value is
/// stamped on the corresponding <see cref="EvalItem.ExpectedOutput"/>.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query. When provided,
/// must be the same length as <paramref name="queries"/>. Each list is
/// stamped on the corresponding <see cref="EvalItem.ExpectedToolCalls"/>.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency. Results contain all N Ă— queries.Count items.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IAgentEvaluator evaluator,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var items = await RunAgentForEvalAsync(agent, queries, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
return await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates an agent using an MEAI evaluator directly.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluator">The MEAI evaluator (e.g., <c>RelevanceEvaluator</c>, <c>CompositeEvaluator</c>).</param>
/// <param name="chatConfiguration">Chat configuration for the MEAI evaluator (includes the judge model).</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IEvaluator evaluator,
ChatConfiguration chatConfiguration,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var wrapped = new MeaiEvaluatorAdapter(evaluator, chatConfiguration);
return await agent.EvaluateAsync(queries, wrapped, evalName, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates an agent by running it against test queries with multiple evaluators.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluators">The evaluators to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One result per evaluator.</returns>
public static async Task<IReadOnlyList<AgentEvaluationResults>> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IEnumerable<IAgentEvaluator> evaluators,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var items = await RunAgentForEvalAsync(agent, queries, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
var results = new List<AgentEvaluationResults>();
foreach (var evaluator in evaluators)
{
var result = await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
return results;
}
/// <summary>
/// Evaluates pre-existing agent responses without re-running the agent.
/// </summary>
/// <param name="agent">The agent (used for tool definitions).</param>
/// <param name="responses">Pre-existing agent responses.</param>
/// <param name="queries">The queries that produced each response (must match count).</param>
/// <param name="evaluator">The evaluator to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IAgentEvaluator evaluator,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
CancellationToken cancellationToken = default)
{
var items = BuildItemsFromResponses(agent, responses, queries, expectedOutput, expectedToolCalls);
return await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates pre-existing agent responses using an MEAI evaluator directly.
/// </summary>
/// <param name="agent">The agent (used for tool definitions).</param>
/// <param name="responses">Pre-existing agent responses.</param>
/// <param name="queries">The queries that produced each response (must match count).</param>
/// <param name="evaluator">The MEAI evaluator.</param>
/// <param name="chatConfiguration">Chat configuration for the MEAI evaluator.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IEvaluator evaluator,
ChatConfiguration chatConfiguration,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
CancellationToken cancellationToken = default)
{
var wrapped = new MeaiEvaluatorAdapter(evaluator, chatConfiguration);
return await agent.EvaluateAsync(responses, queries, wrapped, evalName, expectedOutput, expectedToolCalls, cancellationToken).ConfigureAwait(false);
}
internal static List<EvalItem> BuildItemsFromResponses(
AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IEnumerable<string>? expectedOutput,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls)
{
var responseList = responses.ToList();
var queryList = queries.ToList();
var expectedList = expectedOutput?.ToList();
var expectedToolCallsList = expectedToolCalls?.ToList();
if (responseList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {responseList.Count} responses. Counts must match.");
}
if (expectedList != null && expectedList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {expectedList.Count} expectedOutput values. Counts must match.");
}
if (expectedToolCallsList != null && expectedToolCallsList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {expectedToolCallsList.Count} expectedToolCalls lists. Counts must match.");
}
var items = new List<EvalItem>();
for (int i = 0; i < responseList.Count; i++)
{
var query = queryList[i];
var response = responseList[i];
var messages = new List<ChatMessage>
{
new(ChatRole.User, query),
};
messages.AddRange(response.Messages);
var item = BuildEvalItem(query, response, messages, agent);
if (expectedList != null)
{
item.ExpectedOutput = expectedList[i];
}
if (expectedToolCallsList != null)
{
item.ExpectedToolCalls = expectedToolCallsList[i].ToList();
}
items.Add(item);
}
return items;
}
private static async Task<List<EvalItem>> RunAgentForEvalAsync(
AIAgent agent,
IEnumerable<string> queries,
IEnumerable<string>? expectedOutput,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls,
IConversationSplitter? splitter,
int numRepetitions,
CancellationToken cancellationToken)
{
if (numRepetitions < 1)
{
throw new ArgumentException($"numRepetitions must be >= 1, got {numRepetitions}.", nameof(numRepetitions));
}
var items = new List<EvalItem>();
var queryList = queries.ToList();
var expectedList = expectedOutput?.ToList();
var expectedToolCallsList = expectedToolCalls?.ToList();
if (expectedList != null && expectedList.Count != queryList.Count)
{
throw new ArgumentException(
$"Got {queryList.Count} queries but {expectedList.Count} expectedOutput values. Counts must match.");
}
if (expectedToolCallsList != null && expectedToolCallsList.Count != queryList.Count)
{
throw new ArgumentException(
$"Got {queryList.Count} queries but {expectedToolCallsList.Count} expectedToolCalls lists. Counts must match.");
}
for (int rep = 0; rep < numRepetitions; rep++)
{
for (int i = 0; i < queryList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var query = queryList[i];
var messages = new List<ChatMessage>
{
new(ChatRole.User, query),
};
var response = await agent.RunAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false);
var item = BuildEvalItem(query, response, messages, agent);
item.Splitter = splitter;
if (expectedList != null)
{
item.ExpectedOutput = expectedList[i];
}
if (expectedToolCallsList != null)
{
item.ExpectedToolCalls = expectedToolCallsList[i].ToList();
}
items.Add(item);
}
}
return items;
}
internal static EvalItem BuildEvalItem(
string query,
AgentResponse response,
List<ChatMessage> messages,
AIAgent? agent)
{
// Build conversation from existing messages plus any new response messages
var conversation = new List<ChatMessage>(messages);
foreach (var msg in response.Messages)
{
if (!conversation.Contains(msg))
{
conversation.Add(msg);
}
}
var item = new EvalItem(query, response.Text, conversation)
{
RawResponse = new ChatResponse(response.Messages.LastOrDefault()
?? new ChatMessage(ChatRole.Assistant, response.Text)),
};
// Extract tool definitions from the agent (mirrors Python's to_eval_item(agent=...))
if (agent is not null)
{
var chatOptions = agent.GetService<ChatOptions>();
if (chatOptions?.Tools is { Count: > 0 } tools)
{
item.Tools = tools.ToList().AsReadOnly();
}
}
return item;
}
}
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Aggregate evaluation results across multiple items.
/// </summary>
public sealed class AgentEvaluationResults
{
private readonly List<EvaluationResult> _items;
/// <summary>
/// Initializes a new instance of the <see cref="AgentEvaluationResults"/> class.
/// </summary>
/// <param name="providerName">Name of the evaluation provider.</param>
/// <param name="items">Per-item MEAI evaluation results.</param>
/// <param name="inputItems">The original eval items that were evaluated, for auditing.</param>
public AgentEvaluationResults(string providerName, IEnumerable<EvaluationResult> items, IReadOnlyList<EvalItem>? inputItems = null)
{
this.ProviderName = providerName;
this._items = new List<EvaluationResult>(items);
this.InputItems = inputItems;
}
/// <summary>Gets the evaluation provider name.</summary>
public string ProviderName { get; }
/// <summary>Gets the portal URL for viewing results (Foundry only).</summary>
public Uri? ReportUrl { get; set; }
/// <summary>Gets the Foundry evaluation ID (Foundry only).</summary>
public string? EvalId { get; set; }
/// <summary>Gets the Foundry evaluation run ID (Foundry only).</summary>
public string? RunId { get; set; }
/// <summary>Gets the evaluation run status (e.g., "completed", "failed", "canceled", "timeout").</summary>
public string? Status { get; set; }
/// <summary>Gets error details when the evaluation run failed.</summary>
public string? Error { get; set; }
/// <summary>Gets the per-item MEAI evaluation results.</summary>
public IReadOnlyList<EvaluationResult> Items => this._items;
/// <summary>
/// Gets the original eval items that produced these results, for auditing.
/// Each entry corresponds positionally to <see cref="Items"/> — <c>InputItems[i]</c>
/// is the query/response that produced <c>Items[i]</c>.
/// </summary>
public IReadOnlyList<EvalItem>? InputItems { get; }
/// <summary>Gets per-agent results for workflow evaluations.</summary>
public IReadOnlyDictionary<string, AgentEvaluationResults>? SubResults { get; set; }
/// <summary>Gets per-evaluator pass/fail breakdown (Foundry only).</summary>
public IReadOnlyDictionary<string, PerEvaluatorResult>? PerEvaluator { get; set; }
/// <summary>
/// Gets detailed per-item results from the Foundry output_items API,
/// including individual evaluator scores, error info, and token usage.
/// </summary>
public IReadOnlyList<EvalItemResult>? DetailedItems { get; set; }
/// <summary>Gets the number of items that passed.</summary>
public int Passed => this._items.Count(ItemPassed);
/// <summary>Gets the number of items that failed.</summary>
public int Failed => this._items.Count(i => !ItemPassed(i));
/// <summary>Gets the total number of items evaluated.</summary>
public int Total => this._items.Count;
/// <summary>Gets whether all items passed.</summary>
public bool AllPassed
{
get
{
if (this.SubResults is not null)
{
return this.SubResults.Values.All(s => s.AllPassed)
&& (this.Total == 0 || this.Failed == 0);
}
return this.Total > 0 && this.Failed == 0;
}
}
/// <summary>
/// Asserts that all items passed. Throws <see cref="InvalidOperationException"/> on failure.
/// </summary>
/// <param name="message">Optional custom failure message.</param>
/// <exception cref="InvalidOperationException">Thrown when any items failed.</exception>
public void AssertAllPassed(string? message = null)
{
if (!this.AllPassed)
{
var detail = message ?? $"{this.ProviderName}: {this.Passed} passed, {this.Failed} failed out of {this.Total}.";
if (this.ReportUrl is not null)
{
detail += $" See {this.ReportUrl} for details.";
}
if (this.SubResults is not null)
{
var failedAgents = this.SubResults
.Where(kvp => !kvp.Value.AllPassed)
.Select(kvp => kvp.Key);
detail += $" Failed agents: {string.Join(", ", failedAgents)}.";
}
throw new InvalidOperationException(detail);
}
}
private static bool ItemPassed(EvaluationResult result)
{
foreach (var metric in result.Metrics.Values)
{
// Trust the evaluator's own pass/fail determination first.
if (metric.Interpretation?.Failed == true)
{
return false;
}
// A boolean false is unambiguous — the check failed.
if (metric is BooleanMetric boolean && boolean.Value == false)
{
return false;
}
// Numeric metrics without Interpretation are informational scores;
// the evaluator should set Interpretation if it wants pass/fail semantics.
}
return result.Metrics.Count > 0;
}
}
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
/// <summary>
/// Result of a single check on a single evaluation item.
/// </summary>
/// <param name="Passed">Whether the check passed.</param>
/// <param name="Reason">Human-readable explanation.</param>
/// <param name="CheckName">Name of the check that produced this result.</param>
public sealed record EvalCheckResult(bool Passed, string Reason, string CheckName);
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
/// <summary>
/// Delegate for a synchronous evaluation check on a single item.
/// </summary>
/// <param name="item">The evaluation item.</param>
/// <returns>The check result.</returns>
public delegate EvalCheckResult EvalCheck(EvalItem item);
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Specifies how <see cref="EvalChecks.ToolCalledCheck(ToolCalledMode, string[])"/> matches tool names.
/// </summary>
public enum ToolCalledMode
{
/// <summary>All specified tools must have been called.</summary>
All,
/// <summary>At least one of the specified tools must have been called.</summary>
Any,
}
/// <summary>
/// Built-in check functions for common evaluation patterns.
/// </summary>
public static class EvalChecks
{
/// <summary>
/// Creates a check that verifies the response contains all specified keywords.
/// </summary>
/// <param name="keywords">Keywords that must appear in the response.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck KeywordCheck(params string[] keywords)
{
return KeywordCheck(caseSensitive: false, keywords);
}
/// <summary>
/// Creates a check that verifies the response contains all specified keywords.
/// </summary>
/// <param name="caseSensitive">Whether the comparison is case-sensitive.</param>
/// <param name="keywords">Keywords that must appear in the response.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck KeywordCheck(bool caseSensitive, params string[] keywords)
{
return (EvalItem item) =>
{
var comparison = caseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var missing = keywords
.Where(kw => !item.Response.Contains(kw, comparison))
.ToList();
var passed = missing.Count == 0;
var reason = passed
? $"All keywords found: {string.Join(", ", keywords)}"
: $"Missing keywords: {string.Join(", ", missing)}";
return new EvalCheckResult(passed, reason, "keyword_check");
};
}
/// <summary>
/// Creates a check that verifies specific tools were called in the conversation.
/// All specified tools must have been called.
/// </summary>
/// <param name="toolNames">Tool names that must appear in the conversation.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCalledCheck(params string[] toolNames)
{
return ToolCalledCheck(ToolCalledMode.All, toolNames);
}
/// <summary>
/// Creates a check that verifies specific tools were called in the conversation.
/// </summary>
/// <param name="mode">Whether <see cref="ToolCalledMode.All"/> or <see cref="ToolCalledMode.Any"/> of the specified tools must be called.</param>
/// <param name="toolNames">Tool names to check for.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCalledCheck(ToolCalledMode mode, params string[] toolNames)
{
return (EvalItem item) =>
{
var calledTools = GetCalledTools(item);
if (mode == ToolCalledMode.Any)
{
var found = toolNames.Where(t => calledTools.Contains(t)).ToList();
var passed = found.Count > 0;
var reason = passed
? $"Called: {string.Join(", ", found)}"
: $"None of expected tools called: {string.Join(", ", toolNames)}";
return new EvalCheckResult(passed, reason, "tool_called_check");
}
var missing = toolNames.Where(t => !calledTools.Contains(t)).ToList();
var allPassed = missing.Count == 0;
var allReason = allPassed
? $"All tools called: {string.Join(", ", toolNames)}"
: $"Missing tool calls: {string.Join(", ", missing)}";
return new EvalCheckResult(allPassed, allReason, "tool_called_check");
};
}
/// <summary>
/// A check that verifies at least one tool was called in the conversation.
/// </summary>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCallsPresent()
{
return (EvalItem item) =>
{
var calledTools = GetCalledTools(item);
var passed = calledTools.Count > 0;
var reason = passed
? $"Tools called: {string.Join(", ", calledTools)}"
: "No tool calls found in conversation";
return new EvalCheckResult(passed, reason, "tool_calls_present");
};
}
/// <summary>
/// A check that verifies expected tool calls match on name and optionally arguments.
/// </summary>
/// <remarks>
/// <para>
/// For each expected tool call, finds matching calls in the conversation by name.
/// If <see cref="ExpectedToolCall.Arguments"/> is provided, checks that the actual
/// arguments contain all expected key-value pairs (subset match — extra actual arguments are OK).
/// </para>
/// <para>If no expected tool calls are set on the item, the check passes.</para>
/// </remarks>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCallArgsMatch()
{
return (EvalItem item) =>
{
var expected = item.ExpectedToolCalls;
if (expected is null || expected.Count == 0)
{
return new EvalCheckResult(true, "No expected tool calls specified.", "tool_call_args_match");
}
var actualCalls = GetCalledToolsWithArgs(item);
int matched = 0;
var details = new List<string>();
foreach (var exp in expected)
{
var matching = actualCalls.Where(c => string.Equals(c.Name, exp.Name, StringComparison.OrdinalIgnoreCase)).ToList();
if (matching.Count == 0)
{
details.Add($" {exp.Name}: not called");
continue;
}
if (exp.Arguments is null)
{
matched++;
details.Add($" {exp.Name}: called (args not checked)");
continue;
}
// Subset match — all expected keys present with expected values
bool found = false;
foreach (var call in matching)
{
if (call.Arguments is not null
&& exp.Arguments.All(kvp =>
call.Arguments.TryGetValue(kvp.Key, out var actual)
&& Equals(actual, kvp.Value)))
{
found = true;
break;
}
}
if (found)
{
matched++;
details.Add($" {exp.Name}: args match");
}
else
{
details.Add($" {exp.Name}: args mismatch");
}
}
var passed = matched == expected.Count;
var reason = $"Tool call args match: {matched}/{expected.Count}\n{string.Join("\n", details)}";
return new EvalCheckResult(passed, reason, "tool_call_args_match");
};
}
/// <summary>
/// Creates a check that verifies the response is non-empty and meets a minimum length.
/// </summary>
/// <param name="minLength">Minimum response length (default 1).</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck NonEmpty(int minLength = 1)
{
return (EvalItem item) =>
{
var trimmed = item.Response.Trim();
var passed = trimmed.Length >= minLength;
var reason = passed
? $"Response length {trimmed.Length} meets minimum {minLength}"
: $"Response length {trimmed.Length} is below minimum {minLength}";
return new EvalCheckResult(passed, reason, "non_empty");
};
}
/// <summary>
/// Creates a check that verifies the response contains the expected output text.
/// </summary>
/// <param name="caseSensitive">Whether the comparison is case-sensitive (default false).</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ContainsExpected(bool caseSensitive = false)
{
return (EvalItem item) =>
{
if (string.IsNullOrEmpty(item.ExpectedOutput))
{
return new EvalCheckResult(false, "ExpectedOutput is not set; check cannot be applied.", "contains_expected");
}
var comparison = caseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var passed = item.Response.Contains(item.ExpectedOutput, comparison);
var reason = passed
? $"Response contains expected output: \"{item.ExpectedOutput}\""
: $"Response does not contain expected output: \"{item.ExpectedOutput}\"";
return new EvalCheckResult(passed, reason, "contains_expected");
};
}
/// <summary>
/// A check that verifies the conversation contains at least one image
/// (<see cref="DataContent"/> or <see cref="UriContent"/> with an image media type).
/// </summary>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck HasImageContent()
{
return (EvalItem item) =>
{
var passed = item.HasImageContent;
var reason = passed
? "Conversation contains image content"
: "No image content found in conversation";
return new EvalCheckResult(passed, reason, "has_image_content");
};
}
private static HashSet<string> GetCalledTools(EvalItem item)
{
var calledTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var message in item.Conversation)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
calledTools.Add(functionCall.Name);
}
}
}
return calledTools;
}
private static List<(string Name, IReadOnlyDictionary<string, object>? Arguments)> GetCalledToolsWithArgs(EvalItem item)
{
var calls = new List<(string Name, IReadOnlyDictionary<string, object>? Arguments)>();
foreach (var message in item.Conversation)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
IDictionary<string, object?>? rawArgs = functionCall.Arguments;
IReadOnlyDictionary<string, object>? args = null;
if (rawArgs is not null)
{
var dict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in rawArgs)
{
if (kvp.Value is not null)
{
// Normalize JsonElement values to their .NET equivalents for comparison
dict[kvp.Key] = kvp.Value is JsonElement je ? UnwrapJsonElement(je) : kvp.Value;
}
}
args = dict;
}
calls.Add((functionCall.Name, args));
}
}
}
return calls;
}
private static object UnwrapJsonElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString()!,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => element.ToString(),
};
}
}
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provider-agnostic data for a single evaluation item.
/// </summary>
public sealed class EvalItem
{
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class.
/// </summary>
/// <param name="query">The user query.</param>
/// <param name="response">The agent response text.</param>
/// <param name="conversation">The full conversation as <see cref="ChatMessage"/> list.</param>
public EvalItem(string query, string response, IReadOnlyList<ChatMessage> conversation)
{
this.Query = query;
this.Response = response;
this.Conversation = conversation;
}
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class from a conversation,
/// deriving query and response text via the default splitter.
/// </summary>
/// <remarks>
/// Use this constructor when the conversation contains multimodal content (images, etc.)
/// that can't be represented as plain text. The query is extracted from the last user
/// message text, and the response from the last assistant message text.
/// </remarks>
/// <param name="conversation">The full conversation as <see cref="ChatMessage"/> list.</param>
/// <param name="splitter">
/// Optional splitter to determine query/response boundaries.
/// Defaults to <see cref="ConversationSplitters.LastTurn"/>.
/// </param>
public EvalItem(IReadOnlyList<ChatMessage> conversation, IConversationSplitter? splitter = null)
{
this.Conversation = conversation;
this.Splitter = splitter;
var effective = splitter ?? ConversationSplitters.LastTurn;
var (queryMessages, responseMessages) = effective.Split(conversation);
this.Query = queryMessages.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty;
this.Response = string.Join(
" ",
responseMessages
.Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrEmpty(m.Text))
.Select(m => m.Text));
}
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class from query and response
/// strings, automatically building a minimal conversation.
/// </summary>
/// <remarks>
/// Use this constructor for simple text-only evaluations where you don't need
/// a full conversation history.
/// </remarks>
/// <param name="query">The user query.</param>
/// <param name="response">The agent response text.</param>
public EvalItem(string query, string response)
{
this.Query = query;
this.Response = response;
this.Conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
new(ChatRole.Assistant, response),
};
}
/// <summary>Gets the user query.</summary>
public string Query { get; }
/// <summary>Gets the agent response text.</summary>
public string Response { get; }
/// <summary>Gets the full conversation history.</summary>
/// <remarks>
/// The conversation preserves all content types including images
/// (<see cref="DataContent"/>, <see cref="UriContent"/> with image media types).
/// Use this property in custom <see cref="EvalCheck"/> functions
/// to inspect multimodal content that isn't captured in the
/// text-only <see cref="Query"/> and <see cref="Response"/> properties.
/// </remarks>
public IReadOnlyList<ChatMessage> Conversation { get; }
/// <summary>
/// Gets whether any message in the conversation contains image content.
/// </summary>
/// <remarks>
/// Checks for <see cref="DataContent"/> or <see cref="UriContent"/> with an image media type.
/// Useful in <see cref="EvalCheck"/> functions to verify multimodal content is present.
/// </remarks>
public bool HasImageContent =>
this.Conversation.Any(m =>
m.Contents.Any(c =>
(c is DataContent dc && dc.HasTopLevelMediaType("image"))
|| (c is UriContent uc && uc.HasTopLevelMediaType("image"))));
/// <summary>Gets or sets the tools available to the agent.</summary>
public IReadOnlyList<AITool>? Tools { get; set; }
/// <summary>Gets or sets grounding context for evaluation.</summary>
public string? Context { get; set; }
/// <summary>Gets or sets the expected output for ground-truth comparison.</summary>
public string? ExpectedOutput { get; set; }
/// <summary>
/// Gets or sets the expected tool calls for tool-correctness evaluation.
/// </summary>
/// <remarks>
/// Each entry describes a tool call the agent should make. The evaluator
/// decides matching semantics (ordering, extras, argument checking).
/// See <see cref="ExpectedToolCall"/>.
/// </remarks>
public IReadOnlyList<ExpectedToolCall>? ExpectedToolCalls { get; set; }
/// <summary>Gets or sets the raw chat response for MEAI evaluators.</summary>
public ChatResponse? RawResponse { get; set; }
/// <summary>
/// Gets or sets the conversation splitter for this item.
/// </summary>
/// <remarks>
/// When set by orchestration functions (e.g. <c>EvaluateAsync(splitter: ...)</c>),
/// this is used as the default by <see cref="Split(IConversationSplitter?)"/>.
/// Priority: explicit <c>Split(splitter)</c> argument &gt;
/// <see cref="Splitter"/> &gt; <see cref="ConversationSplitters.LastTurn"/>.
/// </remarks>
public IConversationSplitter? Splitter { get; set; }
/// <summary>
/// Splits the conversation into query messages and response messages.
/// </summary>
/// <param name="splitter">
/// The splitter to use. When <c>null</c>, uses <see cref="Splitter"/>
/// if set, otherwise <see cref="ConversationSplitters.LastTurn"/>.
/// </param>
/// <returns>A tuple of (query messages, response messages).</returns>
public (IReadOnlyList<ChatMessage> QueryMessages, IReadOnlyList<ChatMessage> ResponseMessages) Split(
IConversationSplitter? splitter = null)
{
var effective = splitter ?? this.Splitter ?? ConversationSplitters.LastTurn;
return effective.Split(this.Conversation);
}
/// <summary>
/// Splits a multi-turn conversation into one <see cref="EvalItem"/> per user turn.
/// </summary>
/// <remarks>
/// Each user message starts a new turn. The resulting item has cumulative context:
/// query messages contain the full conversation up to and including that user message,
/// and the response is everything up to the next user message.
/// </remarks>
/// <param name="conversation">The full conversation to split.</param>
/// <param name="tools">Optional tools available to the agent.</param>
/// <param name="context">Optional grounding context.</param>
/// <returns>A list of eval items, one per user turn.</returns>
public static IReadOnlyList<EvalItem> PerTurnItems(
IReadOnlyList<ChatMessage> conversation,
IReadOnlyList<AITool>? tools = null,
string? context = null)
{
var items = new List<EvalItem>();
var userIndices = new List<int>();
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
userIndices.Add(i);
}
}
for (int t = 0; t < userIndices.Count; t++)
{
int userIdx = userIndices[t];
int nextBoundary = t + 1 < userIndices.Count
? userIndices[t + 1]
: conversation.Count;
var responseMessages = conversation.Skip(userIdx + 1).Take(nextBoundary - userIdx - 1).ToList();
var query = conversation[userIdx].Text ?? string.Empty;
var responseText = string.Join(
" ",
responseMessages
.Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrEmpty(m.Text))
.Select(m => m.Text));
var fullSlice = conversation.Take(nextBoundary).ToList();
var item = new EvalItem(query, responseText, fullSlice)
{
Tools = tools,
Context = context,
};
items.Add(item);
}
return items;
}
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Agents.AI;
/// <summary>
/// Per-item result from a Foundry evaluation run, with individual evaluator scores and error details.
/// </summary>
public sealed class EvalItemResult
{
/// <summary>
/// Initializes a new instance of the <see cref="EvalItemResult"/> class.
/// </summary>
/// <param name="itemId">The output item ID from the evaluation API.</param>
/// <param name="status">The item evaluation status (e.g., "pass", "fail", "error").</param>
/// <param name="scores">Per-evaluator score results.</param>
public EvalItemResult(string itemId, string status, IReadOnlyList<EvalScoreResult> scores)
{
this.ItemId = itemId;
this.Status = status;
this.Scores = scores;
}
/// <summary>Gets the output item ID from the evaluation API.</summary>
public string ItemId { get; }
/// <summary>Gets the item evaluation status (e.g., "pass", "fail", "error", "errored").</summary>
public string Status { get; }
/// <summary>Gets the per-evaluator score results.</summary>
public IReadOnlyList<EvalScoreResult> Scores { get; }
/// <summary>Gets or sets an error code when the item evaluation errored.</summary>
public string? ErrorCode { get; set; }
/// <summary>Gets or sets an error message when the item evaluation errored.</summary>
public string? ErrorMessage { get; set; }
/// <summary>Gets or sets the response ID from the evaluation API (e.g., for response-based evals).</summary>
public string? ResponseId { get; set; }
/// <summary>Gets or sets the input text echoed back by the evaluation API.</summary>
public string? InputText { get; set; }
/// <summary>Gets or sets the output text echoed back by the evaluation API.</summary>
public string? OutputText { get; set; }
/// <summary>Gets or sets token usage information from the evaluation.</summary>
public IReadOnlyDictionary<string, int>? TokenUsage { get; set; }
/// <summary>Gets whether this item is in an error state.</summary>
public bool IsError => this.Status is "error" or "errored";
/// <summary>Gets whether this item passed all evaluators.</summary>
public bool IsPassed => this.Scores.Count > 0 && this.Scores.All(s => s.Passed == true);
/// <summary>Gets whether this item failed any evaluator.</summary>
public bool IsFailed => this.Scores.Any(s => s.Passed == false);
}
/// <summary>
/// A single evaluator's score on one evaluation item.
/// </summary>
/// <param name="Name">The evaluator name that produced this score.</param>
/// <param name="Score">The numeric score value.</param>
/// <param name="Passed">Whether the evaluator considered this a pass, or null if not determined.</param>
public record EvalScoreResult(string Name, double Score, bool? Passed = null);
/// <summary>
/// Per-evaluator pass/fail breakdown from an evaluation run.
/// </summary>
/// <param name="Passed">Number of items that passed for this evaluator.</param>
/// <param name="Failed">Number of items that failed for this evaluator.</param>
public record PerEvaluatorResult(int Passed, int Failed);
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI;
/// <summary>
/// A tool call that an agent is expected to make.
/// </summary>
/// <remarks>
/// Used with <c>EvaluateAsync</c> to assert that the agent called the correct tools.
/// The evaluator decides matching semantics (order, extras, argument checking);
/// this type is pure data.
/// </remarks>
/// <param name="Name">The tool/function name (e.g. <c>"get_weather"</c>).</param>
/// <param name="Arguments">
/// Expected arguments. <c>null</c> means "don't check arguments".
/// When provided, evaluators typically do subset matching (all expected keys must be present).
/// </param>
public record ExpectedToolCall(string Name, IReadOnlyDictionary<string, object>? Arguments = null);
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI;
/// <summary>
/// Factory for creating <see cref="EvalCheck"/> delegates from typed lambda functions.
/// </summary>
public static class FunctionEvaluator
{
/// <summary>
/// Creates a check from a function that takes the response text and returns a bool.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the response passes.</param>
public static EvalCheck Create(string name, Func<string, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item.Response);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes response and expected text.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the response passes.</param>
public static EvalCheck Create(string name, Func<string, string?, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item.Response, item.ExpectedOutput);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes the full <see cref="EvalItem"/>.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the item passes.</param>
public static EvalCheck Create(string name, Func<EvalItem, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes the full <see cref="EvalItem"/>
/// and returns a <see cref="EvalCheckResult"/>.
/// </summary>
/// <param name="name">Check name (used as fallback if the result has no name).</param>
/// <param name="check">Function that returns a full check result.</param>
public static EvalCheck Create(string name, Func<EvalItem, EvalCheckResult> check)
{
return (EvalItem item) =>
{
var result = check(item);
return result with { CheckName = result.CheckName ?? name };
};
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Batch-oriented evaluator interface for agent evaluation.
/// </summary>
/// <remarks>
/// Unlike MEAI's <c>IEvaluator</c> which evaluates one item at a time,
/// <see cref="IAgentEvaluator"/> evaluates a batch of items. This enables
/// efficient cloud-based evaluation (e.g., Foundry) and aggregate result computation.
/// </remarks>
public interface IAgentEvaluator
{
/// <summary>Gets the evaluator name.</summary>
string Name { get; }
/// <summary>
/// Evaluates a batch of items and returns aggregate results.
/// </summary>
/// <param name="items">The items to evaluate.</param>
/// <param name="evalName">A display name for this evaluation run.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregate evaluation results.</returns>
Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Agent Framework Eval",
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Strategy for splitting a conversation into query and response halves for evaluation.
/// </summary>
/// <remarks>
/// Use one of the built-in splitters from <see cref="ConversationSplitters"/> or implement
/// your own for domain-specific splitting logic (e.g., splitting before a memory-retrieval
/// tool call to evaluate recall quality).
/// </remarks>
public interface IConversationSplitter
{
/// <summary>
/// Splits a conversation into query messages and response messages.
/// </summary>
/// <param name="conversation">The full conversation to split.</param>
/// <returns>A tuple of (query messages, response messages).</returns>
(IReadOnlyList<ChatMessage> QueryMessages, IReadOnlyList<ChatMessage> ResponseMessages) Split(
IReadOnlyList<ChatMessage> conversation);
}
/// <summary>
/// Built-in conversation splitters for common evaluation patterns.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><see cref="LastTurn"/>: Evaluates whether the agent answered the <em>latest</em> question well.</item>
/// <item><see cref="Full"/>: Evaluates whether the <em>whole conversation trajectory</em> served the original request.</item>
/// </list>
/// For custom splits, implement <see cref="IConversationSplitter"/> directly.
/// </remarks>
public static class ConversationSplitters
{
/// <summary>
/// Split at the last user message. Everything up to and including that message
/// is the query; everything after is the response. This is the default strategy.
/// </summary>
public static IConversationSplitter LastTurn { get; } = new LastTurnSplitter();
/// <summary>
/// The first user message (and any preceding system messages) is the query;
/// the entire remainder of the conversation is the response.
/// Evaluates overall conversation trajectory.
/// </summary>
public static IConversationSplitter Full { get; } = new FullSplitter();
private sealed class LastTurnSplitter : IConversationSplitter
{
public (IReadOnlyList<ChatMessage>, IReadOnlyList<ChatMessage>) Split(
IReadOnlyList<ChatMessage> conversation)
{
int lastUserIdx = -1;
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
lastUserIdx = i;
}
}
if (lastUserIdx >= 0)
{
return (
conversation.Take(lastUserIdx + 1).ToList(),
conversation.Skip(lastUserIdx + 1).ToList());
}
return (new List<ChatMessage>(), conversation.ToList());
}
}
private sealed class FullSplitter : IConversationSplitter
{
public (IReadOnlyList<ChatMessage>, IReadOnlyList<ChatMessage>) Split(
IReadOnlyList<ChatMessage> conversation)
{
int firstUserIdx = -1;
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
firstUserIdx = i;
break;
}
}
if (firstUserIdx >= 0)
{
return (
conversation.Take(firstUserIdx + 1).ToList(),
conversation.Skip(firstUserIdx + 1).ToList());
}
return (new List<ChatMessage>(), conversation.ToList());
}
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Evaluator that runs check functions locally without API calls.
/// </summary>
public sealed class LocalEvaluator : IAgentEvaluator
{
private readonly EvalCheck[] _checks;
/// <summary>
/// Initializes a new instance of the <see cref="LocalEvaluator"/> class.
/// </summary>
/// <param name="checks">The check functions to run on each item.</param>
public LocalEvaluator(params EvalCheck[] checks)
{
this._checks = checks;
}
/// <inheritdoc />
public string Name => "LocalEvaluator";
/// <inheritdoc />
public Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Local Eval",
CancellationToken cancellationToken = default)
{
var results = new List<EvaluationResult>(items.Count);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var evalResult = new EvaluationResult();
foreach (var check in this._checks)
{
var EvalCheckResult = check(item);
evalResult.Metrics[EvalCheckResult.CheckName] = new BooleanMetric(
EvalCheckResult.CheckName,
EvalCheckResult.Passed,
reason: EvalCheckResult.Reason)
{
Interpretation = new EvaluationMetricInterpretation
{
Rating = EvalCheckResult.Passed
? EvaluationRating.Good
: EvaluationRating.Unacceptable,
Failed = !EvalCheckResult.Passed,
},
};
}
results.Add(evalResult);
}
return Task.FromResult(new AgentEvaluationResults(this.Name, results, inputItems: items));
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Adapter that wraps an MEAI <see cref="IEvaluator"/> into an <see cref="IAgentEvaluator"/>.
/// Runs the MEAI evaluator per-item and aggregates results.
/// </summary>
internal sealed class MeaiEvaluatorAdapter : IAgentEvaluator
{
private readonly IEvaluator _evaluator;
private readonly ChatConfiguration _chatConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="MeaiEvaluatorAdapter"/> class.
/// </summary>
/// <param name="evaluator">The MEAI evaluator to wrap.</param>
/// <param name="chatConfiguration">Chat configuration for the evaluator (includes the judge model).</param>
public MeaiEvaluatorAdapter(IEvaluator evaluator, ChatConfiguration chatConfiguration)
{
this._evaluator = evaluator;
this._chatConfiguration = chatConfiguration;
}
/// <inheritdoc />
public string Name => this._evaluator.GetType().Name;
/// <inheritdoc />
public async Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "MEAI Eval",
CancellationToken cancellationToken = default)
{
var results = new List<EvaluationResult>(items.Count);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var (queryMessages, _) = item.Split();
var messages = queryMessages.ToList();
var chatResponse = item.RawResponse
?? new ChatResponse(new ChatMessage(ChatRole.Assistant, item.Response));
var result = await this._evaluator.EvaluateAsync(
messages,
chatResponse,
this._chatConfiguration,
cancellationToken: cancellationToken).ConfigureAwait(false);
results.Add(result);
}
return new AgentEvaluationResults(this.Name, results, inputItems: items);
}
}
@@ -31,6 +31,14 @@
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="Microsoft.Extensions.AI.Evaluation" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework</Title>
@@ -0,0 +1,308 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for <see cref="FoundryEvalConverter"/>.
/// </summary>
public sealed class FoundryEvalConverterTests
{
// ---------------------------------------------------------------
// ResolveEvaluator tests
// ---------------------------------------------------------------
[Fact]
public void ResolveEvaluator_QualityShortNames_ResolvesToBuiltin()
{
Assert.Equal("builtin.relevance", FoundryEvalConverter.ResolveEvaluator("relevance"));
Assert.Equal("builtin.coherence", FoundryEvalConverter.ResolveEvaluator("coherence"));
}
[Fact]
public void ResolveEvaluator_FullyQualifiedName_ReturnsSame()
{
Assert.Equal("builtin.relevance", FoundryEvalConverter.ResolveEvaluator("builtin.relevance"));
}
[Fact]
public void ResolveEvaluator_UnknownName_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(
() => FoundryEvalConverter.ResolveEvaluator("gobblygook"));
Assert.Contains("gobblygook", ex.Message);
}
[Fact]
public void ResolveEvaluator_AgentEvaluators_ResolveCorrectly()
{
Assert.Equal("builtin.intent_resolution", FoundryEvalConverter.ResolveEvaluator("intent_resolution"));
Assert.Equal("builtin.tool_call_accuracy", FoundryEvalConverter.ResolveEvaluator("tool_call_accuracy"));
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertMessage tests
// ---------------------------------------------------------------
[Fact]
public void ConvertMessage_PlainText_ProducesTextContent()
{
var msg = new ChatMessage(ChatRole.User, "Hello world");
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal("user", output[0].Role);
var text = Assert.IsType<WireTextContent>(Assert.Single(output[0].Content));
Assert.Equal("Hello world", text.Text);
}
[Fact]
public void ConvertMessage_ImageUri_ProducesInputImage()
{
var msg = new ChatMessage(ChatRole.User,
[
new UriContent(new Uri("https://example.com/img.png"), "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.IsType<WireImageContent>(Assert.Single(output[0].Content));
}
[Fact]
public void ConvertMessage_FunctionCall_ProducesToolCallContent()
{
var msg = new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" }),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
var toolCall = Assert.IsType<WireToolCallContent>(Assert.Single(output[0].Content));
Assert.Equal("c1", toolCall.ToolCallId);
Assert.Equal("get_weather", toolCall.Name);
}
[Fact]
public void ConvertMessage_FunctionCallWithoutArguments_OmitsArguments()
{
var msg = new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "list_items"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
var toolCall = Assert.IsType<WireToolCallContent>(Assert.Single(output[0].Content));
Assert.Null(toolCall.Arguments);
}
[Fact]
public void ConvertMessage_FunctionResults_FanOutToSeparateMessages()
{
var msg = new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("c1", "72F sunny"),
new FunctionResultContent("c2", "Paris 68F"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Equal(2, output.Count);
Assert.All(output, m => Assert.Equal("tool", m.Role));
Assert.Equal("c1", output[0].ToolCallId);
Assert.Equal("c2", output[1].ToolCallId);
}
[Fact]
public void ConvertMessage_EmptyContent_ProducesEmptyTextFallback()
{
var msg = new ChatMessage(ChatRole.Assistant, Array.Empty<AIContent>());
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
var text = Assert.IsType<WireTextContent>(Assert.Single(output[0].Content));
Assert.Equal(string.Empty, text.Text);
}
[Fact]
public void ConvertMessage_MixedContent_ProducesAllContentTypes()
{
var msg = new ChatMessage(ChatRole.User,
[
new TextContent("Describe this"),
new UriContent(new Uri("https://example.com/img.png"), "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal(2, output[0].Content.Count);
Assert.IsType<WireTextContent>(output[0].Content[0]);
Assert.IsType<WireImageContent>(output[0].Content[1]);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertEvalItem tests
// ---------------------------------------------------------------
[Fact]
public void ConvertEvalItem_BasicItem_HasQueryAndResponse()
{
var item = new EvalItem(query: "What is AI?", response: "Artificial Intelligence.");
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Equal("What is AI?", payload.Query);
Assert.Equal("Artificial Intelligence.", payload.Response);
Assert.NotNull(payload.QueryMessages);
Assert.NotNull(payload.ResponseMessages);
}
[Fact]
public void ConvertEvalItem_WithContext_IncludesContextField()
{
var item = new EvalItem(query: "q", response: "r")
{
Context = "Some grounding context",
};
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Equal("Some grounding context", payload.Context);
}
[Fact]
public void ConvertEvalItem_WithoutContext_OmitsContextField()
{
var item = new EvalItem(query: "q", response: "r");
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Null(payload.Context);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.BuildTestingCriteria tests
// ---------------------------------------------------------------
[Fact]
public void BuildTestingCriteria_QualityEvaluator_UsesStringDataMapping()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var entry = criteria[0];
Assert.Equal("azure_ai_evaluator", entry.Type);
Assert.Equal("builtin.relevance", entry.EvaluatorName);
Assert.NotNull(entry.DataMapping);
var mapping = entry.DataMapping;
Assert.Equal("{{item.query}}", mapping["query"]);
Assert.Equal("{{item.response}}", mapping["response"]);
}
[Fact]
public void BuildTestingCriteria_AgentEvaluator_UsesConversationArrayMapping()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["intent_resolution"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.Equal("{{item.query_messages}}", mapping["query"]);
Assert.Equal("{{item.response_messages}}", mapping["response"]);
}
[Fact]
public void BuildTestingCriteria_ToolEvaluator_IncludesToolDefinitions()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["tool_call_accuracy"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("tool_definitions"));
Assert.Equal("{{item.tool_definitions}}", mapping["tool_definitions"]);
}
[Fact]
public void BuildTestingCriteria_GroundednessEvaluator_IncludesContext()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["groundedness"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("context"));
Assert.Equal("{{item.context}}", mapping["context"]);
}
[Fact]
public void BuildTestingCriteria_WithoutDataMapping_OmitsMappingField()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance"], "gpt-4o-mini", includeDataMapping: false);
Assert.Single(criteria);
Assert.Null(criteria[0].DataMapping);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.BuildItemSchema tests
// ---------------------------------------------------------------
[Fact]
public void BuildItemSchema_Default_HasQueryResponseAndConversationFields()
{
var schema = FoundryEvalConverter.BuildItemSchema();
Assert.True(schema.Properties.ContainsKey("query"));
Assert.True(schema.Properties.ContainsKey("response"));
Assert.True(schema.Properties.ContainsKey("query_messages"));
Assert.True(schema.Properties.ContainsKey("response_messages"));
Assert.False(schema.Properties.ContainsKey("context"));
Assert.False(schema.Properties.ContainsKey("tool_definitions"));
}
[Fact]
public void BuildItemSchema_WithContext_IncludesContextProperty()
{
var schema = FoundryEvalConverter.BuildItemSchema(hasContext: true);
Assert.True(schema.Properties.ContainsKey("context"));
}
[Fact]
public void BuildItemSchema_WithTools_IncludesToolDefinitionsProperty()
{
var schema = FoundryEvalConverter.BuildItemSchema(hasTools: true);
Assert.True(schema.Properties.ContainsKey("tool_definitions"));
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertMessage DataContent test
// ---------------------------------------------------------------
[Fact]
public void ConvertMessage_DataContent_ProducesInputImage()
{
var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes
var msg = new ChatMessage(ChatRole.User,
[
new TextContent("Describe this image"),
new DataContent(imageBytes, "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal(2, output[0].Content.Count);
var text = Assert.IsType<WireTextContent>(output[0].Content[0]);
Assert.Equal("Describe this image", text.Text);
var image = Assert.IsType<WireImageContent>(output[0].Content[1]);
Assert.Contains("data:image/png;base64,", image.ImageUrl);
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for <see cref="FoundryEvals"/> internal helpers.
/// </summary>
public sealed class FoundryEvalsTests
{
[Fact]
public void FilterToolEvaluators_AllToolEvaluators_NoTools_ThrowsArgumentException()
{
// All configured evaluators are tool-type, but no items have tools.
var evaluators = new[] { "tool_call_accuracy", "tool_selection" };
var ex = Assert.Throws<ArgumentException>(
() => FoundryEvals.FilterToolEvaluators(evaluators, hasTools: false));
Assert.Contains("tool definitions", ex.Message);
}
[Fact]
public void FilterToolEvaluators_MixedEvaluators_NoTools_FiltersToolOnes()
{
var evaluators = new[] { "relevance", "tool_call_accuracy", "coherence" };
var result = FoundryEvals.FilterToolEvaluators(evaluators, hasTools: false);
Assert.Equal(2, result.Length);
Assert.Contains("relevance", result);
Assert.Contains("coherence", result);
Assert.DoesNotContain("tool_call_accuracy", result);
}
[Fact]
public void FilterToolEvaluators_HasTools_ReturnsAllEvaluators()
{
var evaluators = new[] { "relevance", "tool_call_accuracy" };
var result = FoundryEvals.FilterToolEvaluators(evaluators, hasTools: true);
Assert.Equal(evaluators, result);
}
}
@@ -9,6 +9,12 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\AgentResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,11 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CopilotStudio\Microsoft.Agents.AI.CopilotStudio.csproj" />
</ItemGroup>
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="EvaluationTests.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
@@ -4,6 +4,11 @@
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
</PropertyGroup>
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="WorkflowEvaluationTests.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
@@ -0,0 +1,326 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Tests for <see cref="WorkflowEvaluationExtensions.ExtractAgentData"/>.
/// </summary>
public sealed class WorkflowEvaluationTests
{
[Fact]
public void ExtractAgentData_EmptyEvents_ReturnsEmpty()
{
var result = WorkflowEvaluationExtensions.ExtractAgentData(new List<WorkflowEvent>(), splitter: null);
Assert.Empty(result);
}
[Fact]
public void ExtractAgentData_MatchedPair_ReturnsItem()
{
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "What is the weather?"),
new ExecutorCompletedEvent("agent-1", "It's sunny."),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.True(result.ContainsKey("agent-1"));
Assert.Single(result["agent-1"]);
Assert.Equal("What is the weather?", result["agent-1"][0].Query);
Assert.Equal("It's sunny.", result["agent-1"][0].Response);
Assert.Equal(2, result["agent-1"][0].Conversation.Count);
}
[Fact]
public void ExtractAgentData_UnmatchedInvocation_NotIncluded()
{
// An invocation without a matching completion should not appear in results
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "Hello"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Empty(result);
}
[Fact]
public void ExtractAgentData_CompletionWithoutInvocation_NotIncluded()
{
// A completion without a prior invocation should not appear in results
var events = new List<WorkflowEvent>
{
new ExecutorCompletedEvent("agent-1", "Response"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Empty(result);
}
[Fact]
public void ExtractAgentData_MultipleAgents_SeparatedByExecutorId()
{
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "Q1"),
new ExecutorInvokedEvent("agent-2", "Q2"),
new ExecutorCompletedEvent("agent-1", "A1"),
new ExecutorCompletedEvent("agent-2", "A2"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Equal(2, result.Count);
Assert.Equal("Q1", result["agent-1"][0].Query);
Assert.Equal("A1", result["agent-1"][0].Response);
Assert.Equal("Q2", result["agent-2"][0].Query);
Assert.Equal("A2", result["agent-2"][0].Response);
}
[Fact]
public void ExtractAgentData_DuplicateExecutorId_LastInvocationUsed()
{
// If the same executor is invoked twice before completing,
// the second invocation overwrites the first
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "First question"),
new ExecutorInvokedEvent("agent-1", "Second question"),
new ExecutorCompletedEvent("agent-1", "Answer"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Single(result["agent-1"]);
Assert.Equal("Second question", result["agent-1"][0].Query);
}
[Fact]
public void ExtractAgentData_MultipleRoundsForSameExecutor_AllCaptured()
{
// Same executor invoked→completed twice (sequential rounds)
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "Q1"),
new ExecutorCompletedEvent("agent-1", "A1"),
new ExecutorInvokedEvent("agent-1", "Q2"),
new ExecutorCompletedEvent("agent-1", "A2"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result); // one executor
Assert.Equal(2, result["agent-1"].Count); // two items
Assert.Equal("Q1", result["agent-1"][0].Query);
Assert.Equal("Q2", result["agent-1"][1].Query);
}
[Fact]
public void ExtractAgentData_NullData_UsesEmptyString()
{
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", null!),
new ExecutorCompletedEvent("agent-1", null),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Equal(string.Empty, result["agent-1"][0].Query);
Assert.Equal(string.Empty, result["agent-1"][0].Response);
}
[Fact]
public void ExtractAgentData_WithSplitter_SetOnItems()
{
var splitter = ConversationSplitters.LastTurn;
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "Q"),
new ExecutorCompletedEvent("agent-1", "A"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter);
Assert.Equal(splitter, result["agent-1"][0].Splitter);
}
[Fact]
public void ExtractAgentData_ChatMessageData_ExtractsText()
{
// When Data is a ChatMessage, the fix should extract .Text instead of type name
var queryMsg = new ChatMessage(ChatRole.User, "What is the weather?");
var responseMsg = new ChatMessage(ChatRole.Assistant, "It's sunny.");
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", queryMsg),
new ExecutorCompletedEvent("agent-1", responseMsg),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Equal("What is the weather?", result["agent-1"][0].Query);
Assert.Equal("It's sunny.", result["agent-1"][0].Response);
}
[Fact]
public void ExtractAgentData_ChatMessageListData_ExtractsLastUserText()
{
// When Data is IReadOnlyList<ChatMessage>, extract last user message text
IReadOnlyList<ChatMessage> messages = new List<ChatMessage>
{
new(ChatRole.User, "First question"),
new(ChatRole.Assistant, "First answer"),
new(ChatRole.User, "Follow-up question"),
};
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", messages),
new ExecutorCompletedEvent("agent-1", "Response text"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Equal("Follow-up question", result["agent-1"][0].Query);
}
[Fact]
public void ExtractAgentData_AgentResponseData_ExtractsText()
{
// When completed Data is an AgentResponse, extract .Text
var agentResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Agent says hello"));
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "Hi there"),
new ExecutorCompletedEvent("agent-1", agentResponse),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Equal("Hi there", result["agent-1"][0].Query);
Assert.Equal("Agent says hello", result["agent-1"][0].Response);
}
[Fact]
public void ExtractAgentData_AgentResponseData_PreservesFullMessages()
{
// When completed Data is an AgentResponse, the conversation should include
// all response messages (tool calls, intermediate, etc.) not just a text summary
var toolCallMsg = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" })]);
var toolResultMsg = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", "Sunny, 72°F")]);
var finalMsg = new ChatMessage(ChatRole.Assistant, "It's sunny and 72°F in Seattle.");
var agentResponse = new AgentResponse
{
Messages = [toolCallMsg, toolResultMsg, finalMsg],
};
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", "What's the weather?"),
new ExecutorCompletedEvent("agent-1", agentResponse),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
// Should have user query + all 3 response messages
Assert.Equal(4, result["agent-1"][0].Conversation.Count);
Assert.Equal(ChatRole.User, result["agent-1"][0].Conversation[0].Role);
Assert.Equal(ChatRole.Assistant, result["agent-1"][0].Conversation[1].Role);
Assert.Equal(ChatRole.Tool, result["agent-1"][0].Conversation[2].Role);
Assert.Equal(ChatRole.Assistant, result["agent-1"][0].Conversation[3].Role);
}
[Fact]
public void ExtractAgentData_UnknownObjectData_UsesToString()
{
// When Data is an unknown object type, the ToString() fallback should produce
// the string representation (not a type name for known types)
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("agent-1", 42),
new ExecutorCompletedEvent("agent-1", 3.14),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.Equal("42", result["agent-1"][0].Query);
Assert.Equal("3.14", result["agent-1"][0].Response);
}
[Fact]
public void ExtractAgentData_SkipsInternalExecutors()
{
var events = new List<WorkflowEvent>
{
new ExecutorInvokedEvent("_internal", "internal query"),
new ExecutorCompletedEvent("_internal", "internal response"),
new ExecutorInvokedEvent("input-conversation", "start"),
new ExecutorCompletedEvent("input-conversation", "done"),
new ExecutorInvokedEvent("end-conversation", "end query"),
new ExecutorCompletedEvent("end-conversation", "end response"),
new ExecutorInvokedEvent("end", "end query"),
new ExecutorCompletedEvent("end", "end response"),
new ExecutorInvokedEvent("real-agent", "real query"),
new ExecutorCompletedEvent("real-agent", "real response"),
};
var result = WorkflowEvaluationExtensions.ExtractAgentData(events, splitter: null);
Assert.Single(result);
Assert.True(result.ContainsKey("real-agent"));
Assert.DoesNotContain("_internal", result.Keys);
Assert.DoesNotContain("input-conversation", result.Keys);
Assert.DoesNotContain("end-conversation", result.Keys);
Assert.DoesNotContain("end", result.Keys);
}
// ---------------------------------------------------------------
// EvaluateAsync integration test
// ---------------------------------------------------------------
[Fact]
public async Task EvaluateAsync_WithSequentialWorkflow_ReturnsPerAgentSubResultsAsync()
{
// Arrange: two agents in a sequential workflow
var agent1 = new TestEchoAgent(name: "agent-one");
var agent2 = new TestEchoAgent(name: "agent-two");
var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
var input = new List<ChatMessage> { new(ChatRole.User, "Hello world") };
var evaluator = new LocalEvaluator(
FunctionEvaluator.Create("has_content", (EvalItem item) => item.Conversation.Count > 0));
// Act
await using var run = await InProcessExecution.RunAsync(workflow, input);
var results = await run.EvaluateAsync(evaluator, includeOverall: false, includePerAgent: true);
// Assert — results returned
Assert.NotNull(results);
// Assert — per-agent sub-results are populated
Assert.NotNull(results.SubResults);
Assert.True(results.SubResults.Count >= 2, $"Expected at least 2 agent sub-results, got {results.SubResults.Count}");
// Each sub-result should have evaluated items
foreach (var (agentId, subResult) in results.SubResults)
{
Assert.True(subResult.Total > 0, $"Agent '{agentId}' should have at least one evaluated item");
}
}
}
+1
View File
@@ -24,6 +24,7 @@
],
"words": [
"aeiou",
"agentserver",
"agui",
"aiplatform",
"azuredocindex",
@@ -26,6 +26,28 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
_user_agent_prefixes: list[str] = []
def append_to_user_agent(prefix: str) -> None:
"""Prepend a prefix to the agent framework user agent string.
This is useful for hosting layers that want to identify themselves in telemetry.
Duplicate prefixes are ignored.
Args:
prefix: The prefix to prepend (e.g. "foundry-hosting").
"""
if prefix and prefix not in _user_agent_prefixes:
_user_agent_prefixes.append(prefix)
def _get_user_agent() -> str:
"""Return the full user agent string including any prepended prefixes."""
if not _user_agent_prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(_user_agent_prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
@@ -57,12 +79,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
user_agent = _get_user_agent()
if not headers:
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
headers[USER_AGENT_KEY] = (
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
if USER_AGENT_KEY in headers
else AGENT_FRAMEWORK_USER_AGENT
)
return {USER_AGENT_KEY: user_agent}
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
return headers
@@ -906,6 +906,9 @@ def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
if isinstance(tool_item, FunctionTool):
results.append(tool_item.to_json_schema_spec())
continue
if isinstance(tool_item, BaseModel):
results.append(tool_item.model_dump(exclude_none=True))
continue
if isinstance(tool_item, SerializationMixin):
results.append(tool_item.to_dict())
continue
@@ -1879,6 +1879,12 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse
response.finish_reason = update.finish_reason
if update.model is not None:
response.model = update.model
if (
isinstance(response, AgentResponse)
and isinstance(update, AgentResponseUpdate)
and update.finish_reason is not None
):
response.finish_reason = update.finish_reason
response.continuation_token = update.continuation_token
@@ -2435,6 +2441,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
response_id: str | None = None,
agent_id: str | None = None,
created_at: CreatedAtT | None = None,
finish_reason: FinishReasonLiteral | FinishReason | None = None,
usage_details: UsageDetails | None = None,
value: ResponseModelT | None = None,
response_format: StructuredResponseFormat = None,
@@ -2450,6 +2457,9 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
agent_id: The identifier of the agent that produced this response. Useful in multi-agent
scenarios to track which agent generated the response.
created_at: A timestamp for the chat response.
finish_reason: The reason the model stopped generating. Common values include
``"stop"`` (natural completion), ``"length"`` (token limit), and
``"tool_calls"`` (the model invoked a tool).
usage_details: The usage details for the chat response.
value: The structured output of the agent run response, if applicable.
response_format: Optional response format for the agent response.
@@ -2476,6 +2486,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
self.response_id = response_id
self.agent_id = agent_id
self.created_at = created_at
self.finish_reason = finish_reason
self.usage_details = usage_details
self._value: ResponseModelT | None = value
self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format
@@ -2688,6 +2699,7 @@ class AgentResponseUpdate(SerializationMixin):
response_id: str | None = None,
message_id: str | None = None,
created_at: CreatedAtT | None = None,
finish_reason: FinishReasonLiteral | FinishReason | None = None,
continuation_token: ContinuationToken | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
@@ -2703,6 +2715,9 @@ class AgentResponseUpdate(SerializationMixin):
response_id: Optional ID of the response of which this update is a part.
message_id: Optional ID of the message of which this update is a part.
created_at: Optional timestamp for the chat response update.
finish_reason: The reason the model stopped generating. Common values include
``"stop"`` (natural completion), ``"length"`` (token limit), and
``"tool_calls"`` (the model invoked a tool).
continuation_token: Optional token for resuming a long-running background operation.
When present, indicates the operation is still in progress.
additional_properties: Optional additional properties associated with the chat response update.
@@ -2729,6 +2744,7 @@ class AgentResponseUpdate(SerializationMixin):
self.response_id = response_id
self.message_id = message_id
self.created_at = created_at
self.finish_reason = finish_reason
self.continuation_token = continuation_token
self.additional_properties = _restore_compaction_annotation_in_additional_properties(
additional_properties,
@@ -2761,6 +2777,7 @@ def map_chat_to_agent_update(update: ChatResponseUpdate, agent_name: str | None)
response_id=update.response_id,
message_id=update.message_id,
created_at=update.created_at,
finish_reason=update.finish_reason, # type: ignore[arg-type]
continuation_token=update.continuation_token,
additional_properties=update.additional_properties,
raw_representation=update,
@@ -16,12 +16,26 @@ from agent_framework._middleware import FunctionInvocationContext
from agent_framework._tools import (
_parse_annotation,
_parse_inputs,
_tools_to_dict,
)
from agent_framework.observability import OtelAttr
# region FunctionTool and tool decorator tests
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
"""Pydantic-based tool specs are serialized without logging parse warnings."""
class ProviderTool(BaseModel):
kind: str
enabled: bool = True
note: str | None = None
result = _tools_to_dict([ProviderTool(kind="google_search")])
assert result == [{"kind": "google_search", "enabled": True}]
def test_tool_decorator():
"""Test the tool decorator."""
@@ -40,8 +40,10 @@ from agent_framework._types import (
_get_data_bytes_as_str,
_parse_content_list,
_parse_structured_response_value,
_process_update,
_validate_uri,
add_usage_details,
map_chat_to_agent_update,
validate_tool_mode,
)
from agent_framework.exceptions import AdditionItemMismatch, ContentError
@@ -4179,3 +4181,101 @@ def test_prepend_instructions_custom_role():
# endregion
# region finish_reason
def test_agent_response_init_with_finish_reason() -> None:
"""Test that AgentResponse correctly initializes and stores finish_reason."""
response = AgentResponse(
messages=[Message("assistant", [Content.from_text("test")])],
finish_reason="stop",
)
assert response.finish_reason == "stop"
def test_agent_response_update_init_with_finish_reason() -> None:
"""Test that AgentResponseUpdate correctly initializes and stores finish_reason."""
update = AgentResponseUpdate(
contents=[Content.from_text("test")],
role="assistant",
finish_reason="stop",
)
assert update.finish_reason == "stop"
def test_map_chat_to_agent_update_forwards_finish_reason() -> None:
"""Test that mapping a ChatResponseUpdate with finish_reason forwards it."""
chat_update = ChatResponseUpdate(
contents=[Content.from_text("test")],
finish_reason="length",
)
agent_update = map_chat_to_agent_update(chat_update, agent_name="test_agent")
assert agent_update.finish_reason == "length"
assert agent_update.author_name == "test_agent"
def test_process_update_propagates_finish_reason_to_agent_response() -> None:
"""Test that _process_update correctly updates an AgentResponse from an AgentResponseUpdate."""
response = AgentResponse(messages=[Message("assistant", [Content.from_text("test")])])
update = AgentResponseUpdate(
contents=[Content.from_text("more text")],
role="assistant",
finish_reason="stop",
)
# Process the update
_process_update(response, update)
assert response.finish_reason == "stop"
def test_process_update_does_not_overwrite_with_none() -> None:
"""Test that _process_update does not overwrite an existing finish_reason with None."""
response = AgentResponse(
messages=[Message("assistant", [Content.from_text("test")])],
finish_reason="length",
)
update = AgentResponseUpdate(
contents=[Content.from_text("more text")],
role="assistant",
finish_reason=None,
)
# Process the update
_process_update(response, update)
assert response.finish_reason == "length"
def test_agent_response_serialization_includes_finish_reason() -> None:
"""Test that AgentResponse serializes correctly, including finish_reason."""
response = AgentResponse(
messages=[Message("assistant", [Content.from_text("test")])],
response_id="test_123",
finish_reason="stop",
)
# Serialize using the framework's API and verify finish_reason is included.
data = response.to_dict()
assert "finish_reason" in data
assert data["finish_reason"] == "stop"
def test_agent_response_update_serialization_includes_finish_reason() -> None:
"""Test that AgentResponseUpdate serializes correctly, including finish_reason."""
update = AgentResponseUpdate(
contents=[Content.from_text("test")],
role="assistant",
response_id="test_456",
finish_reason="tool_calls",
)
data = update.to_dict()
assert "finish_reason" in data
assert data["finish_reason"] == "tool_calls"
# endregion
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+11
View File
@@ -0,0 +1,11 @@
# Foundry Hosting
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
## Responses
TODO
## Invocations
TODO
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from agent_framework._telemetry import append_to_user_agent
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator, Optional
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
def __init__(
self,
agent: BaseAgent,
*,
openapi_spec: Optional[dict[str, Any]] = None,
**kwargs: Any,
) -> None:
"""Initialize an InvocationsHostServer.
Args:
agent: The agent to handle responses for.
openapi_spec: The OpenAPI specification for the server.
**kwargs: Additional keyword arguments.
This host will expect the request to be a JSON body with a "message" field.
The response from the host will be a JSON object with a "response" field containing
the agent's response and a "session_id" field containing the session ID.
"""
super().__init__(openapi_spec=openapi_spec, **kwargs)
if not isinstance(agent, SupportsAgentRun):
raise TypeError("Agent must support the SupportsAgentRun interface")
append_to_user_agent(self.USER_AGENT_PREFIX)
self._agent = agent
self._sessions: dict[str, AgentSession] = {}
self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType]
async def _handle_invoke(self, request: Request) -> Response:
"""Invoke the agent with the given request."""
data = await request.json()
session_id: str = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in self._agent.run(user_message, session=session, stream=True):
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await self._agent.run([user_message], session=session, stream=stream)
return JSONResponse({
"response": response.text,
"session_id": session_id,
})
@@ -0,0 +1,758 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping
from agent_framework import (
ChatOptions,
Content,
ContextProvider,
FileCheckpointStorage,
HistoryProvider,
Message,
RawAgent,
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework._telemetry import append_to_user_agent
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
)
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ComputerScreenshotContent,
CreateResponse,
FunctionCallOutputItemParam,
FunctionShellAction,
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
LocalEnvironmentResource,
MessageContent,
MessageContentInputFileContent,
MessageContentInputImageContent,
MessageContentInputTextContent,
MessageContentOutputTextContent,
MessageContentReasoningTextContent,
MessageContentRefusalContent,
OutputItem,
OutputItemFunctionToolCall,
OutputItemMessage,
OutputItemOutputMessage,
OutputItemReasoningItem,
OutputMessageContent,
OutputMessageContentOutputTextContent,
OutputMessageContentRefusalContent,
ResponseStreamEvent,
SummaryTextContent,
TextContent,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemFunctionCallBuilder,
OutputItemMcpCallBuilder,
OutputItemMessageBuilder,
OutputItemReasoningItemBuilder,
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from typing_extensions import Any, Sequence, cast
logger = logging.getLogger(__name__)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
def __init__(
self,
agent: SupportsAgentRun,
*,
prefix: str = "",
options: ResponsesServerOptions | None = None,
store: ResponseProviderProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a ResponsesHostServer.
Args:
agent: The agent to handle responses for.
prefix: The URL prefix for the server.
options: Optional server options.
store: Optional response store.
**kwargs: Additional keyword arguments.
Note:
1. The agent must not have a history provider with `load_messages=True`,
because history is managed by the hosting infrastructure.
2. The agent must not have any context providers that maintain context
in memory, because the hosting environment may get deactivated between
requests, and any in-memory context would be lost.
"""
super().__init__(prefix=prefix, options=options, store=store, **kwargs)
for provider in getattr(agent, "context_providers", []):
if isinstance(provider, HistoryProvider) and provider.load_messages:
raise RuntimeError(
"There shouldn't be a history provider with `load_messages=True` already present. "
"History is managed by the hosting infrastructure."
)
provider = cast(ContextProvider, provider)
logger.warning(
"Context provider %s is present. If it maintains context in memory, "
"the context may be lost between requests. Use with caution.",
provider.source_id,
)
self._is_workflow_agent = False
self._checkpoint_storage_path = None
if isinstance(agent, WorkflowAgent):
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
raise RuntimeError(
"There should not be a checkpoint storage already present in the workflow agent. "
"The hosting infrastructure will manage checkpoints instead."
)
self._checkpoint_storage_path = (
self.CHECKPOINT_STORAGE_PATH
if self.config.is_hosted
else os.path.join(os.getcwd(), self.CHECKPOINT_STORAGE_PATH.lstrip("/"))
)
self._is_workflow_agent = True
self._agent = agent
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
# Append the user agent prefix for telemetry purposes
append_to_user_agent(self.USER_AGENT_PREFIX)
@staticmethod
def _is_streaming_request(request: CreateResponse) -> bool:
"""Check if the request is a streaming request."""
return request.stream is not None and request.stream is True
async def _handler(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
yield event
return
input_text = await context.get_input_text()
history = await context.get_history()
messages = [*_to_messages(history), input_text]
chat_options, are_options_set = _to_chat_options(request)
is_streaming_request = self._is_streaming_request(request)
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # pyright: ignore[reportUnknownMemberType]
response = await raw_agent.run(messages, stream=False, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response = await self._agent.run(messages, stream=False)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # pyright: ignore[reportUnknownMemberType]
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response_stream = self._agent.run(messages, stream=True)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
async def _handle_workflow_agent(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a workflow agent.
Why this is required:
The sandbox may be deactivated after some period of inactivity, and only data managed
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_text = await context.get_input_text()
is_streaming_request = self._is_streaming_request(request)
_, are_options_set = _to_chat_options(request)
if are_options_set:
logger.warning("Workflow agent doesn't support runtime options. They will be ignored.")
if request.previous_response_id is not None and context.conversation_id is not None:
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
context_id = request.previous_response_id or context.conversation_id
# The following should never happen due to the checks above.
# This is for type safety and defensive programming.
if self._checkpoint_storage_path is None:
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Restore from the latest checkpoint if available, otherwise start with an empty history
if context_id is not None:
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
if not is_streaming_request:
_ = await self._agent.run(
stream=False,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
)
else:
# Consume the streaming or the invocation will result in a no-op
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
pass
# Now run the agent with the latest input
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
# Create a new checkpoint storage for this response based on the following rules:
# - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response
# - If a previous response ID is provided, create a new checkpoint storage for this response
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
context_id = context.conversation_id or context.response_id
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
@staticmethod
async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str):
"""Delete all checkpoints except the latest one.
We only need the last checkpoint for each invocation.
"""
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name)
if latest_checkpoint is not None:
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name)
for checkpoint in all_checkpoints:
if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id:
await checkpoint_storage.delete(checkpoint.checkpoint_id)
# region Active Builder State
class _OutputItemTracker:
"""Tracks the current active output item builder during streaming.
Handles lazy creation, delta emission, and closing of streaming builders
for text messages, reasoning, function calls, and MCP calls.
"""
_DELTA_TYPES = frozenset({"text", "text_reasoning", "function_call", "mcp_server_tool_call"})
def __init__(self, stream: ResponseEventStream) -> None:
self._stream = stream
self._active_type: str | None = None
self._active_id: str | None = None
# Accumulated delta text for the current active builder
self._accumulated: list[str] = []
# Builder state — only one is active at a time
self._message_item: OutputItemMessageBuilder | None = None
self._text_content: TextContentBuilder | None = None
self._reasoning_item: OutputItemReasoningItemBuilder | None = None
self._summary_part: ReasoningSummaryPartBuilder | None = None
self._fc_builder: OutputItemFunctionCallBuilder | None = None
self._mcp_builder: OutputItemMcpCallBuilder | None = None
self.needs_async = False
def handle(self, content: Content) -> Generator[ResponseStreamEvent, None, None]:
"""Process a content item, yielding sync events.
Sets ``needs_async = True`` if the caller must also drain an
async ``_to_outputs`` call for this content.
"""
if content.type == "text" and content.text is not None:
if self._active_type != "text":
yield from self._close()
yield from self._open_message()
assert self._text_content is not None # noqa: S101
self._accumulated.append(content.text)
yield self._text_content.emit_delta(content.text)
elif content.type == "text_reasoning" and content.text is not None:
if self._active_type != "text_reasoning":
yield from self._close()
yield from self._open_reasoning()
assert self._summary_part is not None # noqa: S101
self._accumulated.append(content.text)
yield self._summary_part.emit_text_delta(content.text)
elif content.type == "function_call" and content.call_id is not None:
if self._active_type != "function_call" or self._active_id != content.call_id:
yield from self._close()
yield from self._open_function_call(content)
assert self._fc_builder is not None # noqa: S101
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
yield self._fc_builder.emit_arguments_delta(args_str)
elif content.type == "mcp_server_tool_call" and content.tool_name:
key = f"{content.server_name or 'default'}::{content.tool_name}"
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
yield from self._close()
yield from self._open_mcp_call(content)
assert self._mcp_builder is not None # noqa: S101
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
yield self._mcp_builder.emit_arguments_delta(args_str)
else:
yield from self._close()
self.needs_async = True
def close(self) -> Generator[ResponseStreamEvent, None, None]:
"""Close any remaining active builder."""
yield from self._close()
# -- Private open/close helpers --
def _open_message(self) -> Generator[ResponseStreamEvent, None, None]:
self._message_item = self._stream.add_output_item_message()
self._text_content = self._message_item.add_text_content()
self._active_type = "text"
self._active_id = None
yield self._message_item.emit_added()
yield self._text_content.emit_added()
def _open_reasoning(self) -> Generator[ResponseStreamEvent, None, None]:
self._reasoning_item = self._stream.add_output_item_reasoning_item()
self._summary_part = self._reasoning_item.add_summary_part()
self._active_type = "text_reasoning"
self._active_id = None
yield self._reasoning_item.emit_added()
yield self._summary_part.emit_added()
def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent, None, None]:
self._fc_builder = self._stream.add_output_item_function_call(
name=content.name or "",
call_id=content.call_id or "",
)
self._active_type = "function_call"
self._active_id = content.call_id
yield self._fc_builder.emit_added()
def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent, None, None]:
self._mcp_builder = self._stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
self._active_type = "mcp_server_tool_call"
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
yield self._mcp_builder.emit_added()
def _close(self) -> Generator[ResponseStreamEvent, None, None]:
accumulated = "".join(self._accumulated)
if self._active_type == "text" and self._text_content and self._message_item:
yield self._text_content.emit_text_done(accumulated)
yield self._text_content.emit_done()
yield self._message_item.emit_done()
self._text_content = None
self._message_item = None
elif self._active_type == "text_reasoning" and self._summary_part and self._reasoning_item:
yield self._summary_part.emit_text_done(accumulated)
yield self._summary_part.emit_done()
yield self._reasoning_item.emit_done()
self._summary_part = None
self._reasoning_item = None
elif self._active_type == "function_call" and self._fc_builder:
yield self._fc_builder.emit_arguments_done(accumulated)
yield self._fc_builder.emit_done()
self._fc_builder = None
elif self._active_type == "mcp_server_tool_call" and self._mcp_builder:
yield self._mcp_builder.emit_arguments_done(accumulated)
yield self._mcp_builder.emit_completed()
yield self._mcp_builder.emit_done()
self._mcp_builder = None
self._active_type = None
self._active_id = None
self._accumulated.clear()
# endregion
# region Option Conversion
def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
"""Converts a CreateResponse request to ChatOptions.
Args:
request (CreateResponse): The request to convert.
Returns:
ChatOptions: The converted ChatOptions.
bool: Whether any options were set.
"""
chat_options = ChatOptions()
are_options_set = False
if request.temperature is not None:
chat_options["temperature"] = request.temperature
are_options_set = True
if request.top_p is not None:
chat_options["top_p"] = request.top_p
are_options_set = True
if request.max_output_tokens is not None:
chat_options["max_tokens"] = request.max_output_tokens
are_options_set = True
if request.parallel_tool_calls is not None:
chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls
are_options_set = True
return chat_options, are_options_set
# endregion
# region Input Message Conversion
def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(_to_message(item))
return messages
def _to_message(item: OutputItem) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
Returns:
Message: The converted Message.
Raises:
ValueError: If the OutputItem type is not supported.
"""
if item.type == "output_message":
msg = cast(OutputItemOutputMessage, item)
contents = [_convert_output_message_content(part) for part in msg.content]
return Message(role=msg.role, contents=contents)
if item.type == "message":
msg = cast(OutputItemMessage, item)
contents = [_convert_message_content(part) for part in msg.content]
return Message(role=msg.role, contents=contents)
if item.type == "function_call":
fc = cast(OutputItemFunctionToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
)
if item.type == "function_call_output":
fco = cast(FunctionCallOutputItemParam, item)
output = fco.output if isinstance(fco.output, str) else str(fco.output)
return Message(
role="tool",
contents=[Content.from_function_result(fco.call_id, result=output)],
)
if item.type == "reasoning":
reasoning = cast(OutputItemReasoningItem, item)
contents: list[Content] = []
if reasoning.summary:
for summary in reasoning.summary:
contents.append(Content.from_text(summary.text))
return Message(role="assistant", contents=contents)
raise ValueError(f"Unsupported OutputItem type: {item.type}")
def _convert_output_message_content(content: OutputMessageContent) -> Content:
"""Converts an OutputMessageContent to a Content object.
Args:
content (OutputMessageContent): The OutputMessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the OutputMessageContent type is not supported.
"""
if content.type == "output_text":
text_content = cast(OutputMessageContentOutputTextContent, content)
return Content.from_text(text_content.text)
if content.type == "refusal":
refusal_content = cast(OutputMessageContentRefusalContent, content)
return Content.from_text(refusal_content.refusal)
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
def _convert_message_content(content: MessageContent) -> Content:
"""Converts a MessageContent to a Content object.
Args:
content (MessageContent): The MessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the MessageContent type is not supported.
"""
if content.type == "input_text":
input_text = cast(MessageContentInputTextContent, content)
return Content.from_text(input_text.text)
if content.type == "output_text":
output_text = cast(MessageContentOutputTextContent, content)
return Content.from_text(output_text.text)
if content.type == "text":
text = cast(TextContent, content)
return Content.from_text(text.text)
if content.type == "summary_text":
summary = cast(SummaryTextContent, content)
return Content.from_text(summary.text)
if content.type == "refusal":
refusal = cast(MessageContentRefusalContent, content)
return Content.from_text(refusal.refusal)
if content.type == "reasoning_text":
reasoning = cast(MessageContentReasoningTextContent, content)
return Content.from_text_reasoning(text=reasoning.text)
if content.type == "input_image":
image = cast(MessageContentInputImageContent, content)
if image.image_url:
return Content.from_uri(image.image_url)
if image.file_id:
return Content.from_hosted_file(image.file_id)
if content.type == "input_file":
file = cast(MessageContentInputFileContent, content)
if file.file_url:
return Content.from_uri(file.file_url)
if file.file_id:
return Content.from_hosted_file(file.file_id, name=file.filename)
if content.type == "computer_screenshot":
screenshot = cast(ComputerScreenshotContent, content)
return Content.from_uri(screenshot.image_url)
raise ValueError(f"Unsupported MessageContent type: {content.type}")
# endregion
# region Output Item Conversion
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
"""Convert arguments to a JSON string.
Args:
arguments: The arguments to convert, can be a string, mapping, or None.
Returns:
The arguments as a JSON string.
"""
if arguments is None:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
Args:
stream: The ResponseEventStream to use for building events.
content: The Content to convert.
Yields:
ResponseStreamEvent: The converted event objects.
Raises:
ValueError: If the Content type is not supported.
"""
if content.type == "text" and content.text is not None:
async for event in stream.aoutput_item_message(content.text):
yield event
elif content.type == "text_reasoning" and content.text is not None:
async for event in stream.aoutput_item_reasoning_item(content.text):
yield event
elif content.type == "function_call":
async for event in stream.aoutput_item_function_call(
content.name, # type: ignore[arg-type]
content.call_id, # type: ignore[arg-type]
_arguments_to_str(content.arguments),
):
yield event
elif content.type == "function_result":
async for event in stream.aoutput_item_function_call_output(
content.call_id, # type: ignore[arg-type]
str(content.result or ""),
):
yield event
elif content.type == "image_generation_tool_result" and content.outputs is not None:
async for event in stream.aoutput_item_image_gen_call(str(content.outputs)):
yield event
elif content.type == "mcp_server_tool_call":
mcp_call = stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
yield mcp_call.emit_added()
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
yield event
yield mcp_call.emit_completed()
yield mcp_call.emit_done()
elif content.type == "mcp_server_tool_result":
output = (
content.output
if isinstance(content.output, str)
else str(content.output)
if content.output is not None
else ""
)
async for event in stream.aoutput_item_custom_tool_call_output(content.call_id or "", output):
yield event
elif content.type == "shell_tool_call":
action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0)
async for event in stream.aoutput_item_function_shell_call(
content.call_id or "",
action,
LocalEnvironmentResource(),
status=content.status or "completed",
):
yield event
elif content.type == "shell_tool_result":
output_items: list[FunctionShellCallOutputContent] = []
if content.outputs:
for out in content.outputs:
exit_code = getattr(out, "exit_code", None)
output_items.append(
FunctionShellCallOutputContent(
stdout=getattr(out, "stdout", "") or "",
stderr=getattr(out, "stderr", "") or "",
outcome=FunctionShellCallOutputExitOutcome(exit_code=exit_code if exit_code is not None else 0),
)
)
async for event in stream.aoutput_item_function_shell_call_output(
content.call_id or "",
output_items,
status=content.status or "completed",
max_output_length=content.max_output_length,
):
yield event
else:
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
logger.warning(f"Content type '{content.type}' is not supported yet.")
# endregion
@@ -0,0 +1,99 @@
[project]
name = "agent-framework-foundry-hosting"
description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260402"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"azure-ai-agentserver-core==2.0.0b1",
"azure-ai-agentserver-responses==1.0.0b1",
"azure-ai-agentserver-invocations==1.0.0b1",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry_hosting"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_foundry_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,524 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP round-trip tests for ResponsesHostServer.
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
ASGITransport — no real server process is started. Requests go through
the Starlette routing stack, the Responses API middleware, and arrive at
the registered _handle_create handler.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
HistoryProvider,
Message,
RawAgent,
ResponseStream,
)
from azure.ai.agentserver.responses import InMemoryResponseProvider
from typing_extensions import Any
from agent_framework_foundry_hosting import ResponsesHostServer
# region Helpers
def _make_agent(
*,
response: AgentResponse | None = None,
stream_updates: list[AgentResponseUpdate] | None = None,
) -> MagicMock:
"""Create a mock agent implementing SupportsAgentRun."""
agent = MagicMock(spec=RawAgent)
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
agent.context_providers = []
if response is not None:
async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse:
return response
agent.run = AsyncMock(side_effect=run_non_streaming)
if stream_updates is not None:
async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
for update in stream_updates:
yield update
def run_streaming(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("stream"):
return ResponseStream(_stream_gen()) # type: ignore
raise NotImplementedError("Only streaming is configured on this mock")
agent.run = MagicMock(side_effect=run_streaming)
return agent
def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer:
"""Create a ResponsesHostServer with an in-memory store."""
return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs)
async def _post(
server: ResponsesHostServer,
*,
input_text: str = "Hello",
model: str = "test-model",
stream: bool = False,
temperature: float | None = None,
top_p: float | None = None,
max_output_tokens: int | None = None,
parallel_tool_calls: bool | None = None,
) -> httpx.Response:
"""Send a POST /responses request through the ASGI transport."""
payload: dict[str, Any] = {"model": model, "input": input_text, "stream": stream}
if temperature is not None:
payload["temperature"] = temperature
if top_p is not None:
payload["top_p"] = top_p
if max_output_tokens is not None:
payload["max_output_tokens"] = max_output_tokens
if parallel_tool_calls is not None:
payload["parallel_tool_calls"] = parallel_tool_calls
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.post("/responses", json=payload)
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
events: list[dict[str, Any]] = []
current_event: str | None = None
current_data_lines: list[str] = []
for line in body.split("\n"):
if line.startswith("event: "):
current_event = line[len("event: ") :]
elif line.startswith("data: "):
current_data_lines.append(line[len("data: ") :])
elif line.strip() == "" and current_event is not None:
data_str = "\n".join(current_data_lines)
try:
data = json.loads(data_str)
except json.JSONDecodeError:
data = data_str
events.append({"event": current_event, "data": data})
current_event = None
current_data_lines = []
return events
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
"""Extract event type strings from parsed SSE events."""
return [e["event"] for e in events]
# endregion
# region Initialization
class TestResponsesHostServerInit:
def test_init_basic(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
assert server is not None
def test_init_rejects_history_provider_with_load_messages(self) -> None:
hp = HistoryProvider(source_id="test", load_messages=True)
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.context_providers = [hp]
with pytest.raises(RuntimeError, match="history provider"):
ResponsesHostServer(agent)
# endregion
# region Health Check
class TestHealthCheck:
async def test_readiness(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/readiness")
assert resp.status_code == 200
# endregion
# region Non-streaming
class TestNonStreaming:
async def test_basic_text_response(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello!")])])
)
server = _make_server(agent)
resp = await _post(server, input_text="Hi", stream=False)
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
body = resp.json()
assert body["object"] == "response"
assert body["status"] == "completed"
assert len(body["output"]) > 0
# Find the message output item with our text
text_found = False
for item in body["output"]:
assert item["type"] == "message"
for part in item.get("content", []):
if part.get("type") == "output_text" and part.get("text") == "Hello!":
text_found = True
assert text_found, f"Expected 'Hello!' in output, got: {body['output']}"
async def test_function_call_and_result(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[Content.from_function_call("call_1", "get_weather", arguments='{"loc": "NYC"}')],
),
Message(role="tool", contents=[Content.from_function_result("call_1", result="sunny")]),
Message(role="assistant", contents=[Content.from_text("The weather is sunny!")]),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "function_call" in types
assert "function_call_output" in types
assert "message" in types
async def test_reasoning_content(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_text_reasoning(text="Let me think..."),
Content.from_text("The answer is 42"),
],
),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "reasoning" in types
assert "message" in types
async def test_empty_response(self) -> None:
agent = _make_agent(response=AgentResponse(messages=[]))
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
async def test_chat_options_forwarded(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
server = _make_server(agent)
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
assert resp.status_code == 200
agent.run.assert_awaited_once()
call_kwargs = agent.run.call_args.kwargs
assert call_kwargs["stream"] is False
options = call_kwargs["options"]
assert options["temperature"] == 0.5
assert options["top_p"] == 0.9
assert options["max_tokens"] == 1024
# endregion
# region Streaming
class TestStreaming:
async def test_basic_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("world!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
assert "text/event-stream" in resp.headers["content-type"]
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
assert "response.output_text.delta" in types
assert types.count("response.output_text.delta") == 2
assert "response.output_text.done" in types
# Verify the accumulated text in the done event
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert len(done_events) == 1
assert done_events[0]["data"]["text"] == "Hello world!"
async def test_function_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "hello"}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert types.count("response.function_call_arguments.delta") == 2
assert "response.function_call_arguments.done" in types
# Verify accumulated arguments
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
async def test_alternating_text_and_function_call(self) -> None:
agent = _make_agent(
stream_updates=[
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("search...")], role="assistant"),
# Function call argument deltas
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "x"}')],
role="assistant",
),
# More text deltas
AgentResponseUpdate(contents=[Content.from_text("Found ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("it!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# 4 text deltas + 2 function call argument deltas
assert types.count("response.output_text.delta") == 4
assert types.count("response.function_call_arguments.delta") == 2
# 3 distinct output items (text, fc, text)
assert types.count("response.output_item.added") == 3
assert types.count("response.output_item.done") == 3
# Verify accumulated content
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 2
assert text_done[0]["data"]["text"] == "Let me search..."
assert text_done[1]["data"]["text"] == "Found it!"
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "x"}'
async def test_reasoning_then_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
# Reasoning deltas
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="think...")], role="assistant"),
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("The answer ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("is 42")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# Reasoning + text = 2 output items
assert types.count("response.output_item.added") == 2
assert types.count("response.output_item.done") == 2
assert types.count("response.output_text.delta") == 2
# Verify accumulated text
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 1
assert text_done[0]["data"]["text"] == "The answer is 42"
async def test_empty_streaming(self) -> None:
agent = _make_agent(stream_updates=[])
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types == ["response.created", "response.in_progress", "response.completed"]
async def test_mixed_contents_in_single_update(self) -> None:
"""Text and function call in one update switches builder mid-update."""
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content.from_text("Let me search"),
Content.from_function_call("call_1", "search", arguments='{"q": "test"}'),
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert "response.output_text.delta" in types
assert "response.output_text.done" in types
assert "response.function_call_arguments.delta" in types
assert "response.function_call_arguments.done" in types
async def test_different_function_call_ids_produce_separate_items(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "func_a", arguments='{"x":1}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_2", "func_b", arguments='{"y":2}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
# Two separate function call items
assert types.count("response.output_item.added") == 2
assert types.count("response.function_call_arguments.done") == 2
async def test_mcp_tool_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments='{"query":',
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments=' "test"}',
)
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert "response.output_item.added" in types
assert "response.output_item.done" in types
# endregion
+2 -1
View File
@@ -1,6 +1,6 @@
# Gemini Package (agent-framework-gemini)
Integration with Google's Gemini API via the `google-genai` SDK.
Integration with Google's Gemini Developer API and Vertex AI via the `google-genai` SDK.
## Core Classes
@@ -8,6 +8,7 @@ Integration with Google's Gemini API via the `google-genai` SDK.
- **`GeminiChatClient`** - Full-featured chat client with function invocation, middleware, and telemetry
- **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters
- **`GeminiSettings`** - Settings loaded from environment variables
- **`GoogleGeminiSettings`** - SDK-standard `GOOGLE_*` settings loaded from environment variables
- **`ThinkingConfig`** - Configuration for extended thinking
## Gemini-specific Options
+19 -2
View File
@@ -12,11 +12,28 @@ The Gemini integration enables Microsoft Agent Framework applications to call Go
## Authentication
Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) and set it via environment variable:
The connector supports both `google-genai` authentication modes.
### Gemini Developer API
Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) and set either the package-prefixed or SDK-standard environment variable:
```bash
export GEMINI_API_KEY="your-api-key"
export GEMINI_MODEL="gemini-2.5-flash"
# or: export GOOGLE_API_KEY="your-api-key"
export GEMINI_MODEL="gemini-2.5-flash-lite"
# or: export GOOGLE_MODEL="gemini-2.5-flash-lite"
```
### Vertex AI
Set the standard Vertex AI environment variables used by `google-genai`:
```bash
export GOOGLE_GENAI_USE_VERTEXAI=true
export GOOGLE_CLOUD_PROJECT="your-project-id"
export GOOGLE_CLOUD_LOCATION="global"
export GOOGLE_MODEL="gemini-2.5-flash-lite"
```
## Examples
@@ -2,7 +2,14 @@
import importlib.metadata
from ._chat_client import GeminiChatClient, GeminiChatOptions, GeminiSettings, RawGeminiChatClient, ThinkingConfig
from ._chat_client import (
GeminiChatClient,
GeminiChatOptions,
GeminiSettings,
GoogleGeminiSettings,
RawGeminiChatClient,
ThinkingConfig,
)
try:
__version__ = importlib.metadata.version(__name__)
@@ -13,6 +20,7 @@ __all__ = [
"GeminiChatClient",
"GeminiChatOptions",
"GeminiSettings",
"GoogleGeminiSettings",
"RawGeminiChatClient",
"ThinkingConfig",
"__version__",
@@ -30,6 +30,7 @@ from agent_framework import (
from agent_framework._settings import SecretString, load_settings
from agent_framework.observability import ChatTelemetryLayer
from google import genai
from google.auth.credentials import Credentials
from google.genai import types
from pydantic import BaseModel
@@ -54,6 +55,7 @@ __all__ = [
"GeminiChatClient",
"GeminiChatOptions",
"GeminiSettings",
"GoogleGeminiSettings",
"RawGeminiChatClient",
"ThinkingConfig",
]
@@ -161,10 +163,74 @@ class GeminiSettings(TypedDict, total=False):
model: str | None
class GoogleGeminiSettings(TypedDict, total=False):
"""Google SDK configuration settings loaded from ``GOOGLE_*`` environment variables."""
api_key: SecretString | None
model: str | None
genai_use_vertexai: bool | None
cloud_project: str | None
cloud_location: str | None
# endregion
_GEMINI_SERVICE_URL = "https://generativelanguage.googleapis.com"
_GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com"
_VERTEX_AI_BASE_URL = "https://aiplatform.googleapis.com"
def _resolve_vertexai_mode(client: genai.Client, *, fallback: bool | None = None) -> bool:
"""Resolve whether a client targets Vertex AI, preferring the instantiated SDK client state."""
api_client = getattr(client, "_api_client", None)
vertexai = getattr(api_client, "vertexai", None)
if isinstance(vertexai, bool):
return vertexai
return bool(fallback)
def _resolve_service_url(client: genai.Client, *, vertexai: bool) -> str:
"""Resolve the base service URL from the instantiated SDK client, with a stable fallback."""
api_client = getattr(client, "_api_client", None)
http_options = getattr(api_client, "_http_options", None)
base_url = getattr(http_options, "base_url", None)
if isinstance(base_url, str) and base_url:
return base_url.rstrip("/")
return _VERTEX_AI_BASE_URL if vertexai else _GEMINI_API_BASE_URL
def _validate_client_auth_configuration(
*,
vertexai: bool | None,
api_key: SecretString | None,
project: str | None,
location: str | None,
credentials: Credentials | None,
) -> None:
"""Validate supported auth combinations before instantiating the SDK client."""
if vertexai is not True:
if api_key is None:
raise ValueError(
"Gemini client requires an API key when Vertex AI is not enabled. "
"Set GOOGLE_API_KEY or GEMINI_API_KEY, or pass api_key explicitly."
)
return
if api_key is not None or credentials is not None or (project and location):
return
if project or location:
raise ValueError(
"Gemini client requires both GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION "
"when Vertex AI is enabled without an API key."
)
raise ValueError(
"Gemini client requires Vertex AI credentials or configuration when Vertex AI is enabled. "
"Provide GOOGLE_API_KEY for Vertex AI express mode, pass credentials, or set "
"GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION."
)
# Keys mapping to a different GenerateContentConfig field name
_OPTION_TRANSLATIONS: dict[str, str] = {
@@ -210,7 +276,7 @@ class RawGeminiChatClient(
BaseChatClient[GeminiChatOptionsT],
Generic[GeminiChatOptionsT],
):
"""A raw Gemini chat client for the Google Gemini API without function invocation, middleware or telemetry.
"""A raw Gemini chat client for Gemini Developer API or Vertex AI.
Use this when you want full control over the request pipeline. For instance, to opt out of
telemetry, use custom middleware, or compose your own layers. If you want the full-featured
@@ -224,6 +290,10 @@ class RawGeminiChatClient(
*,
api_key: str | None = None,
model: str | None = None,
vertexai: bool | None = None,
project: str | None = None,
location: str | None = None,
credentials: Credentials | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
client: genai.Client | None = None,
@@ -232,11 +302,21 @@ class RawGeminiChatClient(
"""Create a raw Gemini chat client.
Args:
api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable.
model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable.
api_key: Gemini Developer API key. Falls back to environment settings, preferring
``GOOGLE_API_KEY`` over ``GEMINI_API_KEY``.
model: Default model identifier. Falls back to environment settings, preferring
``GOOGLE_MODEL`` over ``GEMINI_MODEL``.
vertexai: Whether to use Vertex AI endpoints. Falls back to environment settings,
using ``GOOGLE_GENAI_USE_VERTEXAI`` when not passed explicitly.
project: Google Cloud project ID for Vertex AI. Falls back to environment settings,
using ``GOOGLE_CLOUD_PROJECT`` when not passed explicitly.
location: Vertex AI location. Falls back to environment settings, preferring
using ``GOOGLE_CLOUD_LOCATION`` when not passed explicitly.
credentials: Google Cloud credentials for Vertex AI. When omitted, the SDK can use
Application Default Credentials.
env_file_path: Path to a ``.env`` file for credential loading.
env_file_encoding: Encoding for the ``.env`` file.
client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required.
client: Pre-built ``genai.Client`` instance. When provided, connector auth settings are not required.
additional_properties: Extra properties stored on the client instance.
"""
settings = load_settings(
@@ -247,21 +327,58 @@ class RawGeminiChatClient(
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
google_settings = load_settings(
GoogleGeminiSettings,
env_prefix="GOOGLE_",
api_key=api_key,
model=model,
genai_use_vertexai=vertexai,
cloud_project=project,
cloud_location=location,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
configured_vertexai = google_settings.get("genai_use_vertexai")
if client:
self._genai_client = client
else:
resolved_key = settings.get("api_key")
if not resolved_key:
raise ValueError(
"Gemini API key is required. Set via api_key parameter or GEMINI_API_KEY environment variable."
)
self._genai_client = genai.Client(
api_key=resolved_key.get_secret_value(),
http_options={"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}},
resolved_key = google_settings.get("api_key") or settings.get("api_key")
resolved_project = google_settings.get("cloud_project")
resolved_location = google_settings.get("cloud_location")
_validate_client_auth_configuration(
vertexai=configured_vertexai,
api_key=resolved_key,
project=resolved_project,
location=resolved_location,
credentials=credentials,
)
self.model = settings.get("model")
client_kwargs: dict[str, Any] = {
"http_options": {"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}},
}
if configured_vertexai is not None:
client_kwargs["vertexai"] = configured_vertexai
if resolved_key is not None and (
configured_vertexai is not True
or (credentials is None and not (resolved_project and resolved_location))
):
client_kwargs["api_key"] = resolved_key.get_secret_value()
if configured_vertexai is True and resolved_project:
client_kwargs["project"] = resolved_project
if configured_vertexai is True and resolved_location:
client_kwargs["location"] = resolved_location
if configured_vertexai is True and credentials is not None:
client_kwargs["credentials"] = credentials
self._genai_client = genai.Client(**client_kwargs)
self._vertexai = _resolve_vertexai_mode(self._genai_client, fallback=configured_vertexai)
self._service_url = _resolve_service_url(self._genai_client, vertexai=self._vertexai)
self.model = google_settings.get("model") or settings.get("model")
super().__init__(additional_properties=additional_properties)
@@ -414,12 +531,12 @@ class RawGeminiChatClient(
@override
def service_url(self) -> str:
"""Return the base URL of the Gemini API service.
"""Return the base URL of the configured Gemini or Vertex AI service.
Returns:
The Gemini API base URL.
The resolved service base URL.
"""
return _GEMINI_SERVICE_URL
return self._service_url
# region Request preparation
@@ -528,15 +645,16 @@ class RawGeminiChatClient(
call_id = content.call_id or self._generate_tool_call_id()
if content.name:
call_id_to_name[call_id] = content.name
parts.append(
types.Part(
function_call=types.FunctionCall(
id=call_id,
name=content.name or "",
args=content.parse_arguments() or {},
)
)
function_call = types.FunctionCall(
id=call_id,
name=content.name or "",
args=content.parse_arguments() or {},
)
raw_part = content.raw_representation
if isinstance(raw_part, types.Part) and raw_part.function_call is not None:
parts.append(raw_part.model_copy(update={"function_call": function_call}, deep=True))
else:
parts.append(types.Part(function_call=function_call))
case _:
logger.debug("Skipping unsupported content type for Gemini: %s", content.type)
return parts
@@ -889,7 +1007,7 @@ class GeminiChatClient(
RawGeminiChatClient[GeminiChatOptionsT],
Generic[GeminiChatOptionsT],
):
"""Gemini chat client for the Google Gemini API with function invocation, middleware, and telemetry.
"""Gemini chat client for Gemini Developer API or Vertex AI with function invocation, middleware, and telemetry.
This is the recommended client for most use cases. It builds on ``RawGeminiChatClient``
and adds:
@@ -908,6 +1026,10 @@ class GeminiChatClient(
*,
api_key: str | None = None,
model: str | None = None,
vertexai: bool | None = None,
project: str | None = None,
location: str | None = None,
credentials: Credentials | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
client: genai.Client | None = None,
@@ -918,11 +1040,18 @@ class GeminiChatClient(
"""Create a Gemini chat client.
Args:
api_key: The Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable.
model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable.
api_key: Gemini Developer API key. Falls back to environment settings, preferring
``GOOGLE_API_KEY`` over ``GEMINI_API_KEY``.
model: Default model identifier. Falls back to environment settings, preferring
``GOOGLE_MODEL`` over ``GEMINI_MODEL``.
vertexai: Whether to use Vertex AI endpoints. Falls back to ``GOOGLE_GENAI_USE_VERTEXAI``.
project: Google Cloud project ID for Vertex AI. Falls back to ``GOOGLE_CLOUD_PROJECT``.
location: Vertex AI location. Falls back to ``GOOGLE_CLOUD_LOCATION``.
credentials: Google Cloud credentials for Vertex AI. When omitted, the SDK can use
Application Default Credentials.
env_file_path: Path to a ``.env`` file for credential loading.
env_file_encoding: Encoding for the ``.env`` file.
client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required.
client: Pre-built ``genai.Client`` instance. When provided, connector auth settings are not required.
additional_properties: Extra properties stored on the client instance.
middleware: Optional middleware chain applied to every call.
function_invocation_configuration: Optional configuration for the function invocation loop.
@@ -930,6 +1059,10 @@ class GeminiChatClient(
super().__init__(
api_key=api_key,
model=model,
vertexai=vertexai,
project=project,
location=location,
credentials=credentials,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
client=client,
+4 -2
View File
@@ -14,5 +14,7 @@ This folder contains examples demonstrating how to use Google Gemini models with
## Environment Variables
- `GEMINI_API_KEY`: Your Google AI Studio API key (get one from [Google AI Studio](https://aistudio.google.com/apikey))
- `GEMINI_MODEL`: The Gemini model to use (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`)
- `GOOGLE_MODEL` or `GEMINI_MODEL`: The Gemini model to use (for example,
`gemini-2.5-flash-lite` or `gemini-2.5-pro`)
- For Gemini Developer API: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
- For Vertex AI: `GOOGLE_GENAI_USE_VERTEXAI=true`, `GOOGLE_CLOUD_PROJECT`, and `GOOGLE_CLOUD_LOCATION`
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -4,9 +4,9 @@
Allows the model to reason through complex problems before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
"""
import asyncio
@@ -23,10 +23,12 @@ async def main() -> None:
"""Example of extended thinking with a Python version comparison question."""
print("=== Extended thinking ===")
# 1. Configure Gemini extended thinking for a reasoning-heavy request.
options: GeminiChatOptions = {
"thinking_config": ThinkingConfig(thinking_budget=2048),
}
# 2. Create the agent with the Gemini chat client and default thinking options.
agent = Agent(
client=GeminiChatClient(),
name="PythonAgent",
@@ -34,6 +36,7 @@ async def main() -> None:
default_options=options,
)
# 3. Stream the answer so you can see the final response as it arrives.
query = "What new language features were introduced in Python between 3.10 and 3.14?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
@@ -45,3 +48,12 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Extended thinking ===
User: What new language features were introduced in Python between 3.10 and 3.14?
Agent: Python 3.11 introduced exception groups and TaskGroup.
Python 3.12 added PEP 695 type parameter syntax.
Python 3.13-3.14 continued improving typing, performance, and developer ergonomics.
"""
+18 -3
View File
@@ -4,9 +4,9 @@
Covers both non-streaming and streaming responses.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
"""
import asyncio
@@ -35,6 +35,7 @@ async def non_streaming_example() -> None:
"""Runs the agent and waits for the complete response before printing it."""
print("=== Non-streaming ===")
# 1. Create the agent with the Gemini chat client and local weather tool.
agent = Agent(
client=GeminiChatClient(),
name="WeatherAgent",
@@ -42,6 +43,7 @@ async def non_streaming_example() -> None:
tools=[get_weather],
)
# 2. Ask the agent for a single weather lookup and print the final response.
query = "What's the weather like in Karlsruhe, Germany?"
print(f"User: {query}")
result = await agent.run(query)
@@ -52,6 +54,7 @@ async def streaming_example() -> None:
"""Runs the agent and prints each chunk as it is received."""
print("=== Streaming ===")
# 1. Create the same agent configuration for a streaming tool-call example.
agent = Agent(
client=GeminiChatClient(),
name="WeatherAgent",
@@ -59,6 +62,7 @@ async def streaming_example() -> None:
tools=[get_weather],
)
# 2. Ask a multi-location question and stream the model output as it arrives.
query = "What's the weather like in Portland and in Paris?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
@@ -76,3 +80,14 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Non-streaming ===
User: What's the weather like in Karlsruhe, Germany?
Result: The weather in Karlsruhe, Germany is currently sunny with a high of 16°C.
=== Streaming ===
User: What's the weather like in Portland and in Paris?
Agent: In Portland, it is currently rainy with a high of 11°C. In Paris, it is cloudy with a high of 27°C.
"""
@@ -4,9 +4,9 @@
Allows the model to write and run code in a sandboxed environment to answer questions.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
"""
import asyncio
@@ -23,6 +23,7 @@ async def main() -> None:
"""Run the code execution example."""
print("=== Code execution ===")
# 1. Create the agent with Gemini and the built-in code execution tool.
agent = Agent(
client=GeminiChatClient(),
name="CodeAgent",
@@ -30,6 +31,7 @@ async def main() -> None:
tools=[GeminiChatClient.get_code_interpreter_tool()],
)
# 2. Ask for a computed answer and stream the generated code and final result.
query = "What are the first 20 prime numbers? Compute them in code."
print(f"User: {query}")
print("Agent: ", end="", flush=True)
@@ -41,3 +43,10 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Code execution ===
User: What are the first 20 prime numbers? Compute them in code.
Agent: The first 20 prime numbers are 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, and 71.
"""
@@ -4,9 +4,9 @@
Allows Gemini to retrieve location and mapping information before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
"""
import asyncio
@@ -23,6 +23,7 @@ async def main() -> None:
"""Run the Google Maps grounding example."""
print("=== Google Maps grounding ===")
# 1. Create the agent with Gemini and the built-in Google Maps grounding tool.
agent = Agent(
client=GeminiChatClient(),
name="MapsAgent",
@@ -30,6 +31,7 @@ async def main() -> None:
tools=[GeminiChatClient.get_maps_grounding_tool()],
)
# 2. Ask a location-aware question and stream the grounded answer.
query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
@@ -41,3 +43,11 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Google Maps grounding ===
User: What are some highly rated restaurants in the city center of Karlsruhe, Germany?
Agent: Here are several highly rated restaurants near Karlsruhe city center,
along with their cuisine styles and approximate walking distance.
"""
@@ -4,9 +4,9 @@
Allows Gemini to retrieve up-to-date information from the web before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
"""
import asyncio
@@ -23,6 +23,7 @@ async def main() -> None:
"""Run the Google Search grounding example."""
print("=== Google Search grounding ===")
# 1. Create the agent with Gemini and the built-in Google Search grounding tool.
agent = Agent(
client=GeminiChatClient(),
name="SearchAgent",
@@ -30,6 +31,7 @@ async def main() -> None:
tools=[GeminiChatClient.get_web_search_tool()],
)
# 2. Ask a current-events style question and stream the grounded answer.
query = "What is the latest stable release of the .NET SDK?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
@@ -41,3 +43,10 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Google Search grounding ===
User: What is the latest stable release of the .NET SDK?
Agent: As of April 14, 2026, the latest stable release of the .NET SDK is .NET 10.0 (SDK 10.0.201).
"""
@@ -15,12 +15,28 @@ from pydantic import BaseModel
from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig
skip_if_no_api_key = pytest.mark.skipif(
not os.getenv("GEMINI_API_KEY"),
reason="GEMINI_API_KEY not set; skipping integration tests.",
def _has_gemini_integration_credentials() -> bool:
"""Return whether integration credentials for either Gemini API or Vertex AI appear to be configured."""
if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"):
return True
if os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in {"true", "1", "yes", "on"}:
return bool(
os.getenv("GOOGLE_CLOUD_PROJECT")
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
or os.getenv("GOOGLE_API_KEY")
)
return False
skip_if_no_credentials = pytest.mark.skipif(
not _has_gemini_integration_credentials(),
reason="Gemini Developer API or Vertex AI credentials not set; skipping integration tests.",
)
_TEST_MODEL = "gemini-2.5-flash"
_TEST_MODEL = os.getenv("GOOGLE_MODEL") or os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite")
# stub helpers
@@ -89,6 +105,7 @@ def _make_response(
candidate.finish_reason = None
response.candidates = [candidate]
response.finish_reason = finish_reason
response.model_version = model_version
if prompt_tokens is not None or output_tokens is not None:
@@ -115,6 +132,8 @@ def _make_gemini_client(
) -> tuple[GeminiChatClient, MagicMock]:
"""Return a (GeminiChatClient, mock_genai_client) pair."""
mock = mock_client or MagicMock()
mock._api_client.vertexai = False
mock._api_client._http_options.base_url = "https://generativelanguage.googleapis.com/"
client = GeminiChatClient(client=mock, model=model)
return client, mock
@@ -135,12 +154,134 @@ def test_client_created_from_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
assert client.model == "gemini-2.5-flash"
def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.MonkeyPatch) -> None:
"""Raises ValueError at construction when neither an API key nor a pre-built client is available."""
def test_client_created_from_google_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Initialises successfully when the SDK-standard Google API key environment variable is set."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_MODEL", raising=False)
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "test-key-123")
monkeypatch.setenv("GOOGLE_MODEL", "gemini-2.5-flash-lite")
with pytest.raises(ValueError, match="GEMINI_API_KEY"):
mock_client = MagicMock()
mock_client._api_client.vertexai = False
mock_client._api_client._http_options.base_url = "https://generativelanguage.googleapis.com/"
with patch("agent_framework_gemini._chat_client.genai.Client") as client_factory:
client_factory.return_value = mock_client
client = GeminiChatClient()
assert client_factory.call_args.kwargs["api_key"] == "test-key-123"
assert "vertexai" not in client_factory.call_args.kwargs
assert client.model == "gemini-2.5-flash-lite"
assert client.service_url() == "https://generativelanguage.googleapis.com"
def test_client_created_from_vertex_ai_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Initialises a Vertex AI client when the SDK-standard Vertex AI environment variables are set."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "global")
mock_client = MagicMock()
mock_client._api_client.vertexai = True
mock_client._api_client._http_options.base_url = "https://aiplatform.googleapis.com/"
with patch("agent_framework_gemini._chat_client.genai.Client", return_value=mock_client) as client_factory:
client = GeminiChatClient()
assert client_factory.call_args.kwargs["vertexai"] is True
assert client_factory.call_args.kwargs["project"] == "test-project"
assert client_factory.call_args.kwargs["location"] == "global"
assert "api_key" not in client_factory.call_args.kwargs
assert client.service_url() == "https://aiplatform.googleapis.com"
def test_google_settings_take_precedence_over_gemini_aliases(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prefers SDK-standard ``GOOGLE_*`` settings when both env families are present."""
monkeypatch.setenv("GEMINI_API_KEY", "gemini-key")
monkeypatch.setenv("GEMINI_MODEL", "gemini-model")
monkeypatch.setenv("GOOGLE_API_KEY", "google-key")
monkeypatch.setenv("GOOGLE_MODEL", "google-model")
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "google-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "global")
mock_client = MagicMock()
mock_client._api_client.vertexai = True
mock_client._api_client._http_options.base_url = "https://aiplatform.googleapis.com/"
with patch("agent_framework_gemini._chat_client.genai.Client", return_value=mock_client) as client_factory:
client = GeminiChatClient()
assert client_factory.call_args.kwargs["vertexai"] is True
assert client_factory.call_args.kwargs["project"] == "google-project"
assert client_factory.call_args.kwargs["location"] == "global"
assert "api_key" not in client_factory.call_args.kwargs
assert client.model == "google-model"
assert client.service_url() == "https://aiplatform.googleapis.com"
def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.MonkeyPatch) -> None:
"""Raises ValueError at construction when neither Gemini API nor Vertex AI settings are available."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_MODEL", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
with pytest.raises(ValueError, match="requires an API key when Vertex AI is not enabled"):
GeminiChatClient(model="gemini-2.5-flash")
def test_vertex_ai_express_mode_uses_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Passes the API key in Vertex AI express mode when no project/location pair is configured."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_MODEL", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "test-key-123")
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
mock_client = MagicMock()
mock_client._api_client.vertexai = True
mock_client._api_client._http_options.base_url = "https://aiplatform.googleapis.com/"
with patch("agent_framework_gemini._chat_client.genai.Client", return_value=mock_client) as client_factory:
client = GeminiChatClient(model="gemini-2.5-flash-lite")
assert client_factory.call_args.kwargs["vertexai"] is True
assert client_factory.call_args.kwargs["api_key"] == "test-key-123"
assert "project" not in client_factory.call_args.kwargs
assert "location" not in client_factory.call_args.kwargs
assert client.service_url() == "https://aiplatform.googleapis.com"
def test_vertex_ai_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Raises a deterministic error when Vertex AI is enabled without any auth configuration."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
with pytest.raises(ValueError, match="requires Vertex AI credentials or configuration"):
GeminiChatClient(model="gemini-2.5-flash")
def test_vertex_ai_requires_project_and_location_together(monkeypatch: pytest.MonkeyPatch) -> None:
"""Raises a deterministic error when only one Vertex AI location setting is present."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
with pytest.raises(ValueError, match="requires both GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION"):
GeminiChatClient(model="gemini-2.5-flash")
@@ -495,6 +636,30 @@ async def test_thinking_parts_are_silently_skipped() -> None:
assert response.messages[0].text == "The answer is 42."
def test_function_call_part_preserves_thought_signature_from_raw_part() -> None:
"""Reuses the original Gemini Part so tool loops retain thought_signature metadata."""
client, _ = _make_gemini_client()
raw_part = types.Part(
function_call=types.FunctionCall(id="call-1", name="get_weather", args={"location": "Paris"}),
thought_signature=b"sig-123",
)
content = Content.from_function_call(
call_id="call-1",
name="get_weather",
arguments={"location": "Paris"},
raw_representation=raw_part,
)
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].thought_signature == b"sig-123"
assert parts[0].function_call is not None
assert parts[0].function_call.id == "call-1"
assert parts[0].function_call.name == "get_weather"
assert parts[0].function_call.args == {"location": "Paris"}
# code execution parts
@@ -1283,12 +1448,26 @@ def test_service_url() -> None:
assert client.service_url() == "https://generativelanguage.googleapis.com"
def test_service_url_falls_back_when_sdk_base_url_is_unavailable() -> None:
"""Falls back to the known service URL when the SDK client does not expose a base URL."""
gemini_sdk_client = MagicMock()
gemini_sdk_client._api_client.vertexai = False
gemini_client = GeminiChatClient(client=gemini_sdk_client, model="gemini-2.5-flash")
vertex_sdk_client = MagicMock()
vertex_sdk_client._api_client.vertexai = True
vertex_client = GeminiChatClient(client=vertex_sdk_client, model="gemini-2.5-flash")
assert gemini_client.service_url() == "https://generativelanguage.googleapis.com"
assert vertex_client.service_url() == "https://aiplatform.googleapis.com"
# integration tests
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_basic_chat() -> None:
"""Basic request/response round-trip returns a non-empty text reply."""
client = GeminiChatClient(model=_TEST_MODEL)
@@ -1302,7 +1481,7 @@ async def test_integration_basic_chat() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_streaming() -> None:
"""Streaming yields multiple chunks that together form a non-empty response."""
client = GeminiChatClient(model=_TEST_MODEL)
@@ -1319,7 +1498,7 @@ async def test_integration_streaming() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_structured_output() -> None:
"""Structured output with a Pydantic response_format returns a parsed value via response.value."""
@@ -1340,7 +1519,7 @@ async def test_integration_structured_output() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_tool_calling() -> None:
"""Model invokes the registered tool when asked a question that requires it."""
@@ -1363,7 +1542,7 @@ async def test_integration_tool_calling() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_thinking_config() -> None:
"""Model accepts a thinking budget and returns a non-empty text reply."""
options: GeminiChatOptions = {"thinking_config": ThinkingConfig(thinking_budget=512)}
@@ -1380,7 +1559,7 @@ async def test_integration_thinking_config() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_google_search_grounding() -> None:
"""Google Search grounding returns a non-empty response for a current-events question."""
client = GeminiChatClient(model=_TEST_MODEL)
@@ -1396,7 +1575,7 @@ async def test_integration_google_search_grounding() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_google_maps_grounding() -> None:
"""Google Maps grounding returns a non-empty response for a location-based question."""
client = GeminiChatClient(model=_TEST_MODEL)
@@ -1417,7 +1596,7 @@ async def test_integration_google_maps_grounding() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_no_api_key
@skip_if_no_credentials
async def test_integration_code_execution() -> None:
"""Code execution tool produces a non-empty response for a computation request."""
client = GeminiChatClient(model=_TEST_MODEL)
+1
View File
@@ -79,6 +79,7 @@ agent-framework-declarative = { workspace = true }
agent-framework-devui = { workspace = true }
agent-framework-durabletask = { workspace = true }
agent-framework-foundry = { workspace = true }
agent-framework-foundry-hosting = { workspace = true }
agent-framework-foundry-local = { workspace = true }
agent-framework-gemini = { workspace = true }
agent-framework-github-copilot = { workspace = true }
@@ -0,0 +1,56 @@
# Foundry Hosted Agents Samples
This directory contains samples that demonstrate how to use the Agent Framework to host agents on Foundry with different capabilities and configurations. Each sample includes a README with instructions on how to set up, run, and interact with the agent.
Read more about Foundry Hosted Agents [here](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents).
## Environment setup
1. Navigate to the sample directory you want to run. For example:
```bash
python -m venv .venv
# Windows
.venv\Scripts\Activate
# macOS/Linux
source .venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Create a `.env` file with your Foundry configuration following the `env.example` file in the sample.
4. Make sure you are logged in with the Azure CLI:
```bash
az login
```
## Deploying to a Docker container
Navigate to the sample directory and build the Docker image:
```bash
docker build -t hosted-agent-sample .
```
Run the container, passing in the required environment variables:
```bash
docker run -p 8088:8088 \
-e FOUNDRY_PROJECT_ENDPOINT=<your-endpoint> \
-e FOUNDRY_MODEL=<your-model> \
hosted-agent-sample
```
The server will be available at `http://localhost:8088`. You can send requests using the same `curl` command shown above.
## Deploying to Foundry
Follow this [guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent?tabs=bash#configure-your-agent) to deploy your agent to Foundry.
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT= "..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,44 @@
# Basic example of hosting an agent with the `invocations` API
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
### Interacting with the agent
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}'
```
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
```
HTTP/1.1 200
content-length: 34
content-type: application/json
x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83
x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17
x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12)
date: Fri, 17 Apr 2026 23:46:44 GMT
server: hypercorn-h11
{"response":"Hi! How can I help?"}
```
### Multi-turn conversation
To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example:
```bash
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-basic-invocations
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Invocations Protocol
- Streaming
template:
name: agent-framework-agent-basic-invocations
kind: hosted
protocols:
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-basic-invocations
protocols:
- protocol: invocations
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
@@ -0,0 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import InvocationsHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = InvocationsHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT= "..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,44 @@
# Basic example of hosting an agent with the `invocations` API
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
### Interacting with the agent
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}'
```
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
```
HTTP/1.1 200
content-length: 34
content-type: application/json
x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83
x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17
x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12)
date: Fri, 17 Apr 2026 23:46:44 GMT
server: hypercorn-h11
{"response":"Hi! How can I help?"}
```
### Multi-turn conversation
To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example:
```bash
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-basic-invocations
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Invocations Protocol
- Streaming
template:
name: agent-framework-agent-basic-invocations
kind: hosted
protocols:
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-basic-invocations
protocols:
- protocol: invocations
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from collections.abc import AsyncGenerator
from agent_framework import Agent, AgentSession
from agent_framework.foundry import FoundryChatClient
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
# Load environment variables from .env file
load_dotenv()
# In-memory session store — keyed by session ID.
# WARNING: This is lost on restart. Use durable storage in production.
_sessions: dict[str, AgentSession] = {}
# Create the agent
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
app = InvocationAgentServerHost()
@app.invoke_handler
async def handle_invoke(request: Request):
"""Handle streaming multi-turn chat with Azure OpenAI via SSE."""
data = await request.json()
session_id = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = _sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in agent.run(user_message, session=session, stream=True):
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await agent.run([user_message], session=session, stream=stream)
return JSONResponse({"response": response.text})
if __name__ == "__main__":
app.run()

Some files were not shown because too many files have changed in this diff Show More