mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2848b66ebf | ||
|
|
3d4a8c4151 | ||
|
|
9551f9ebd7 | ||
|
|
921162425d | ||
|
|
ca732733d3 | ||
|
|
e321802317 | ||
|
|
535af00aa2 | ||
|
|
b2f759366a | ||
|
|
93926e0d33 | ||
|
|
f16cb9a118 | ||
|
|
9a301b8d4b | ||
|
|
15a11a426a | ||
|
|
cfd3dfe40b | ||
|
|
3b6a4574eb |
@@ -2,7 +2,7 @@ name: Merge Gatekeeper
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main", "feature*" ]
|
||||
branches: ["main", "feature*"]
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
|
||||
@@ -13,23 +13,105 @@ concurrency:
|
||||
jobs:
|
||||
merge-gatekeeper:
|
||||
runs-on: ubuntu-latest
|
||||
# Restrict permissions of the GITHUB_TOKEN.
|
||||
# Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
|
||||
permissions:
|
||||
checks: read
|
||||
statuses: read
|
||||
steps:
|
||||
- name: Run Merge Gatekeeper
|
||||
# NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs:
|
||||
# https://github.com/upsidr/merge-gatekeeper/tags
|
||||
# https://github.com/upsidr/merge-gatekeeper/branches
|
||||
uses: upsidr/merge-gatekeeper@v1
|
||||
- name: Wait for required checks
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
timeout: 3600
|
||||
interval: 30
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TIMEOUT_SECONDS: "3600"
|
||||
INTERVAL_SECONDS: "30"
|
||||
SELF_JOB_NAME: ${{ github.job }}
|
||||
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
|
||||
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
|
||||
# They are outside our control and their transient failures should not block merges.
|
||||
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
|
||||
with:
|
||||
script: |
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
|
||||
const intervalSeconds = Number(process.env.INTERVAL_SECONDS);
|
||||
const selfName = process.env.SELF_JOB_NAME;
|
||||
const ignored = new Set(
|
||||
process.env.IGNORED_NAMES.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
);
|
||||
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Mirrors upsidr/merge-gatekeeper: merge combined-statuses and check-runs
|
||||
// for the PR head SHA, with combined-statuses winning on name collision.
|
||||
async function collectChecks() {
|
||||
const merged = new Map();
|
||||
|
||||
const combined = await github.rest.repos.getCombinedStatusForRef({
|
||||
owner, repo, ref: sha, per_page: 100,
|
||||
});
|
||||
for (const s of combined.data.statuses ?? []) {
|
||||
if (!merged.has(s.context)) {
|
||||
// Combined-status states: success | pending | error | failure
|
||||
merged.set(s.context, { name: s.context, state: s.state });
|
||||
}
|
||||
}
|
||||
|
||||
const runs = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner, repo, ref: sha, per_page: 100,
|
||||
});
|
||||
for (const r of runs) {
|
||||
if (merged.has(r.name)) continue;
|
||||
let state;
|
||||
if (r.status !== 'completed') {
|
||||
state = 'pending';
|
||||
} else if (r.conclusion === 'skipped') {
|
||||
continue; // Skipped runs are dropped, matching the original action.
|
||||
} else if (r.conclusion === 'success' || r.conclusion === 'neutral') {
|
||||
state = 'success';
|
||||
} else {
|
||||
// cancelled | timed_out | action_required | stale | failure
|
||||
state = 'error';
|
||||
}
|
||||
merged.set(r.name, { name: r.name, state });
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function evaluate(entries) {
|
||||
const failed = [];
|
||||
const pending = [];
|
||||
const succeeded = [];
|
||||
for (const e of entries) {
|
||||
if (e.name === selfName || ignored.has(e.name)) continue;
|
||||
if (e.state === 'success') succeeded.push(e.name);
|
||||
else if (e.state === 'error' || e.state === 'failure') failed.push(e.name);
|
||||
else pending.push(e.name);
|
||||
}
|
||||
return { failed, pending, succeeded };
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
for (;;) {
|
||||
const entries = await collectChecks();
|
||||
const { failed, pending, succeeded } = evaluate(entries);
|
||||
|
||||
core.info(
|
||||
`succeeded=${succeeded.length} pending=${pending.length} failed=${failed.length}`,
|
||||
);
|
||||
if (failed.length) {
|
||||
core.setFailed(`Failing checks: ${failed.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
if (pending.length === 0) {
|
||||
core.info(`All required checks passed: ${succeeded.join(', ') || '(none)'}`);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
core.setFailed(`Timed out waiting for: ${pending.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
core.info(`Waiting on (${pending.length}): ${pending.slice(0, 10).join(', ')}${pending.length > 10 ? ', …' : ''}`);
|
||||
await sleep(intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
@@ -246,3 +246,5 @@ dotnet/filtered-*.slnx
|
||||
# Local tool state
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
**/issues/
|
||||
|
||||
@@ -582,6 +582,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
@@ -636,6 +637,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
|
||||
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
|
||||
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
|
||||
"src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
|
||||
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
|
||||
|
||||
+1
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatClientAgent with the Harness AIContextProviders
|
||||
// This sample demonstrates how to use a HarnessAgent with the Harness AIContextProviders
|
||||
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
|
||||
// capabilities powered by Azure AI Foundry.
|
||||
// The agent plans research tasks, creates a todo list, gets user approval,
|
||||
@@ -17,7 +17,6 @@ using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
@@ -29,7 +28,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
|
||||
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// and research-focused instructions including the mandatory planning workflow.
|
||||
var instructions =
|
||||
"""
|
||||
@@ -110,13 +109,9 @@ var instructions =
|
||||
- Check for relevant previously downloaded data / findings before starting new research.
|
||||
""";
|
||||
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
|
||||
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
|
||||
// per-service-call chat history persistence, and in-loop compaction.
|
||||
// Then wrap with UseToolApproval to allow auto-approving tools once confirmed.
|
||||
AIAgent agent =
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||
new OpenAIClient(
|
||||
@@ -130,49 +125,32 @@ AIAgent agent =
|
||||
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
|
||||
|
||||
// Build a ChatClient Pipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
|
||||
.UseMessageInjection() // Allow message injection during the function call loop.
|
||||
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
|
||||
|
||||
// Build our agent on top of the ChatClient Pipeline
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
||||
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
|
||||
RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run.
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
|
||||
new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
|
||||
}),
|
||||
AIContextProviders =
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
||||
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
})
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
})
|
||||
.AsBuilder()
|
||||
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
|
||||
.Build();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `ChatClientAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry.
|
||||
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **ChatClientAgent** — configured directly with Harness providers for planning and task management
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **ToolApproval** — the agent is wrapped with `UseToolApproval()` to allow auto-approving tools once confirmed
|
||||
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
|
||||
- **TodoProvider** — the agent creates and manages a todo list to track research questions
|
||||
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
|
||||
|
||||
+1
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -22,6 +22,9 @@ using OpenAI.Responses;
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
|
||||
// --- Sub-agent: Web Search Agent ---
|
||||
// This agent can search the web and is used by the parent agent to look up stock prices.
|
||||
AIAgent webSearchAgent =
|
||||
@@ -34,20 +37,19 @@ AIAgent webSearchAgent =
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(),
|
||||
],
|
||||
},
|
||||
});
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||
@@ -83,21 +85,20 @@ AIAgent parentAgent =
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using sub-agents.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new SubAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using sub-agents.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new SubAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Harness Step 02 — SubAgents (Stock Price Research)
|
||||
|
||||
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents.
|
||||
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
|
||||
|
||||
## What It Does
|
||||
|
||||
|
||||
+1
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider
|
||||
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
|
||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
||||
// and extract information from the data, then write results back as new files.
|
||||
//
|
||||
@@ -17,7 +17,6 @@ using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
@@ -57,11 +56,7 @@ var instructions =
|
||||
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
""";
|
||||
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
// Create the chat client from the OpenAI provider.
|
||||
AIAgent agent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
@@ -72,36 +67,20 @@ AIAgent agent =
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
|
||||
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new FileAccessProvider(fileStore),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
UseProvidedChatClientAsIs = true,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(
|
||||
new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
}),
|
||||
AIContextProviders =
|
||||
[
|
||||
new FileAccessProvider(fileStore),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
},
|
||||
})
|
||||
.AsBuilder()
|
||||
.Build();
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `ChatClientAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results.
|
||||
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction — so the sample only needs to supply the chat client, token limits, and application-specific options.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
|
||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
||||
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||
|
||||
@@ -163,10 +163,22 @@ app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
|
||||
app.MapDevUI();
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIResponses(pirateAgentBuilder);
|
||||
app.MapOpenAIResponses(knightsKnavesAgentBuilder);
|
||||
app.MapOpenAIResponses(chemistryAgent);
|
||||
app.MapOpenAIResponses(mathsAgent);
|
||||
app.MapOpenAIResponses(literatureAgent);
|
||||
app.MapOpenAIResponses(scienceSequentialWorkflow);
|
||||
app.MapOpenAIResponses(scienceConcurrentWorkflow);
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapOpenAIChatCompletions(pirateAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(chemistryAgent);
|
||||
app.MapOpenAIChatCompletions(mathsAgent);
|
||||
app.MapOpenAIChatCompletions(literatureAgent);
|
||||
app.MapOpenAIChatCompletions(scienceSequentialWorkflow);
|
||||
app.MapOpenAIChatCompletions(scienceConcurrentWorkflow);
|
||||
|
||||
// Map the agents HTTP endpoints
|
||||
app.MapAgentDiscovery("/agents");
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) :
|
||||
{
|
||||
OpenAIClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"),
|
||||
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
|
||||
Transport = new HttpClientPipelineTransport(httpClient)
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC
|
||||
{
|
||||
OpenAIClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri(httpClient.BaseAddress!, "/v1/"),
|
||||
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
|
||||
Transport = new HttpClientPipelineTransport(httpClient)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for creating a <see cref="HarnessAgent"/> from an <see cref="IChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class ChatClientHarnessExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="HarnessAgent"/> that wraps this <see cref="IChatClient"/> with a pre-configured
|
||||
/// pipeline including function invocation, per-service-call chat history persistence, and in-loop compaction.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||
public static HarnessAgent AsHarnessAgent(
|
||||
this IChatClient chatClient,
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
HarnessAgentOptions? options = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
|
||||
/// function invocation, per-service-call chat history persistence, and in-loop compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The underlying <see cref="ChatClientAgent"/> is configured with
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
|
||||
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
|
||||
/// to match the manually-assembled pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied, the agent defaults to an
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> whose chat reducer applies the same compaction strategy,
|
||||
/// keeping in-memory history from growing unboundedly across sessions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class HarnessAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// The built-in default system instructions used when <see cref="ChatOptions.Instructions"/> is not set.
|
||||
/// </summary>
|
||||
public const string DefaultInstructions =
|
||||
"""
|
||||
You are a helpful AI assistant that uses tools to complete tasks.
|
||||
|
||||
## General guidelines
|
||||
|
||||
- Think through the task before acting. Break complex work into clear steps.
|
||||
- Use the tools available to you to gather information, perform actions, and verify results.
|
||||
- Explain your reasoning between tool calls so the user can follow your progress.
|
||||
- If a tool call fails or returns unexpected results, adapt your approach rather than repeating the same call.
|
||||
- When you have completed the task, present a clear and concise summary of what you did and what you found.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// The agent wraps this client in a function-invocation, per-service-call persistence,
|
||||
/// and compaction pipeline automatically.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy and to limit the model's output.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="System.ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
|
||||
: base(BuildInnerAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
options))
|
||||
{
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
{
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxContextWindowTokens,
|
||||
maxOutputTokens: maxOutputTokens);
|
||||
|
||||
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
|
||||
?? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
});
|
||||
|
||||
string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions;
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens);
|
||||
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = options?.Id,
|
||||
Name = options?.Name,
|
||||
Description = options?.Description,
|
||||
ChatOptions = chatOptions,
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
AIContextProviders = options?.AIContextProviders,
|
||||
UseProvidedChatClientAsIs = true,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens)
|
||||
{
|
||||
ChatOptions result = source?.Clone() ?? new ChatOptions();
|
||||
result.Instructions = instructions;
|
||||
result.MaxOutputTokens ??= maxOutputTokens;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for a <see cref="HarnessAgent"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class HarnessAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the agent id.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the agent description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional chat options such as tools for the agent to use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use <see cref="ChatOptions.Tools"/> to supply additional tools the agent can invoke.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="ChatOptions.Instructions"/> to override the <see cref="HarnessAgent"/>'s built-in
|
||||
/// default instructions. When <see cref="ChatOptions.Instructions"/> is <see langword="null"/> or not set,
|
||||
/// the default instructions are used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ChatOptions? ChatOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>
|
||||
/// configured with a compaction-based chat reducer derived from the <c>maxContextWindowTokens</c>
|
||||
/// and <c>maxOutputTokens</c> constructor parameters of <see cref="HarnessAgent"/>.
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional <see cref="AIContextProvider"/> instances to include in the agent pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These providers are passed to the underlying <see cref="ChatClientAgent"/> via
|
||||
/// <see cref="ChatClientAgentOptions.AIContextProviders"/>.
|
||||
/// </remarks>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>false</IsReleaseCandidate>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Harness</Title>
|
||||
<Description>Provides the HarnessAgent, a pre-configured AI agent that can be used for long running tasks.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Harness.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -3,10 +3,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -32,6 +34,13 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
private readonly OpenTelemetryChatClient _otelClient;
|
||||
/// <summary>The provider name extracted from <see cref="AIAgentMetadata"/>.</summary>
|
||||
private readonly string? _providerName;
|
||||
/// <summary>The resolved source name for telemetry. Always non-empty; defaults to <see cref="OpenTelemetryConsts.DefaultSourceName"/>.</summary>
|
||||
private readonly string _sourceName;
|
||||
/// <summary>
|
||||
/// Indicates whether the underlying <see cref="IChatClient"/> of a <see cref="ChatClientAgent"/> inner agent
|
||||
/// should be automatically wrapped with <see cref="OpenTelemetryChatClient"/> on each invocation.
|
||||
/// </summary>
|
||||
private readonly bool _autoWireChatClient;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
|
||||
/// <param name="innerAgent">The underlying <see cref="AIAgent"/> to be augmented with telemetry capabilities.</param>
|
||||
@@ -44,13 +53,44 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
/// The constructor automatically extracts provider metadata from the inner agent and configures
|
||||
/// telemetry collection according to OpenTelemetry semantic conventions for AI systems.
|
||||
/// </remarks>
|
||||
public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) : base(innerAgent)
|
||||
public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null)
|
||||
#pragma warning disable MAAI001 // Auto-wiring is the new default; the experimental opt-out lives on the 3-arg overload.
|
||||
: this(innerAgent, sourceName, autoWireChatClient: true)
|
||||
#pragma warning restore MAAI001
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
|
||||
/// <param name="innerAgent">The underlying <see cref="AIAgent"/> to be augmented with telemetry capabilities.</param>
|
||||
/// <param name="sourceName">
|
||||
/// An optional source name that will be used to identify telemetry data from this agent.
|
||||
/// If not provided, a default source name will be used for telemetry identification.
|
||||
/// </param>
|
||||
/// <param name="autoWireChatClient">
|
||||
/// When <see langword="true"/> and the inner agent is a <see cref="ChatClientAgent"/>, the underlying
|
||||
/// <see cref="IChatClient"/> is automatically wrapped with <see cref="OpenTelemetryChatClient"/> for each invocation
|
||||
/// so that chat-level telemetry flows alongside agent-level telemetry. If the underlying chat client is already
|
||||
/// instrumented, no additional wrapping is applied. Set to <see langword="false"/> to opt-out of this behavior.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The constructor automatically extracts provider metadata from the inner agent and configures
|
||||
/// telemetry collection according to OpenTelemetry semantic conventions for AI systems.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent)
|
||||
{
|
||||
this._providerName = innerAgent.GetService<AIAgentMetadata>()?.ProviderName;
|
||||
|
||||
// Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner
|
||||
// OpenTelemetryChatClient always emit spans under the same ActivitySource, even when
|
||||
// the caller passes "" or whitespace (which neither client should treat as a real source).
|
||||
this._sourceName = string.IsNullOrWhiteSpace(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!;
|
||||
this._autoWireChatClient = autoWireChatClient;
|
||||
|
||||
this._otelClient = new OpenTelemetryChatClient(
|
||||
new ForwardingChatClient(this),
|
||||
sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!);
|
||||
sourceName: this._sourceName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -163,6 +203,85 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
public Activity? CurrentActivity { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
|
||||
/// <see cref="IChatClient"/> is not already instrumented with <see cref="OpenTelemetryChatClient"/>, returns a
|
||||
/// new <see cref="ChatClientAgentRunOptions"/> with a <see cref="ChatClientAgentRunOptions.ChatClientFactory"/>
|
||||
/// that wraps the chat client with <see cref="OpenTelemetryChatClient"/>. When <paramref name="options"/> is a
|
||||
/// plain <see cref="AgentRunOptions"/> (the base type, not <see cref="ChatClientAgentRunOptions"/>), the base
|
||||
/// properties are copied onto the new <see cref="ChatClientAgentRunOptions"/> so high-level callers that pass
|
||||
/// the abstract <see cref="AgentRunOptions"/> still benefit from auto-wiring and propagate their settings to
|
||||
/// the inner agent. Otherwise, returns <paramref name="options"/> unchanged.
|
||||
/// </summary>
|
||||
private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
|
||||
{
|
||||
if (!this._autoWireChatClient)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
// The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
|
||||
// Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported.
|
||||
var chatClientAgent = this.InnerAgent.GetService<ChatClientAgent>();
|
||||
if (chatClientAgent is null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
|
||||
if (chatClientAgent.GetService<ChatClientAgentOptions>()?.UseProvidedChatClientAsIs is true)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
// Capture the underlying IChatClient and check whether it is already instrumented.
|
||||
var chatClient = chatClientAgent.GetService<IChatClient>();
|
||||
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
string sourceName = this._sourceName;
|
||||
static IChatClient WrapIfNeeded(IChatClient cc, string sourceName) =>
|
||||
cc.GetService(typeof(OpenTelemetryChatClient)) is not null
|
||||
? cc
|
||||
: cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
|
||||
|
||||
if (options is ChatClientAgentRunOptions ccOptions)
|
||||
{
|
||||
// Don't mutate the caller's options; clone and chain any caller-provided factory.
|
||||
// If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap.
|
||||
var clone = (ChatClientAgentRunOptions)ccOptions.Clone();
|
||||
var userFactory = clone.ChatClientFactory;
|
||||
clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName);
|
||||
return clone;
|
||||
}
|
||||
|
||||
// For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve
|
||||
// any base AgentRunOptions properties from the caller so they reach the inner agent.
|
||||
var newOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => WrapIfNeeded(cc, sourceName),
|
||||
};
|
||||
|
||||
if (options is not null)
|
||||
{
|
||||
CopyBaseAgentRunOptions(options, newOptions);
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value.
|
||||
private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target)
|
||||
{
|
||||
target.ContinuationToken = source.ContinuationToken;
|
||||
target.AllowBackgroundResponses = source.AllowBackgroundResponses;
|
||||
target.AdditionalProperties = source.AdditionalProperties?.Clone();
|
||||
target.ResponseFormat = source.ResponseFormat;
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>The stub <see cref="IChatClient"/> used to delegate from the <see cref="OpenTelemetryChatClient"/> into the inner <see cref="AIAgent"/>.</summary>
|
||||
/// <param name="parentAgent"></param>
|
||||
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
|
||||
@@ -175,8 +294,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
|
||||
return response.AsChatResponse();
|
||||
@@ -190,8 +312,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false))
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
|
||||
yield return update.AsChatResponseUpdate();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class HarnessAgentOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that default property values are as expected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultPropertyValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new HarnessAgentOptions();
|
||||
|
||||
// Assert
|
||||
Assert.Null(options.Id);
|
||||
Assert.Null(options.Name);
|
||||
Assert.Null(options.Description);
|
||||
Assert.Null(options.ChatOptions);
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that all properties can be set and retrieved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PropertiesCanBeSetAndRetrieved()
|
||||
{
|
||||
// Arrange
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
var contextProviders = new AIContextProvider[] { new TodoProvider() };
|
||||
|
||||
// Act
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
Id = "test-id",
|
||||
Name = "test-name",
|
||||
Description = "test-description",
|
||||
ChatOptions = new() { Temperature = 0.5f, Instructions = "custom instructions" },
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
AIContextProviders = contextProviders,
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-id", options.Id);
|
||||
Assert.Equal("test-name", options.Name);
|
||||
Assert.Equal("test-description", options.Description);
|
||||
Assert.NotNull(options.ChatOptions);
|
||||
Assert.Equal(0.5f, options.ChatOptions!.Temperature);
|
||||
Assert.Equal("custom instructions", options.ChatOptions.Instructions);
|
||||
Assert.Same(chatHistoryProvider, options.ChatHistoryProvider);
|
||||
Assert.Same(contextProviders, options.AIContextProviders);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
// 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 Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class HarnessAgentTests
|
||||
{
|
||||
private const int TestMaxContextWindowTokens = 100_000;
|
||||
private const int TestMaxOutputTokens = 10_000;
|
||||
|
||||
#region Constructor Validation
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when chatClient is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!, TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when MaxContextWindowTokens is invalid (zero).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenMaxContextWindowTokensIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 0, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when MaxOutputTokens equals MaxContextWindowTokens.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ThrowsWhenMaxOutputTokensEqualsContextWindow()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 100_000, 100_000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWhenOptionsIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Agent Identity
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Name and Description are passed through to the inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NameAndDescription_ArePassedThrough()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "TestAgent",
|
||||
Description = "A test agent",
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("A test agent", agent.Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Id is passed through to the inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_IsPassedThrough()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Id = "my-agent-id",
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-agent-id", agent.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Instructions
|
||||
|
||||
/// <summary>
|
||||
/// Verify that default instructions are used when none are provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Instructions_DefaultsToBuiltInInstructions()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Equal(HarnessAgent.DefaultInstructions, innerAgent!.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that default instructions are used when options is provided but ChatOptions.Instructions is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Instructions_DefaultsWhenChatOptionsInstructionsIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { Temperature = 0.5f },
|
||||
});
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Equal(HarnessAgent.DefaultInstructions, innerAgent!.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions.Instructions overrides the defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Instructions_CanBeOverriddenViaChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." },
|
||||
});
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Equal("You are a custom assistant.", innerAgent!.Instructions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the default ChatHistoryProvider is InMemoryChatHistoryProvider when none is specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatHistoryProvider_DefaultsToInMemory()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.IsType<InMemoryChatHistoryProvider>(innerAgent!.ChatHistoryProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a custom ChatHistoryProvider is used when provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatHistoryProvider_UsesCustomProviderWhenSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var customProvider = new InMemoryChatHistoryProvider();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
ChatHistoryProvider = customProvider,
|
||||
});
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Same(customProvider, innerAgent!.ChatHistoryProvider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatClient Pipeline
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the inner agent's ChatClient includes FunctionInvokingChatClient in the pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pipeline_IncludesFunctionInvokingChatClient()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(ficc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the inner agent's ChatClient pipeline includes more than just the raw chat client,
|
||||
/// confirming that per-service-call persistence and other decorators have been applied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pipeline_HasDecoratedChatClient()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
var rawClient = mockClient.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.NotSame(rawClient, innerAgent!.ChatClient);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIContextProviders
|
||||
|
||||
/// <summary>
|
||||
/// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent,
|
||||
/// not merged into the chat client builder pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AIContextProviders_ArePassedToInnerAgent()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var todoProvider = new TodoProvider();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
AIContextProviders = [todoProvider],
|
||||
});
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the TodoProvider should appear in the inner agent's AIContextProviders.
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.NotNull(innerAgent!.AIContextProviders);
|
||||
Assert.Contains(todoProvider, innerAgent.AIContextProviders!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when no AIContextProviders are specified, the inner agent has no additional providers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AIContextProviders_IsNullWhenNoneSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Null(innerAgent!.AIContextProviders);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatOptions and Tools
|
||||
|
||||
/// <summary>
|
||||
/// Verify that tools from ChatOptions are passed to the model during invocation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptions_ToolsArePreservedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "test", "TestTool");
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
ChatOptions? capturedOptions = null;
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Tools = [tool],
|
||||
},
|
||||
});
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — verify the tool was included in the ChatOptions passed to the model.
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions!.Tools);
|
||||
Assert.Contains(capturedOptions.Tools, t => t == tool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the source ChatOptions are cloned and not modified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatOptions_SourceIsNotModified()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var sourceChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "original instructions",
|
||||
Temperature = 0.7f,
|
||||
};
|
||||
|
||||
// Act
|
||||
_ = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = sourceChatOptions,
|
||||
});
|
||||
|
||||
// Assert — source ChatOptions should not be mutated.
|
||||
Assert.Equal("original instructions", sourceChatOptions.Instructions);
|
||||
Assert.Equal(0.7f, sourceChatOptions.Temperature);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the HarnessAgent for its own type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsSelfForHarnessAgentType()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.Same(agent, agent.GetService<HarnessAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the inner ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsInnerChatClientAgent()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ChatClientAgent>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync Delegation
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync delegates to the inner ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DelegatesToInnerAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(
|
||||
[new ChatMessage(ChatRole.User, "Hi")],
|
||||
session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.True(response.Messages.Any());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DefaultInstructions
|
||||
|
||||
/// <summary>
|
||||
/// Verify that DefaultInstructions is a non-empty public constant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultInstructions_IsNonEmpty()
|
||||
{
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(HarnessAgent.DefaultInstructions));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsHarnessAgent Extension Method
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsHarnessAgent creates a HarnessAgent with default options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsHarnessAgent_CreatesAgentWithDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<HarnessAgent>(agent);
|
||||
Assert.Equal(HarnessAgent.DefaultInstructions, agent.GetService<ChatClientAgent>()!.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsHarnessAgent passes options through to the HarnessAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsHarnessAgent_PassesOptionsThrough()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "ExtensionAgent",
|
||||
ChatOptions = new ChatOptions { Instructions = "Custom instructions" },
|
||||
});
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ExtensionAgent", agent.Name);
|
||||
Assert.NotNull(innerAgent);
|
||||
Assert.Equal("Custom instructions", innerAgent!.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsHarnessAgent throws when chatClient is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsHarnessAgent_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+37
-1
@@ -267,7 +267,43 @@ public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDispo
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
||||
|
||||
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("agent.name", responseJson, StringComparison.OrdinalIgnoreCase);
|
||||
using JsonDocument errorDoc1 = JsonDocument.Parse(responseJson);
|
||||
string? errorCode = errorDoc1.RootElement.GetProperty("error").GetProperty("code").GetString();
|
||||
Assert.Equal("missing_required_parameter", errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the model field alone is not used for agent resolution.
|
||||
/// The multi-agent endpoint requires agent.name or metadata.entity_id; setting only model returns 400.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateResponse_WithModelOnly_ReturnsBadRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "test-agent";
|
||||
|
||||
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
||||
(AgentName, "Instructions", "Response"));
|
||||
|
||||
// Act - Send request with model=agentName but no agent.name or metadata.entity_id
|
||||
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
||||
{
|
||||
model = AgentName,
|
||||
input = new[]
|
||||
{
|
||||
new { type = "message", role = "user", content = "Test message" }
|
||||
}
|
||||
}), Encoding.UTF8, "application/json");
|
||||
|
||||
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
||||
|
||||
// Assert - model is not used for agent resolution
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
||||
|
||||
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
||||
using JsonDocument errorDoc2 = JsonDocument.Parse(responseJson);
|
||||
string? errorCode = errorDoc2.RootElement.GetProperty("error").GetProperty("code").GetString();
|
||||
Assert.Equal("missing_required_parameter", errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -627,4 +627,455 @@ public class OpenTelemetryAgentTests
|
||||
}
|
||||
|
||||
private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", "").Trim();
|
||||
|
||||
#region AutoWireChatClient
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_DefaultsToEnabled_EmitsChatSpan_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
// Expect 2 activities: the inner chat span (from auto-wired OpenTelemetryChatClient) and the invoke_agent span.
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_Streaming_EmitsChatSpan_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
await foreach (var _ in agent.RunStreamingAsync("hi"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName, autoWireChatClient: false);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
// Only the invoke_agent activity should be emitted; no chat span.
|
||||
var activity = Assert.Single(activities);
|
||||
Assert.StartsWith("invoke_agent", activity.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_NonChatClientAgent_NoOp_Async()
|
||||
{
|
||||
// Inner is not a ChatClientAgent — auto-wiring must be a no-op and options must remain null.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var inner = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = (messages, session, options, ct) =>
|
||||
{
|
||||
observedOptions = options;
|
||||
return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok")));
|
||||
},
|
||||
};
|
||||
|
||||
using var agent = new OpenTelemetryAgent(inner);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
Assert.Null(observedOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_UseProvidedChatClientAsIs_DoesNotEmitChatSpan_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions { UseProvidedChatClientAsIs = true });
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
// UseProvidedChatClientAsIs opts out of auto-wiring, so only the invoke_agent span should be emitted.
|
||||
var activity = Assert.Single(activities);
|
||||
Assert.StartsWith("invoke_agent", activity.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_AlreadyInstrumented_DoesNotDoubleWrap_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
// Pre-wrap with OpenTelemetryChatClient on the same source so spans flow through the tracer.
|
||||
IChatClient preWrapped = fakeChatClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
|
||||
var inner = new ChatClientAgent(preWrapped);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
// Expect exactly 2 activities (one invoke_agent + one chat from the pre-existing wrapper). If we had double-wrapped, we would see 3.
|
||||
Assert.Equal(2, activities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
bool userFactoryCalled = false;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc =>
|
||||
{
|
||||
userFactoryCalled = true;
|
||||
return cc;
|
||||
},
|
||||
};
|
||||
|
||||
_ = await agent.RunAsync("hi", options: runOptions);
|
||||
|
||||
Assert.True(userFactoryCalled);
|
||||
// Auto-wiring should still produce a chat span on top of the user's factory.
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async()
|
||||
{
|
||||
// Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base
|
||||
// properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat)
|
||||
// must be preserved so they reach the inner agent.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
|
||||
// Wrapping agent: surfaces the ChatClientAgent via GetService (so auto-wiring activates),
|
||||
// but captures the AgentRunOptions passed to RunAsync by the OpenTelemetryAgent.
|
||||
var wrapper = new TestAIAgent
|
||||
{
|
||||
GetServiceFunc = (type, key) =>
|
||||
type == typeof(ChatClientAgent) ? innerChatClientAgent : null,
|
||||
RunAsyncFunc = (messages, session, options, ct) =>
|
||||
{
|
||||
observedOptions = options;
|
||||
return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok")));
|
||||
},
|
||||
};
|
||||
|
||||
using var agent = new OpenTelemetryAgent(wrapper);
|
||||
|
||||
var additionalProps = new AdditionalPropertiesDictionary { ["customKey"] = "customValue" };
|
||||
var inputOptions = new AgentRunOptions
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
AdditionalProperties = additionalProps,
|
||||
ResponseFormat = ChatResponseFormat.Json,
|
||||
};
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Equal(true, observedOptions!.AllowBackgroundResponses);
|
||||
Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat);
|
||||
Assert.NotNull(observedOptions.AdditionalProperties);
|
||||
Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
// User factory wraps the chat client with OpenTelemetryChatClient itself.
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
|
||||
};
|
||||
|
||||
_ = await agent.RunAsync("hi", options: runOptions);
|
||||
|
||||
// Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3.
|
||||
Assert.Equal(2, activities.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public async Task Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefaultSource_Async(string? sourceName)
|
||||
{
|
||||
// Both the agent-level invoke_agent span and the auto-wired chat span must be emitted under
|
||||
// OpenTelemetryConsts.DefaultSourceName when the caller passes null, "", or whitespace, so they reach
|
||||
// the same ActivitySource and are not silently dropped by the exporter.
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource("Experimental.Microsoft.Agents.AI")
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("hi");
|
||||
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Agents.AI", a.Source.Name));
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // ResponseContinuationToken is experimental.
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async()
|
||||
{
|
||||
// ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions
|
||||
// and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
|
||||
var wrapper = new TestAIAgent
|
||||
{
|
||||
GetServiceFunc = (type, key) =>
|
||||
type == typeof(ChatClientAgent) ? innerChatClientAgent : null,
|
||||
RunAsyncFunc = (messages, session, options, ct) =>
|
||||
{
|
||||
observedOptions = options;
|
||||
return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok")));
|
||||
},
|
||||
};
|
||||
|
||||
using var agent = new OpenTelemetryAgent(wrapper);
|
||||
|
||||
var token = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var inputOptions = new AgentRunOptions
|
||||
{
|
||||
ContinuationToken = token,
|
||||
};
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Same(token, observedOptions!.ContinuationToken);
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_ChatClientAgentRunOptions_NoUserFactory_PreservesChatOptions_Async()
|
||||
{
|
||||
// When the caller passes a ChatClientAgentRunOptions without a ChatClientFactory, the auto-wiring
|
||||
// must clone (not mutate) the caller's options, set the factory, and preserve nested ChatOptions.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
ChatOptions? observedChatOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient
|
||||
{
|
||||
OnGetResponseAsync = (msgs, opts) => observedChatOptions = opts,
|
||||
};
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
var inputChatOptions = new ChatOptions { Temperature = 0.42f, ModelId = "test-model" };
|
||||
var inputOptions = new ChatClientAgentRunOptions(inputChatOptions);
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
// Caller's options must not have been mutated (no factory installed on the caller's instance).
|
||||
Assert.Null(inputOptions.ChatClientFactory);
|
||||
|
||||
// Inner chat client must observe the caller-supplied ChatOptions.
|
||||
Assert.NotNull(observedChatOptions);
|
||||
Assert.Equal(0.42f, observedChatOptions!.Temperature);
|
||||
Assert.Equal("test-model", observedChatOptions.ModelId);
|
||||
|
||||
// Auto-wiring still produces a chat span.
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_StreamingDisabled_DoesNotEmitChatSpan_Async()
|
||||
{
|
||||
// Symmetry with AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async for the streaming path.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName, autoWireChatClient: false);
|
||||
|
||||
await foreach (var _ in agent.RunStreamingAsync("hi"))
|
||||
{
|
||||
}
|
||||
|
||||
var activity = Assert.Single(activities);
|
||||
Assert.StartsWith("invoke_agent", activity.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_EmitsChatSpan_Async()
|
||||
{
|
||||
// High-level callers may pass the abstract base AgentRunOptions (not ChatClientAgentRunOptions) when
|
||||
// wiring a ChatClientAgent. Auto-wiring must still kick in: convert to ChatClientAgentRunOptions,
|
||||
// install the OTel-wrapping factory, and produce both the invoke_agent and chat spans end-to-end.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
ChatOptions? observedChatOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient
|
||||
{
|
||||
OnGetResponseAsync = (_, opts) => observedChatOptions = opts,
|
||||
};
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
// Pass the base AgentRunOptions, not ChatClientAgentRunOptions.
|
||||
var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false };
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
// Inner chat client was actually invoked (auto-wired factory ran without breaking the pipeline).
|
||||
Assert.NotNull(observedChatOptions);
|
||||
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_StreamingEmitsChatSpan_Async()
|
||||
{
|
||||
// Same as the sync test above but for the streaming path so both invocation paths
|
||||
// are covered when callers pass a base AgentRunOptions.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
ChatOptions? observedChatOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient
|
||||
{
|
||||
OnGetResponseAsync = (_, opts) => observedChatOptions = opts,
|
||||
};
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false };
|
||||
|
||||
await foreach (var _ in agent.RunStreamingAsync("hi", options: inputOptions))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(observedChatOptions);
|
||||
|
||||
Assert.Equal(2, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class AutoWireTestChatClient : IChatClient
|
||||
{
|
||||
public Action<IEnumerable<ChatMessage>, ChatOptions?>? OnGetResponseAsync { get; set; }
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.OnGetResponseAsync?.Invoke(messages, options);
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok")));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.OnGetResponseAsync?.Invoke(messages, options);
|
||||
await Task.Yield();
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, "ok");
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType?.IsInstanceOfType(this) == true ? this : null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -99,6 +99,30 @@ The `AGUIChatClient` supports:
|
||||
- Integration with `Agent` for client-side history management
|
||||
- Interrupt metadata passthrough (`availableInterrupts` and `resume`)
|
||||
|
||||
## Tool Return Helpers
|
||||
|
||||
Use `state_update` when a backend tool needs to send different payloads to the model, the UI, and shared state. The `text` value remains the LLM-bound tool result, `tool_result` becomes the AG-UI `ToolCallResultEvent.content` for frontend rendering, and `state` is merged into durable shared state.
|
||||
|
||||
```python
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework.ag_ui import state_update
|
||||
|
||||
@tool
|
||||
async def get_weather(city: str) -> Content:
|
||||
data = await fetch_weather(city)
|
||||
return state_update(
|
||||
text=f"{city}: {data['temp']}°C and {data['conditions']}",
|
||||
tool_result={
|
||||
"component": "weather-card",
|
||||
"city": city,
|
||||
"temperature": data["temp"],
|
||||
"conditions": data["conditions"],
|
||||
"humidity": data["humidity"],
|
||||
},
|
||||
state={"weather": {"city": city, **data}},
|
||||
)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients
|
||||
|
||||
@@ -49,8 +49,11 @@ from ._run_common import (
|
||||
_close_reasoning_block, # type: ignore
|
||||
_emit_content, # type: ignore
|
||||
_extract_resume_payload, # type: ignore
|
||||
_extract_tool_result_display, # type: ignore
|
||||
_has_only_tool_calls, # type: ignore
|
||||
_normalize_resume_interrupts, # type: ignore
|
||||
_resolve_ui_payload, # type: ignore
|
||||
_stringify_tool_result, # type: ignore
|
||||
)
|
||||
from ._utils import (
|
||||
convert_agui_tools_to_agent_framework,
|
||||
@@ -381,17 +384,23 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]:
|
||||
|
||||
|
||||
def _make_approval_tool_result_events(resolved_approval_results: list[Content]) -> list[ToolCallResultEvent]:
|
||||
"""Build TOOL_CALL_RESULT events for tools executed during approval resolution."""
|
||||
"""Build TOOL_CALL_RESULT events for tools executed during approval resolution.
|
||||
|
||||
Honors ``TOOL_RESULT_DISPLAY_KEY`` so tools returning
|
||||
``state_update(..., tool_result=...)`` route the display payload to the UI
|
||||
event even when gated by HITL approval.
|
||||
"""
|
||||
events: list[ToolCallResultEvent] = []
|
||||
for resolved in resolved_approval_results:
|
||||
if resolved.call_id:
|
||||
raw = resolved.result if resolved.result is not None else ""
|
||||
result_str = raw if isinstance(raw, str) else json.dumps(make_json_safe(raw))
|
||||
llm_str = _stringify_tool_result(raw)
|
||||
ui_str = _resolve_ui_payload(llm_str, _extract_tool_result_display(resolved))
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=generate_event_id(),
|
||||
tool_call_id=resolved.call_id,
|
||||
content=result_str,
|
||||
content=ui_str,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -32,11 +32,14 @@ from ag_ui.core import (
|
||||
from agent_framework import Content
|
||||
|
||||
from ._orchestration._predictive_state import PredictiveStateHandler
|
||||
from ._state import TOOL_RESULT_STATE_KEY
|
||||
from ._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY
|
||||
from ._utils import generate_event_id, make_json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel for an unset display_result; distinguishes "caller didn't pass" from None/{}/"".
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _has_only_tool_calls(contents: list[Any]) -> bool:
|
||||
"""Check if contents have only tool calls (no text)."""
|
||||
@@ -235,6 +238,22 @@ def _emit_tool_call(
|
||||
return events
|
||||
|
||||
|
||||
def _extract_tool_result_marker_values(content: Content, key: str) -> list[Any]:
|
||||
"""Extract marker values from outer and inner tool-result content."""
|
||||
values: list[Any] = []
|
||||
|
||||
outer_ap = getattr(content, "additional_properties", None) or {}
|
||||
if key in outer_ap:
|
||||
values.append(outer_ap[key])
|
||||
|
||||
for item in content.items or ():
|
||||
item_ap = getattr(item, "additional_properties", None) or {}
|
||||
if key in item_ap:
|
||||
values.append(item_ap[key])
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
|
||||
"""Extract a deterministic AG-UI state update from a tool-result ``Content``.
|
||||
|
||||
@@ -252,14 +271,7 @@ def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
|
||||
"""
|
||||
merged: dict[str, Any] | None = None
|
||||
|
||||
outer_ap = getattr(content, "additional_properties", None) or {}
|
||||
outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY)
|
||||
if isinstance(outer_state, dict):
|
||||
merged = dict(outer_state)
|
||||
|
||||
for item in content.items or ():
|
||||
item_ap = getattr(item, "additional_properties", None) or {}
|
||||
item_state = item_ap.get(TOOL_RESULT_STATE_KEY)
|
||||
for item_state in _extract_tool_result_marker_values(content, TOOL_RESULT_STATE_KEY):
|
||||
if isinstance(item_state, dict):
|
||||
if merged is None:
|
||||
merged = dict(item_state)
|
||||
@@ -269,6 +281,21 @@ def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
|
||||
return merged
|
||||
|
||||
|
||||
def _extract_tool_result_display(content: Content) -> Any: # noqa: ANN401
|
||||
"""Extract a UI-only AG-UI tool result display payload, if present."""
|
||||
display_values = _extract_tool_result_marker_values(content, TOOL_RESULT_DISPLAY_KEY)
|
||||
return display_values[-1] if display_values else _UNSET
|
||||
|
||||
|
||||
def _stringify_tool_result(raw_result: Any) -> str: # noqa: ANN401
|
||||
return raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result))
|
||||
|
||||
|
||||
def _resolve_ui_payload(llm_str: str, display_result: Any) -> str: # noqa: ANN401
|
||||
"""Pick the UI-bound string: the serialized display payload when set, else the LLM string."""
|
||||
return llm_str if display_result is _UNSET else _stringify_tool_result(display_result)
|
||||
|
||||
|
||||
def _emit_tool_result_common(
|
||||
call_id: str,
|
||||
raw_result: Any,
|
||||
@@ -276,6 +303,7 @@ def _emit_tool_result_common(
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
*,
|
||||
state_update: Mapping[str, Any] | None = None,
|
||||
display_result: Any = _UNSET, # noqa: ANN401
|
||||
) -> list[BaseEvent]:
|
||||
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
|
||||
|
||||
@@ -301,13 +329,14 @@ def _emit_tool_result_common(
|
||||
events.append(ToolCallEndEvent(tool_call_id=call_id))
|
||||
flow.tool_calls_ended.add(call_id)
|
||||
|
||||
result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result))
|
||||
result_content = _stringify_tool_result(raw_result)
|
||||
ui_result_content = _resolve_ui_payload(result_content, display_result)
|
||||
message_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=message_id,
|
||||
tool_call_id=call_id,
|
||||
content=result_content,
|
||||
content=ui_result_content,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
@@ -358,12 +387,14 @@ def _emit_tool_result(
|
||||
return []
|
||||
raw_result = content.result if content.result is not None else ""
|
||||
state_update = _extract_tool_result_state(content)
|
||||
display_result = _extract_tool_result_display(content)
|
||||
return _emit_tool_result_common(
|
||||
content.call_id,
|
||||
raw_result,
|
||||
flow,
|
||||
predictive_handler,
|
||||
state_update=state_update,
|
||||
display_result=display_result,
|
||||
)
|
||||
|
||||
|
||||
@@ -530,12 +561,14 @@ def _emit_mcp_tool_result(
|
||||
return []
|
||||
raw_output = content.output if content.output is not None else ""
|
||||
state_update = _extract_tool_result_state(content)
|
||||
display_result = _extract_tool_result_display(content)
|
||||
return _emit_tool_result_common(
|
||||
content.call_id,
|
||||
raw_output,
|
||||
flow,
|
||||
predictive_handler,
|
||||
state_update=state_update,
|
||||
display_result=display_result,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Deterministic tool-driven AG-UI state updates.
|
||||
"""Deterministic tool-driven AG-UI state updates and display payloads.
|
||||
|
||||
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
|
||||
deterministic state update by returning :func:`state_update`. Unlike
|
||||
``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from
|
||||
LLM-predicted tool call arguments — ``state_update`` runs *after* the tool
|
||||
executes, so the AG-UI state always reflects the tool's actual return value.
|
||||
deterministic state update or a per-call tool result display payload by
|
||||
returning :func:`state_update`. Unlike ``predict_state_config`` — which emits
|
||||
``StateDeltaEvent``s optimistically from LLM-predicted tool call arguments —
|
||||
``state_update`` runs *after* the tool executes, so AG-UI state and display
|
||||
content always reflect the tool's actual return value.
|
||||
|
||||
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
|
||||
motivating discussion.
|
||||
@@ -14,33 +15,48 @@ motivating discussion.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"]
|
||||
from ._utils import make_json_safe
|
||||
|
||||
__all__ = ["TOOL_RESULT_DISPLAY_KEY", "TOOL_RESULT_STATE_KEY", "state_update"]
|
||||
|
||||
|
||||
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
|
||||
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
|
||||
state snapshot from a tool return value through to the AG-UI emitter."""
|
||||
|
||||
TOOL_RESULT_DISPLAY_KEY = "__ag_ui_tool_result_display__"
|
||||
"""Reserved ``Content.additional_properties`` key used to carry UI-only tool result display content from a tool return value through to the AG-UI emitter."""
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _serialize_tool_result(value: Any) -> str: # noqa: ANN401
|
||||
return value if isinstance(value, str) else json.dumps(make_json_safe(value))
|
||||
|
||||
|
||||
def state_update(
|
||||
text: str = "",
|
||||
*,
|
||||
state: Mapping[str, Any],
|
||||
state: Mapping[str, Any] | None = None,
|
||||
tool_result: Any = _UNSET, # noqa: ANN401
|
||||
) -> Content:
|
||||
"""Build a tool return value that deterministically updates AG-UI shared state.
|
||||
"""Build a tool return value that updates AG-UI shared state or display content.
|
||||
|
||||
Return the result of this helper from an agent tool to push a state update
|
||||
to AG-UI clients using the actual tool output, rather than LLM-predicted
|
||||
tool arguments.
|
||||
or UI-only display payload to AG-UI clients using the actual tool output,
|
||||
rather than LLM-predicted tool arguments.
|
||||
|
||||
When the AG-UI endpoint emits the tool result, it will:
|
||||
|
||||
* Forward ``text`` to the LLM as the normal ``function_result`` content.
|
||||
* Use ``tool_result`` as the ``ToolCallResultEvent.content`` payload shown
|
||||
to AG-UI clients, falling back to ``text`` when no display payload is set.
|
||||
* Merge ``state`` into ``FlowState.current_state``.
|
||||
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
|
||||
event so frontends observe the updated state deterministically. If
|
||||
@@ -49,7 +65,7 @@ def state_update(
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
|
||||
@@ -61,24 +77,61 @@ def state_update(
|
||||
state={"weather": {"city": city, **data}},
|
||||
)
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework_ag_ui import state_update
|
||||
|
||||
|
||||
@tool
|
||||
async def get_weather(city: str) -> Content:
|
||||
data = await _fetch_weather(city)
|
||||
return state_update(
|
||||
text=f"{city}: {data['temp']}°C and {data['conditions']}",
|
||||
tool_result={
|
||||
"component": "weather-card",
|
||||
"city": city,
|
||||
"temperature": data["temp"],
|
||||
"conditions": data["conditions"],
|
||||
"humidity": data["humidity"],
|
||||
},
|
||||
state={"weather": {"city": city, **data}},
|
||||
)
|
||||
|
||||
Args:
|
||||
text: Text passed back to the LLM as the ``function_result`` content.
|
||||
Defaults to an empty string for tools whose only output is a state
|
||||
update.
|
||||
state: A mapping merged into the AG-UI shared state via JSON-compatible
|
||||
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
|
||||
tool_result: JSON-safe payload emitted to AG-UI clients as
|
||||
``ToolCallResultEvent.content`` for frontend rendering. The LLM
|
||||
still receives ``text``. If ``text`` is empty, the serialized
|
||||
display payload is also used as the LLM-bound text fallback.
|
||||
|
||||
Returns:
|
||||
A ``Content`` object with ``type="text"``. The state payload rides in
|
||||
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is
|
||||
extracted by the AG-UI emitter.
|
||||
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY`
|
||||
(``"__ag_ui_tool_result_state__"``), and the display payload rides
|
||||
under :data:`TOOL_RESULT_DISPLAY_KEY`
|
||||
(``"__ag_ui_tool_result_display__"``). Both reserved keys are extracted
|
||||
by the AG-UI emitter.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``state`` is not a ``Mapping``.
|
||||
"""
|
||||
if not isinstance(state, Mapping):
|
||||
if state is not None and not isinstance(state, Mapping):
|
||||
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
|
||||
additional_properties: dict[str, Any] = {}
|
||||
if state is not None:
|
||||
additional_properties[TOOL_RESULT_STATE_KEY] = dict(state)
|
||||
if tool_result is not _UNSET:
|
||||
display_content = _serialize_tool_result(tool_result)
|
||||
additional_properties[TOOL_RESULT_DISPLAY_KEY] = display_content
|
||||
if not text:
|
||||
text = display_content
|
||||
return Content.from_text(
|
||||
text,
|
||||
additional_properties={TOOL_RESULT_STATE_KEY: dict(state)},
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
@@ -68,6 +68,19 @@ def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> A
|
||||
)
|
||||
|
||||
|
||||
def _tool_result_with_display(call_id: str, text: str, tool_result: Any, **kwargs: Any) -> AgentResponseUpdate:
|
||||
"""Build a function_result update carrying an optional UI display marker."""
|
||||
return AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_result(
|
||||
call_id=call_id,
|
||||
result=[state_update(text=text, tool_result=tool_result, **kwargs)],
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
@@ -265,3 +278,87 @@ async def test_deterministic_state_coexists_with_predict_state_config() -> None:
|
||||
# The final observed state must contain both the deterministic and predictive contributions.
|
||||
final = stream.snapshot()
|
||||
assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}"
|
||||
|
||||
|
||||
async def test_tool_result_display_payload_reaches_ui_event_only() -> None:
|
||||
"""Rich display payload overrides TOOL_CALL_RESULT without leaking marker keys."""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_display(
|
||||
"call-1",
|
||||
text="Weather in SF: 14°C foggy",
|
||||
tool_result={"city": "SF", "temp": 14, "conditions": "foggy"},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}'
|
||||
assert "__ag_ui_tool_result_display__" not in result.content
|
||||
assert "__ag_ui_tool_result_state__" not in result.content
|
||||
|
||||
|
||||
async def test_tool_result_display_falls_back_to_text_when_unset() -> None:
|
||||
"""Without a display marker, the UI event keeps the existing text content."""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_state(
|
||||
"call-1",
|
||||
text="Weather in SF: 14°C foggy",
|
||||
state={"weather": {"city": "SF", "temp": 14}},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert result.content == "Weather in SF: 14°C foggy"
|
||||
assert "__ag_ui_tool_result_display__" not in result.content
|
||||
assert "__ag_ui_tool_result_state__" not in result.content
|
||||
|
||||
|
||||
async def test_tool_result_display_coexists_with_state_snapshot() -> None:
|
||||
"""Display and durable state markers produce one deterministic state snapshot."""
|
||||
updates = [
|
||||
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
|
||||
_tool_result_with_display(
|
||||
"call-1",
|
||||
text="Weather in SF: 14°C foggy",
|
||||
tool_result={"city": "SF", "temp": 14, "conditions": "foggy"},
|
||||
state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}},
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_ordered_types(["TOOL_CALL_RESULT", "STATE_SNAPSHOT", "RUN_FINISHED"])
|
||||
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}'
|
||||
|
||||
result_idx = stream.events.index(result)
|
||||
deterministic_snapshots = [
|
||||
event
|
||||
for event in stream.events[result_idx + 1 :]
|
||||
if getattr(getattr(event, "type", None), "value", getattr(event, "type", None)) == "STATE_SNAPSHOT"
|
||||
]
|
||||
assert len(deterministic_snapshots) == 1
|
||||
assert deterministic_snapshots[0].snapshot["weather"] == {
|
||||
"city": "SF",
|
||||
"temp": 14,
|
||||
"conditions": "foggy",
|
||||
}
|
||||
assert "__ag_ui_tool_result_display__" not in str(deterministic_snapshots[0].snapshot)
|
||||
assert "__ag_ui_tool_result_state__" not in str(deterministic_snapshots[0].snapshot)
|
||||
|
||||
@@ -448,3 +448,37 @@ async def test_resolve_approval_responses_returns_only_approved() -> None:
|
||||
rejection_results = [c for c in all_contents if c.type == "function_result" and c.call_id == rejected_call_id]
|
||||
assert len(rejection_results) == 1
|
||||
assert "rejected" in str(rejection_results[0].result).lower()
|
||||
|
||||
|
||||
class TestApprovalToolResultDisplayChannel:
|
||||
"""Approved tools using ``state_update(..., tool_result=...)`` must route the
|
||||
display payload to the UI event while ``flow.tool_results`` still receives
|
||||
the LLM-bound text. The HITL approval emitter is separate from the standard
|
||||
streaming emitter, so it gets its own coverage.
|
||||
"""
|
||||
|
||||
def test_approval_emits_display_payload_when_marker_present(self) -> None:
|
||||
from agent_framework_ag_ui import state_update
|
||||
from agent_framework_ag_ui._agent_run import _make_approval_tool_result_events
|
||||
|
||||
display_payload = {"city": "Seattle", "temp": 14, "conditions": "foggy"}
|
||||
inner = state_update(text="14°C, foggy", tool_result=display_payload)
|
||||
resolved = Content.from_function_result(call_id="call_disp", result=[inner])
|
||||
|
||||
events = _make_approval_tool_result_events([resolved])
|
||||
|
||||
assert len(events) == 1
|
||||
# UI event must carry the serialized display payload, NOT the LLM text.
|
||||
assert json.loads(events[0].content) == display_payload
|
||||
assert events[0].content != "14°C, foggy"
|
||||
|
||||
def test_approval_falls_back_to_text_when_no_marker(self) -> None:
|
||||
"""Backward compat: without a display marker, behaviour is unchanged."""
|
||||
from agent_framework_ag_ui._agent_run import _make_approval_tool_result_events
|
||||
|
||||
resolved = Content.from_function_result(call_id="call_plain", result="Sunny in Seattle")
|
||||
|
||||
events = _make_approval_tool_result_events([resolved])
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].content == "Sunny in Seattle"
|
||||
|
||||
@@ -15,7 +15,7 @@ from agent_framework_ag_ui._run_common import (
|
||||
_extract_tool_result_state,
|
||||
_normalize_resume_interrupts,
|
||||
)
|
||||
from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY
|
||||
from agent_framework_ag_ui._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY
|
||||
|
||||
|
||||
class TestNormalizeResumeInterrupts:
|
||||
@@ -140,6 +140,15 @@ class TestStateUpdateHelper:
|
||||
TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}},
|
||||
}
|
||||
|
||||
def test_builds_text_content_with_display_marker(self):
|
||||
"""state_update can carry a UI display payload without requiring state."""
|
||||
c = state_update(text="14°C, foggy", tool_result={"temp": 14, "conditions": "foggy"})
|
||||
assert c.type == "text"
|
||||
assert c.text == "14°C, foggy"
|
||||
assert c.additional_properties == {
|
||||
TOOL_RESULT_DISPLAY_KEY: '{"temp": 14, "conditions": "foggy"}',
|
||||
}
|
||||
|
||||
def test_empty_text_is_allowed(self):
|
||||
"""State-only tools can omit the text argument."""
|
||||
c = state_update(state={"steps": ["a", "b"]})
|
||||
@@ -165,6 +174,18 @@ class TestStateUpdateHelper:
|
||||
inner = c.additional_properties[TOOL_RESULT_STATE_KEY]
|
||||
assert inner is not caller_state
|
||||
|
||||
def test_tool_result_without_text_falls_back_to_display_payload(self):
|
||||
"""Display-only tools use the serialized display payload as LLM text."""
|
||||
c = state_update(tool_result={"temp": 14, "conditions": "foggy"})
|
||||
assert c.text == '{"temp": 14, "conditions": "foggy"}'
|
||||
assert c.additional_properties[TOOL_RESULT_DISPLAY_KEY] == '{"temp": 14, "conditions": "foggy"}'
|
||||
|
||||
def test_string_tool_result_is_not_json_encoded_again(self):
|
||||
"""A pre-serialized display string passes through verbatim."""
|
||||
c = state_update(text="Weather summary", tool_result='{"temp":14}')
|
||||
assert c.text == "Weather summary"
|
||||
assert c.additional_properties[TOOL_RESULT_DISPLAY_KEY] == '{"temp":14}'
|
||||
|
||||
|
||||
class TestExtractToolResultState:
|
||||
"""Tests for ``_extract_tool_result_state``."""
|
||||
@@ -265,6 +286,60 @@ class TestEmitToolResultWithState:
|
||||
assert result_events[0].content == "Weather: 14°C"
|
||||
assert TOOL_RESULT_STATE_KEY not in result_events[0].content
|
||||
|
||||
def test_display_payload_routes_to_ui_only(self):
|
||||
"""A display marker overrides only the UI event, not the LLM-bound tool result."""
|
||||
tool_return = state_update(
|
||||
text="Weather: 14°C",
|
||||
tool_result={"temp": 14, "conditions": "foggy"},
|
||||
)
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
|
||||
assert len(result_events) == 1
|
||||
assert result_events[0].content == '{"temp": 14, "conditions": "foggy"}'
|
||||
assert flow.tool_results[-1]["content"] == "Weather: 14°C"
|
||||
assert TOOL_RESULT_DISPLAY_KEY not in result_events[0].content
|
||||
assert TOOL_RESULT_DISPLAY_KEY not in flow.tool_results[-1]["content"]
|
||||
|
||||
def test_plain_tool_result_uses_existing_content_for_both_channels(self):
|
||||
"""Without a display marker, UI and LLM channels keep the existing derivation."""
|
||||
content = Content.from_function_result(call_id="c1", result="plain result")
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
|
||||
assert len(result_events) == 1
|
||||
assert result_events[0].content == "plain result"
|
||||
assert flow.tool_results[-1]["content"] == "plain result"
|
||||
|
||||
def test_display_only_payload_falls_back_to_llm_content(self):
|
||||
"""When text is empty, both channels receive the serialized display payload."""
|
||||
tool_return = state_update(tool_result={"temp": 14})
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
|
||||
assert result_events[0].content == '{"temp": 14}'
|
||||
assert flow.tool_results[-1]["content"] == '{"temp": 14}'
|
||||
|
||||
def test_pre_serialized_display_string_routes_verbatim(self):
|
||||
"""String display payloads pass through without JSON double-encoding."""
|
||||
tool_return = state_update(text="Weather summary", tool_result='{"temp":14}')
|
||||
content = Content.from_function_result(call_id="c1", result=[tool_return])
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
|
||||
assert result_events[0].content == '{"temp":14}'
|
||||
assert flow.tool_results[-1]["content"] == "Weather summary"
|
||||
|
||||
def test_coexists_with_active_predictive_state_handler(self):
|
||||
"""Both predictive and deterministic state produce a single coalesced snapshot.
|
||||
|
||||
@@ -346,3 +421,31 @@ class TestEmitMcpToolResultWithState:
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
assert all(e.type != EventType.STATE_SNAPSHOT for e in events)
|
||||
|
||||
|
||||
class TestEmitMcpToolResultWithDisplay:
|
||||
"""MCP tool results must honour the display marker so UI consumers can
|
||||
render structured payloads while ``flow.tool_results`` keeps the LLM
|
||||
string. MCP outputs do not pass through ``parse_result``; the marker
|
||||
rides on the outer content's ``additional_properties``.
|
||||
"""
|
||||
|
||||
def test_mcp_tool_result_routes_display_payload_to_ui_only(self):
|
||||
import json as _json
|
||||
|
||||
display_payload = {"rows": [{"id": 1, "name": "alpha"}, {"id": 2, "name": "beta"}]}
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_disp",
|
||||
output="2 rows returned",
|
||||
additional_properties={TOOL_RESULT_DISPLAY_KEY: display_payload},
|
||||
)
|
||||
flow = FlowState()
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
|
||||
|
||||
assert len(result_events) == 1
|
||||
# UI event carries the structured display payload.
|
||||
assert _json.loads(result_events[0].content) == display_payload
|
||||
# LLM-side accumulator keeps the short text.
|
||||
assert flow.tool_results[-1]["content"] == "2 rows returned"
|
||||
|
||||
Generated
+1
-1
@@ -602,7 +602,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user