Compare commits

..
Author SHA1 Message Date
Peter Ibekwe 6ea7b45290 Revamped sample to address PR comments. 2026-05-22 08:58:19 -07:00
Peter Ibekwe 894b2c6ce0 Resolve PR comments 2026-05-21 21:56:27 -07:00
Peter Ibekwe 111648ee5b Fix CI pipelines. 2026-05-21 17:08:37 -07:00
Peter Ibekwe 41eac64ee3 Addressed PR comments. 2026-05-21 16:02:01 -07:00
Peter Ibekwe e642371a20 Fix pyright issues. 2026-05-18 15:15:02 -07:00
Peter IbekweandCopilot c30f104b20 Python: address PR review on declarative toolbox sample
Two security fixes for PR #5933:

1. Add safe_mode flag to WorkflowFactory (default True) mirroring
   AgentFactory. Gates =Env.* exposure inside DeclarativeWorkflowState
   PowerFx symbols via _safe_mode_context, so workflow YAML loaded from
   untrusted sources no longer leaks the host's full os.environ snapshot
   into PowerFx evaluation. The flag is also forwarded to the
   internally-constructed AgentFactory so inline agent definitions
   follow the same policy.

2. Pin the invoke_foundry_toolbox_mcp sample's _client_provider to the
   resolved toolbox endpoint. The bearer-authenticated httpx client is
   now only returned when MCPToolInvocation.server_url matches the
   toolbox URL case-insensitively; any other URL gets None (the default
   unauthenticated path), preventing the Foundry AAD bearer token from
   being attached to a mis-configured or injected server URL. Mirrors
   the .NET sample's httpClientProvider guard.

The sample is updated to opt in to safe_mode=False because its YAML
intentionally uses =Env.FOUNDRY_TOOLBOX_* to keep configuration in env
vars under the developer's control.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 14:43:26 -07:00
Peter Ibekwe 800a58d6c1 Add Python parity sample for invoking Foundry Toolbox tools from declarative workflows 2026-05-18 13:10:55 -07:00
64 changed files with 1414 additions and 1987 deletions
+3 -3
View File
@@ -26,10 +26,10 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.55.0" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -44,7 +44,7 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
+1 -1
View File
@@ -122,7 +122,7 @@
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
@@ -6,26 +6,26 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
/// for task start, continue, wait, and result retrieval operations.
/// </summary>
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
public sealed class SubAgentToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
_ => null,
};
private static string? FormatStartBackgroundTask(FunctionCallContent call)
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetStringArgumentValue(call, "agentName");
string? description = GetStringArgumentValue(call, "description");
@@ -19,7 +19,7 @@ public sealed class TodoToolFormatter : ToolCallFormatter
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatCompleteTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
@@ -64,50 +64,6 @@ public sealed class TodoToolFormatter : ToolCallFormatter
return sb.ToString();
}
private static string? FormatCompleteTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null)
{
return null;
}
var entries = new List<(int Id, string? Reason)>();
if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id))
{
continue;
}
string? reason = item.TryGetProperty("reason", out JsonElement reasonElement)
? reasonElement.GetString()
: null;
entries.Add((id, reason));
}
}
if (entries.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < entries.Count; i++)
{
string connector = i < entries.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} Complete #{entries[i].Id}");
if (!string.IsNullOrEmpty(entries[i].Reason))
{
sb.Append($" — {Truncate(entries[i].Reason!, 80)}");
}
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
@@ -56,7 +56,7 @@ public abstract class ToolCallFormatter
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new BackgroundAgentToolFormatter(),
new SubAgentToolFormatter(),
new FileMemoryToolFormatter(),
new WebSearchToolFormatter(),
new FallbackToolFormatter(),
@@ -1,9 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
// and a WebBrowsingTool.
// 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,
// and then executes each step — all within an interactive conversation loop.
//
@@ -35,32 +34,86 @@ const int MaxOutputTokens = 128_000;
// and research-focused instructions including the mandatory planning workflow.
var instructions =
"""
## Research Assistant Instructions
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
### Research quality
## Mandatory planning workflow
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below.
*Plan Mode*
1. Analyze the request with the purpose of building a research plan.
2. Create a list of todo items.
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
4. Ask for clarifications from the user where needed.
1. Ask each clarification one by one.
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
3. Do not proceed until you have received all the needed clarifications.
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
*Execute Mode*
1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
4. Mark tasks as completed as you finish them.
5. Continue working, thinking and calling tools until you have the research result for the user.
## General Instructions
- You must check the current mode after any user input, since the user may have changed the mode themselves,
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
- Explain your reasoning and thought process as you work through tasks.
- 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.
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
- Do not answer the underlying question before the plan has been presented and approved.
- This rule applies even when the answer seems obvious or the task seems small.
- For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are:
- greetings,
- pure acknowledgments,
- clarification questions needed to form the plan,
- follow-up questions about results you have already presented,
- meta-discussion about the workflow itself.
**Todo management**
Mark each todo complete as you finish it so the list stays current.
If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why.
Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them.
**Research quality**
Consult multiple sources when possible and cross-reference key claims.
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
Track your sources you will need them when presenting results.
### Presenting results
**Presenting results**
When presenting your final findings:
- Use Markdown formatting for clarity.
- Use clear sections with headings for each major topic or sub-question.
- Cite your sources inline (e.g., "According to [source name](URL), ...").
- End with a brief summary of key takeaways.
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
- Save the final research report to file memory so it survives compaction and can be referenced later.
**File memory**
Use the FileMemory_* tools to:
- Store downloaded search results or web pages.
- Store plans.
- Read the current plan to make sure tasks were done according to plan.
- Store findings.
- Check for relevant previously downloaded data / findings before starting new research.
""";
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
// 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(
@@ -74,26 +127,35 @@ 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.
.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.",
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
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
{
Instructions = instructions,
Tools =
[
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
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.
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();
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
@@ -1,10 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
// A parent agent is given a list of stock tickers and instructed to find the closing price
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
// tool configuration is needed on the background agent.
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
// equipped with Foundry's hosted web search tool.
//
// Special commands:
// /exit — End the session.
@@ -26,9 +25,8 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// --- Background agent: Web Search Agent ---
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
// Features not needed by this sub-agent are disabled.
// --- 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 =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
@@ -43,44 +41,40 @@ AIAgent webSearchAgent =
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
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(),
],
},
});
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the background agent to look up stock prices in parallel.
// This agent orchestrates the sub-agent to look up stock prices in parallel.
var parentInstructions =
"""
You are a stock price research assistant. You have access to a web search background agent that can look up information on the web.
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
## Workflow
1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
- Start all background tasks before waiting for any of them to complete, so they run concurrently.
2. Wait for all background tasks to complete.
3. Retrieve the results from each background task.
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
2. Wait for all sub-tasks to complete.
3. Retrieve the results from each sub-task.
4. Present a summary table with the ticker symbol and closing price for each stock.
5. Clear all completed tasks to free memory.
## Important
- Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory.
- If a background task fails or returns unclear results, continue the task with a more specific query.
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
- Present results in a clean markdown table format.
""";
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the sub-agent to look up stock prices in parallel.
// Most features are disabled since the parent only needs SubAgentsProvider.
AIAgent parentAgent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
@@ -94,16 +88,10 @@ AIAgent parentAgent =
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using background agents.",
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
DisableWebSearch = true,
Description = "An agent that researches stock prices using sub-agents.",
AIContextProviders =
[
new BackgroundAgentsProvider([webSearchAgent]),
new SubAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
@@ -1,24 +1,24 @@
# Harness Step 02 — BackgroundAgents (Stock Price Research)
# Harness Step 02 — SubAgents (Stock Price Research)
This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
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
A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table.
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
### Architecture
```
┌────────────────────────────────────────
│ StockPriceResearcher
│ (Parent Agent)
BackgroundAgentsProvider │
│ ├─ BackgroundAgents_StartTask │
│ ├─ BackgroundAgents_WaitFor... │
│ ├─ BackgroundAgents_GetTaskResults │
│ └─ ...
└────────────┬───────────────────────────
┌─────────────────────────────────┐
│ StockPriceResearcher │
│ (Parent Agent) │
│ │
SubAgentsProvider │
│ ├─ SubAgents_StartTask │
│ ├─ SubAgents_WaitFor... │
│ ├─ SubAgents_GetTaskResults │
│ └─ ... │
└────────────┬────────────────────┘
│ delegates to
┌─────────────────────────────────┐
@@ -40,7 +40,7 @@ A parent agent receives a list of stock tickers and uses a web-search background
## Running the Sample
```bash
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
dotnet run
```
@@ -50,4 +50,4 @@ When prompted, enter a list of stock tickers such as:
BAC, MSFT, BA
```
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
@@ -19,7 +19,7 @@
</ItemGroup>
<ItemGroup>
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,12 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the default 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.
//
// The sample includes a pre-populated `working/` folder with sales transaction data.
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
// which matches this sample's folder layout.
// The sample includes a pre-populated `data/` folder with sales transaction data.
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
@@ -29,6 +27,10 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// Point the file store at the data/ folder that ships with the sample.
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
var fileStore = new FileSystemAgentFileStore(dataFolder);
var instructions =
"""
You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools.
@@ -54,9 +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 the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
// Unused features are disabled.
// Create the chat client from the OpenAI provider.
AIAgent agent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
@@ -71,11 +71,10 @@ AIAgent agent =
{
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableWebSearch = true,
AIContextProviders =
[
new FileAccessProvider(fileStore),
],
ChatOptions = new ChatOptions
{
Instructions = instructions,
@@ -1,11 +1,11 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the default `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, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
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** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
- **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
- **Streaming output** — responses are streamed token-by-token for a natural experience
@@ -39,7 +39,7 @@ dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
## What to Expect
The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
You can ask the agent to:
@@ -53,7 +53,7 @@ E.g. try the following prompt `Please process the sales.csv file by first filter
## Sample Data
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
| Column | Description |
| --- | --- |
+1 -1
View File
@@ -7,5 +7,5 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| Sample | Description |
| --- | --- |
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
@@ -22,7 +22,6 @@
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
@@ -14,7 +14,6 @@ using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -67,15 +66,14 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -46,9 +46,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -28,7 +28,6 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -35,7 +35,6 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -176,15 +175,14 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -39,9 +39,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -118,10 +118,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -87,9 +87,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -79,9 +79,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -1,6 +1,7 @@
.env
bin/
obj/
out/
.vs/
.vscode/
*.user
@@ -66,9 +66,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -53,10 +53,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -69,10 +69,10 @@ builder.Services.AddFoundryToolboxes(toolboxName);
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -55,9 +55,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
@@ -1,41 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
namespace Hosted_Shared_Contributor_Setup;
/// <summary>
/// Routing helpers for contributor samples that host a Foundry-managed agent locally.
/// </summary>
public static class HostedContributorRouteExtensions
{
/// <summary>
/// In Development, maps the per-agent OpenAI route shape that live Foundry uses
/// (<c>/api/projects/{project}/agents/{agentName}/endpoint/protocols/openai/responses</c>) on top
/// of the default <c>MapFoundryResponses()</c> so a local REPL client can reach the agent through
/// <c>AIProjectClient.AsAIAgent(Uri agentEndpoint)</c>, which is the only supported consumption path
/// for Foundry-hosted agents.
///
/// <para>
/// The <c>{project}</c> and <c>{agentName}</c> segments are route-parameter wildcards on the server
/// side; the handler does not consume them, so any value sent by the client is accepted.
/// </para>
///
/// <para><b>For local contributor debugging only and should not be used in production.</b></para>
/// </summary>
/// <param name="app">The <see cref="WebApplication"/> to attach the routes to.</param>
/// <returns>The same <see cref="WebApplication"/> for chaining.</returns>
public static WebApplication MapDevTemporaryLocalAgentEndpoint(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("api/projects/{project}/agents/{agentName}/endpoint/protocols/openai");
}
return app;
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
@@ -10,34 +11,28 @@ using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
// https://<host>/api/projects/<project>
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
var options = new AIProjectClientOptions();
if (projectEndpoint.Scheme == "http")
if (agentEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
AgentSession session = await agent.CreateSessionAsync();
@@ -46,10 +41,10 @@ AgentSession session = await agent.CreateSessionAsync();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Session Files Client
Session Files Client
Connected to: {agentEndpoint}
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
Type a message or 'quit' to exit
""");
Console.ResetColor();
@@ -13,18 +13,18 @@ The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled
## Configuration
```env
AZURE_AI_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
AZURE_AI_AGENT_NAME=hosted-files
AGENT_ENDPOINT=http://localhost:8088
AGENT_NAME=hosted-files
```
Both are required. `AZURE_AI_PROJECT_ENDPOINT` is the Foundry project endpoint URL and `AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent OpenAI endpoint URL from these.
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
## Run
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
$env:AZURE_AI_AGENT_NAME = "hosted-files"
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
dotnet run
```
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
@@ -10,34 +11,28 @@ using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
// https://<host>/api/projects/<project>
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
var options = new AIProjectClientOptions();
if (projectEndpoint.Scheme == "http")
if (agentEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
AgentSession session = await agent.CreateSessionAsync();
@@ -46,9 +41,9 @@ AgentSession session = await agent.CreateSessionAsync();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Simple Agent Sample
Simple Agent Sample
Connected to: {agentEndpoint}
Type a message or 'quit' to exit
Type a message or 'quit' to exit
══════════════════════════════════════════════════════════
""");
Console.ResetColor();
@@ -66,42 +66,6 @@ public static partial class AzureAIProjectChatClientExtensions
return new FoundryAgent(aiProjectClient, innerAgent);
}
/// <summary>
/// Wraps an existing server side hosted agent as a <see cref="FoundryAgent"/> using the provided
/// <see cref="AIProjectClient"/> and an agent-specific endpoint URI.
/// </summary>
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to use for project-level operations. Cannot be <see langword="null"/>.</param>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// The agent name is parsed from this URI and the active agent version is resolved server side
/// from the endpoint's administrator-controlled version selector. Cannot be <see langword="null"/>.
/// </param>
/// <param name="tools">The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <returns>A <see cref="FoundryAgent"/> instance that routes calls through the supplied agent endpoint.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
/// <remarks>
/// Agent version selection is controlled by the Foundry administrator through the endpoint's
/// version selector and cannot be overridden by the caller. Use the
/// <see cref="AsAIAgent(AIProjectClient, AgentReference, IList{AITool}?, Func{IChatClient, IChatClient}?, IServiceProvider?)"/>
/// overload when an explicit agent version pin is required.
/// </remarks>
public static FoundryAgent AsAIAgent(
this AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentEndpoint);
return new FoundryAgent(aiProjectClient, agentEndpoint, tools, clientFactory, services);
}
/// <summary>
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="ProjectsAgentRecord"/>.
/// </summary>
@@ -40,9 +40,27 @@ namespace Microsoft.Agents.AI.Foundry;
public sealed class FoundryAgent : DelegatingAIAgent
{
/// <summary>
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
/// accepted by the Foundry control plane.
/// </summary>
private readonly AIProjectClient _aiProjectClient;
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
/// <summary>
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
/// does not build a full <see cref="AIProjectClient"/>.
/// </summary>
private readonly AIProjectClient? _aiProjectClient;
/// <summary>
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
/// In agent-endpoint mode this is built directly from the project root derived from the
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
/// <see cref="AIProjectClient"/>.
/// </summary>
private readonly ProjectOpenAIClient _projectOpenAIClient;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
@@ -76,6 +94,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
/// <summary>
@@ -87,9 +106,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// </param>
/// <param name="credential">The authentication credential.</param>
/// <param name="clientOptions">
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
/// <list type="bullet">
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
/// </list>
/// </param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
@@ -113,34 +134,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
{
this._aiProjectClient = aiProjectClient;
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
/// </summary>
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI. Must be of the shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// </param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
internal FoundryAgent(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
}
/// <summary>
@@ -150,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -182,7 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
{
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -204,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
return this._aiProjectClient;
}
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
{
return this._projectOpenAIClient;
}
return base.GetService(serviceType, serviceKey);
}
@@ -289,10 +291,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
/// client with <c>Endpoint</c> and
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
@@ -305,44 +308,22 @@ public sealed class FoundryAgent : DelegatingAIAgent
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services,
out AIProjectClient outClient)
IServiceProvider? services)
{
Throw.IfNull(agentEndpoint);
Throw.IfNull(credential);
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
}
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
/// parsed from the endpoint URI and passed to
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
/// </summary>
private static AIAgent BuildAgentEndpointInnerAgent(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentEndpoint);
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
perAgentOptions.Endpoint = agentEndpoint;
perAgentOptions.AgentName = agentName;
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
.AsIChatClient();
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
@@ -358,6 +339,57 @@ public sealed class FoundryAgent : DelegatingAIAgent
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
/// <summary>
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
/// constructor by deriving the project root from the supplied agent endpoint and constructing
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
/// conversations.
/// </summary>
/// <remarks>
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
/// user-agent policy is appended last.
/// </remarks>
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions)
{
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
var projectOptions = new ProjectOpenAIClientOptions();
if (clientOptions is not null)
{
if (clientOptions.RetryPolicy is not null)
{
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
}
if (clientOptions.NetworkTimeout is not null)
{
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
}
if (clientOptions.Transport is not null)
{
projectOptions.Transport = clientOptions.Transport;
}
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
{
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
}
}
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
}
/// <summary>
/// Parses an agent endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
@@ -385,7 +417,8 @@ public sealed class FoundryAgent : DelegatingAIAgent
if (idx < 0)
{
throw new ArgumentException(
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
nameof(agentEndpoint));
}
@@ -428,32 +461,5 @@ public sealed class FoundryAgent : DelegatingAIAgent
return new AIProjectClient(endpoint, credential, clientOptions);
}
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
{
if (clientOptions is null)
{
return null;
}
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
// is project-level (not derived from the agent endpoint), so it must be carried through too.
var projectOptions = new AIProjectClientOptions
{
Transport = clientOptions.Transport,
RetryPolicy = clientOptions.RetryPolicy,
NetworkTimeout = clientOptions.NetworkTimeout,
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
};
if (clientOptions.ClientLoggingOptions is not null)
{
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
}
return projectOptions;
}
#endregion
}
@@ -1,9 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -13,8 +10,7 @@ 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, in-loop compaction, and a rich set
/// of default context providers and agent decorators.
/// function invocation, per-service-call chat history persistence, and in-loop compaction.
/// </summary>
/// <remarks>
/// <para>
@@ -27,27 +23,6 @@ namespace Microsoft.Agents.AI;
/// </list>
/// </para>
/// <para>
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
/// <list type="bullet">
/// <item><description><see cref="TodoProvider"/> — todo list management.</description></item>
/// <item><description><see cref="AgentModeProvider"/> — agent mode tracking (plan/execute).</description></item>
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory.</description></item>
/// <item><description><see cref="FileAccessProvider"/> — shared file access.</description></item>
/// <item><description><see cref="AgentSkillsProvider"/> — skill discovery and loading.</description></item>
/// </list>
/// </para>
/// <para>
/// The agent is also wrapped with the following decorators by default (each can be disabled):
/// <list type="bullet">
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules.</description></item>
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation.</description></item>
/// </list>
/// </para>
/// <para>
/// A <see cref="HostedWebSearchTool"/> is added to the chat options by default (can be disabled via
/// <see cref="HarnessAgentOptions.DisableWebSearch"/>).
/// </para>
/// <para>
/// The underlying <see cref="ChatClientAgent"/> is configured with
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
@@ -73,9 +48,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
- 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 and thought process as you work through tasks.
- 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.
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
- 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.
""";
@@ -101,15 +74,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <exception cref="ArgumentNullException">
/// <exception cref="System.ArgumentNullException">
/// <paramref name="chatClient"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// <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(BuildAgent(
: base(BuildInnerAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
@@ -117,25 +90,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
{
}
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
AIAgentBuilder builder = innerAgent.AsBuilder();
if (options?.DisableToolApproval is not true)
{
builder.UseToolApproval();
}
if (options?.DisableOpenTelemetry is not true)
{
builder.UseOpenTelemetry();
}
return builder.Build();
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
@@ -148,28 +102,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
ChatReducer = compactionStrategy.AsChatReducer(),
});
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
string? agentInstructions = options?.ChatOptions?.Instructions;
string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions;
string instructions = (string.IsNullOrWhiteSpace(harnessInstructions), string.IsNullOrWhiteSpace(agentInstructions)) switch
{
(true, true) => harnessInstructions,
(true, false) => agentInstructions!,
(false, true) => harnessInstructions,
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
};
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
return chatClient
.AsBuilder()
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseFunctionInvocation()
.UseMessageInjection()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(compactionProvider)
@@ -180,78 +121,17 @@ public sealed class HarnessAgent : DelegatingAIAgent
Description = options?.Description,
ChatOptions = chatOptions,
ChatHistoryProvider = chatHistoryProvider,
AIContextProviders = contextProviders,
AIContextProviders = options?.AIContextProviders,
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
}
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens)
{
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
ChatOptions result = source?.Clone() ?? new ChatOptions();
result.Instructions = instructions;
result.MaxOutputTokens ??= maxOutputTokens;
if (options?.DisableWebSearch is not true)
{
result.Tools ??= [];
result.Tools.Add(new HostedWebSearchTool());
}
return result;
}
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
{
var providers = new List<AIContextProvider>();
if (options?.DisableTodoProvider is not true)
{
providers.Add(new TodoProvider());
}
if (options?.DisableAgentModeProvider is not true)
{
providers.Add(new AgentModeProvider(options?.AgentModeProviderOptions));
}
if (options?.DisableFileMemory is not true)
{
AgentFileStore fileMemoryStore = options?.FileMemoryStore
?? new FileSystemAgentFileStore(
Path.Combine(Directory.GetCurrentDirectory(), "agent-file-memory"));
providers.Add(new FileMemoryProvider(
fileMemoryStore,
_ => new FileMemoryState
{
WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString(),
}));
}
if (options?.DisableFileAccess is not true)
{
AgentFileStore fileAccessStore = options?.FileAccessStore
?? new FileSystemAgentFileStore(
Path.Combine(Directory.GetCurrentDirectory(), "working"));
providers.Add(new FileAccessProvider(fileAccessStore));
}
if (options?.DisableAgentSkillsProvider is not true)
{
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
? new AgentSkillsProvider(source)
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
providers.Add(skillsProvider);
}
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
}
return providers;
}
}
@@ -36,31 +36,13 @@ public sealed class HarnessAgentOptions
/// Use <see cref="ChatOptions.Tools"/> to supply additional tools the agent can invoke.
/// </para>
/// <para>
/// Use <see cref="ChatOptions.Instructions"/> to provide agent-specific instructions (e.g., research methodology,
/// data analysis workflow). These are combined with <see cref="HarnessInstructions"/> to form the final instructions
/// sent to the model: harness instructions appear first, followed by agent-specific instructions.
/// When <see cref="ChatOptions.Instructions"/> is <see langword="null"/>, only <see cref="HarnessInstructions"/>
/// (or the default) is used.
/// 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 harness-level instructions that control general tool usage and behavior patterns.
/// </summary>
/// <remarks>
/// <para>
/// Harness instructions provide guidance on how to use tools, explain reasoning, and structure work.
/// They are combined with <see cref="ChatOptions"/>.<see cref="ChatOptions.Instructions"/> (agent-specific instructions)
/// to produce the final instructions sent to the model: harness instructions first, then agent-specific instructions.
/// </para>
/// <para>
/// When <see langword="null"/> (the default), <see cref="HarnessAgent.DefaultInstructions"/> is used.
/// Set to <see cref="string.Empty"/> to omit harness instructions entirely.
/// </para>
/// </remarks>
public string? HarnessInstructions { get; set; }
/// <summary>
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
/// </summary>
@@ -79,131 +61,4 @@ public sealed class HarnessAgentOptions
/// <see cref="ChatClientAgentOptions.AIContextProviders"/>.
/// </remarks>
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
/// <summary>
/// Gets or sets the maximum number of function-invocation loop iterations per request.
/// </summary>
/// <remarks>
/// When set, this value is passed to <see cref="FunctionInvokingChatClient.MaximumIterationsPerRequest"/>.
/// When <see langword="null"/>, the <see cref="FunctionInvokingChatClient"/> default is used.
/// </remarks>
public int? MaximumIterationsPerRequest { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="ToolApprovalAgent"/> wrapper is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the agent is wrapped with tool approval middleware
/// that supports "don't ask again" auto-approval rules.
/// </remarks>
public bool DisableToolApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="FileMemoryProvider"/> is included in the
/// agent's context providers, using either <see cref="FileMemoryStore"/> or a default
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/agent-file-memory/{timestamp}_{guid}</c>.
/// </remarks>
public bool DisableFileMemory { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileMemoryProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableFileMemory"/> is <see langword="false"/>,
/// a default <see cref="FileSystemAgentFileStore"/> is created.
/// This property is ignored when <see cref="DisableFileMemory"/> is <see langword="true"/>.
/// </remarks>
public AgentFileStore? FileMemoryStore { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileAccessProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="FileAccessProvider"/> is included in the
/// agent's context providers, using either <see cref="FileAccessStore"/> or a default
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/working</c>.
/// </remarks>
public bool DisableFileAccess { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileAccessProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableFileAccess"/> is <see langword="false"/>,
/// a default <see cref="FileSystemAgentFileStore"/> is created.
/// This property is ignored when <see cref="DisableFileAccess"/> is <see langword="true"/>.
/// </remarks>
public AgentFileStore? FileAccessStore { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="HostedWebSearchTool"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="HostedWebSearchTool"/> is added
/// to <see cref="ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
/// </remarks>
public bool DisableWebSearch { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="TodoProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="TodoProvider"/> is included
/// in the agent's context providers for tracking work items.
/// </remarks>
public bool DisableTodoProvider { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="AgentModeProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), an <see cref="AgentModeProvider"/> is included
/// in the agent's context providers. Use <see cref="AgentModeProviderOptions"/> to configure
/// custom modes.
/// </remarks>
public bool DisableAgentModeProvider { get; set; }
/// <summary>
/// Gets or sets custom options for the <see cref="AgentModeProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the <see cref="AgentModeProvider"/> uses its built-in default
/// modes ("plan" and "execute"). This property is ignored when
/// <see cref="DisableAgentModeProvider"/> is <see langword="true"/>.
/// </remarks>
public AgentModeProviderOptions? AgentModeProviderOptions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="AgentSkillsProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), an <see cref="AgentSkillsProvider"/> is included
/// in the agent's context providers. Use <see cref="AgentSkillsSource"/> to provide a custom
/// skills source; otherwise, the provider defaults to file-based skill discovery from the current
/// working directory.
/// </remarks>
public bool DisableAgentSkillsProvider { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AI.AgentSkillsSource"/> for the <see cref="AgentSkillsProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableAgentSkillsProvider"/> is <see langword="false"/>,
/// the provider defaults to file-based skill discovery from the current working directory.
/// This property is ignored when <see cref="DisableAgentSkillsProvider"/> is <see langword="true"/>.
/// </remarks>
public AgentSkillsSource? AgentSkillsSource { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="OpenTelemetryAgent"/> wrapper is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the agent is wrapped with an
/// <see cref="OpenTelemetryAgent"/> that provides OpenTelemetry instrumentation
/// following the Semantic Conventions for Generative AI systems.
/// </remarks>
public bool DisableOpenTelemetry { get; set; }
}
@@ -74,11 +74,9 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(TodoState))]
[JsonSerializable(typeof(TodoItem))]
[JsonSerializable(typeof(TodoItemInput))]
[JsonSerializable(typeof(TodoCompleteInput))]
[JsonSerializable(typeof(List<int>), TypeInfoPropertyName = "IntList")]
[JsonSerializable(typeof(List<TodoItem>), TypeInfoPropertyName = "TodoItemList")]
[JsonSerializable(typeof(List<TodoItemInput>), TypeInfoPropertyName = "TodoItemInputList")]
[JsonSerializable(typeof(List<TodoCompleteInput>), TypeInfoPropertyName = "TodoCompleteInputList")]
// AgentModeProvider types
[JsonSerializable(typeof(AgentModeState))]
@@ -97,12 +95,12 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(FileListEntry))]
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
// BackgroundAgentsProvider types
[JsonSerializable(typeof(BackgroundAgentState))]
[JsonSerializable(typeof(BackgroundAgentRuntimeState))]
[JsonSerializable(typeof(BackgroundTaskInfo))]
[JsonSerializable(typeof(BackgroundTaskStatus))]
[JsonSerializable(typeof(List<BackgroundTaskInfo>), TypeInfoPropertyName = "BackgroundTaskInfoList")]
// SubAgentsProvider types
[JsonSerializable(typeof(SubAgentState))]
[JsonSerializable(typeof(SubAgentRuntimeState))]
[JsonSerializable(typeof(SubTaskInfo))]
[JsonSerializable(typeof(SubTaskStatus))]
[JsonSerializable(typeof(List<SubTaskInfo>), TypeInfoPropertyName = "SubTaskInfoList")]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
@@ -45,54 +45,20 @@ public sealed class AgentModeProvider : AIContextProvider
"""
## Agent Mode
- You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
- You must check the current mode after any user input, since the user may have changed the mode themselves,
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
Use the AgentMode_Get tool to check your current operating mode.
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
You are currently operating in the {current_mode} mode.
### Mandatory Mode based Workflow
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
{available_modes}
You are currently operating in the {current_mode} mode.
""";
private static readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> s_defaultModes =
[
new(
"plan",
"""
Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding.
Process to follow when in plan mode:
1. Analyze the request with the purpose of building a research plan.
2. Create a list of todo items.
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
4. Ask for clarifications from the user where needed.
1. Ask each clarification one by one.
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
3. Do not proceed until you have received all the needed clarifications.
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
"""),
new(
"execute",
"""
Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback.
Process to follow when in execute mode:
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
4. Mark tasks as completed as you finish them.
5. Continue working, thinking and calling tools until you have the research result for the user.
"""),
new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."),
new("execute", "Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice."),
];
private readonly ProviderSessionState<AgentModeState> _sessionState;
@@ -221,15 +187,12 @@ public sealed class AgentModeProvider : AIContextProvider
private string BuildInstructions(string currentMode)
{
// Build list of modes text:
var modesListBuilder = new StringBuilder();
foreach (var mode in this._modes)
{
modesListBuilder.AppendLine($"#### {mode.Name}");
modesListBuilder.AppendLine();
modesListBuilder.AppendLine(mode.Description.TrimEnd());
modesListBuilder.AppendLine();
modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}");
}
var modesListText = modesListBuilder.ToString();
return new StringBuilder(this._instructions)
@@ -55,7 +55,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
- Include a description when saving a file to help with future discovery.
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories to avoid duplicate work.
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories.
- Keep memories up-to-date by overwriting files when information changes.
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
@@ -7,15 +7,15 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Holds non-serializable runtime references for in-flight background tasks within a single parent session.
/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session.
/// </summary>
/// <remarks>
/// Properties are marked with <see cref="JsonIgnoreAttribute"/> because <see cref="Task{TResult}"/>
/// and <see cref="AgentSession"/> are not JSON-serializable. After deserialization (e.g., after a restart),
/// a fresh empty instance is created and any previously-running tasks are marked as
/// <see cref="BackgroundTaskStatus.Lost"/> by <see cref="BackgroundAgentsProvider"/>.
/// <see cref="SubTaskStatus.Lost"/> by <see cref="SubAgentsProvider"/>.
/// </remarks>
internal sealed class BackgroundAgentRuntimeState
internal sealed class SubAgentRuntimeState
{
/// <summary>
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
@@ -24,9 +24,9 @@ internal sealed class BackgroundAgentRuntimeState
public Dictionary<int, Task<AgentResponse>> InFlightTasks { get; } = [];
/// <summary>
/// Gets the mapping of task IDs to their background agent <see cref="AgentSession"/> instances,
/// Gets the mapping of task IDs to their sub-agent <see cref="AgentSession"/> instances,
/// needed for <c>ContinueTask</c>.
/// </summary>
[JsonIgnore]
public Dictionary<int, AgentSession> BackgroundTaskSessions { get; } = [];
public Dictionary<int, AgentSession> SubTaskSessions { get; } = [];
}
@@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the serializable state of background tasks managed by the <see cref="BackgroundAgentsProvider"/>,
/// Represents the serializable state of sub-tasks managed by the <see cref="SubAgentsProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class BackgroundAgentState
internal sealed class SubAgentState
{
/// <summary>
/// Gets or sets the next ID to assign to a new background task.
/// Gets or sets the next ID to assign to a new sub-task.
/// </summary>
[JsonPropertyName("nextTaskId")]
public int NextTaskId { get; set; } = 1;
/// <summary>
/// Gets the list of background task metadata entries.
/// Gets the list of sub-task metadata entries.
/// </summary>
[JsonPropertyName("tasks")]
public List<BackgroundTaskInfo> Tasks { get; set; } = [];
public List<SubTaskInfo> Tasks { get; set; } = [];
}
@@ -15,56 +15,56 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to background agents asynchronously.
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to sub-agents asynchronously.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="BackgroundAgentsProvider"/> allows a parent agent to start background tasks on child agents,
/// wait for their completion, and retrieve results. Each background task runs in its own session and
/// The <see cref="SubAgentsProvider"/> allows a parent agent to start sub-tasks on child agents,
/// wait for their completion, and retrieve results. Each sub-task runs in its own session and
/// executes concurrently.
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>BackgroundAgents_StartTask</c> — Start a background task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>BackgroundAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>BackgroundAgents_GetTaskResults</c> — Retrieve the text output of a completed background task.</description></item>
/// <item><description><c>BackgroundAgents_GetAllTasks</c> — List all background tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>BackgroundAgents_ContinueTask</c> — Send follow-up input to a completed background task's session to resume work.</description></item>
/// <item><description><c>BackgroundAgents_ClearCompletedTask</c> — Remove a completed background task and release its session to free memory.</description></item>
/// <item><description><c>SubAgents_StartTask</c> — Start a sub-task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>SubAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>SubAgents_GetTaskResults</c> — Retrieve the text output of a completed sub-task.</description></item>
/// <item><description><c>SubAgents_GetAllTasks</c> — List all sub-tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>SubAgents_ContinueTask</c> — Send follow-up input to a completed sub-task's session to resume work.</description></item>
/// <item><description><c>SubAgents_ClearCompletedTask</c> — Remove a completed sub-task and release its session to free memory.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundAgentsProvider : AIContextProvider
public sealed class SubAgentsProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## BackgroundAgents
You have access to background agents that can perform work on your behalf.
## SubAgents
You have access to sub-agents that can perform work on your behalf.
- Use the `BackgroundAgents_*` list of tools to start tasks on background agents and check their results.
- Creating a background task does not block, and background tasks run concurrently.
- Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
- Creating a sub task does not block, and sub-tasks run concurrently.
- Important: Always wait for outstanding tasks to finish before you finish processing.
- Important: After retrieving results from a completed task, clear it with BackgroundAgents_ClearCompletedTask to free memory, unless you plan to continue it with BackgroundAgents_ContinueTask.
- Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
{background_agents}
{sub_agents}
""";
private readonly Dictionary<string, AIAgent> _agents;
private readonly ProviderSessionState<BackgroundAgentState> _sessionState;
private readonly ProviderSessionState<BackgroundAgentRuntimeState> _runtimeSessionState;
private readonly ProviderSessionState<SubAgentState> _sessionState;
private readonly ProviderSessionState<SubAgentRuntimeState> _runtimeSessionState;
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="BackgroundAgentsProvider"/> class.
/// Initializes a new instance of the <see cref="SubAgentsProvider"/> class.
/// </summary>
/// <param name="agents">The collection of background agents available for delegation.</param>
/// <param name="agents">The collection of sub-agents available for delegation.</param>
/// <param name="options">Optional settings controlling the provider behavior.</param>
/// <exception cref="ArgumentNullException"><paramref name="agents"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">An agent has a null or empty name, or agent names are not unique.</exception>
public BackgroundAgentsProvider(IEnumerable<AIAgent> agents, BackgroundAgentsProviderOptions? options = null)
public SubAgentsProvider(IEnumerable<AIAgent> agents, SubAgentsProviderOptions? options = null)
{
_ = Throw.IfNull(agents);
@@ -74,15 +74,15 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
string agentListText = options?.AgentListBuilder is not null
? options.AgentListBuilder(this._agents)
: BuildDefaultAgentListText(this._agents);
this._instructions = baseInstructions.Replace("{background_agents}", agentListText);
this._instructions = baseInstructions.Replace("{sub_agents}", agentListText);
this._sessionState = new ProviderSessionState<BackgroundAgentState>(
_ => new BackgroundAgentState(),
this._sessionState = new ProviderSessionState<SubAgentState>(
_ => new SubAgentState(),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
this._runtimeSessionState = new ProviderSessionState<BackgroundAgentRuntimeState>(
_ => new BackgroundAgentRuntimeState(),
this._runtimeSessionState = new ProviderSessionState<SubAgentRuntimeState>(
_ => new SubAgentRuntimeState(),
this.GetType().Name + "_Runtime",
AgentJsonUtilities.DefaultOptions);
}
@@ -93,8 +93,8 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
BackgroundAgentState state = this._sessionState.GetOrInitializeState(context.Session);
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
SubAgentState state = this._sessionState.GetOrInitializeState(context.Session);
SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(new AIContext
{
@@ -113,12 +113,12 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
{
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new ArgumentException("All background agents must have a non-empty Name.", nameof(agents));
throw new ArgumentException("All sub-agents must have a non-empty Name.", nameof(agents));
}
if (dict.ContainsKey(agent.Name))
{
throw new ArgumentException($"Duplicate background agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
throw new ArgumentException($"Duplicate sub-agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
}
dict[agent.Name] = agent;
@@ -126,19 +126,19 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
if (dict.Count == 0)
{
throw new ArgumentException("At least one background agent must be provided.", nameof(agents));
throw new ArgumentException("At least one sub-agent must be provided.", nameof(agents));
}
return dict;
}
/// <summary>
/// Builds the default text listing available background agents and their descriptions.
/// Builds the default text listing available sub-agents and their descriptions.
/// </summary>
private static string BuildDefaultAgentListText(IReadOnlyDictionary<string, AIAgent> agents)
{
var sb = new StringBuilder();
sb.AppendLine("Available background agents:");
sb.AppendLine("Available sub-agents:");
foreach (var kvp in agents)
{
sb.Append("- ").Append(kvp.Key);
@@ -156,12 +156,12 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
/// <summary>
/// Refreshes the status of in-flight tasks in the given state for the specified session.
/// </summary>
private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
private void TryRefreshTaskState(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
{
bool changed = false;
foreach (BackgroundTaskInfo task in state.Tasks)
foreach (SubTaskInfo task in state.Tasks)
{
if (task.Status != BackgroundTaskStatus.Running)
if (task.Status != SubTaskStatus.Running)
{
continue;
}
@@ -169,7 +169,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = BackgroundTaskStatus.Lost;
task.Status = SubTaskStatus.Lost;
changed = true;
continue;
}
@@ -188,32 +188,32 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
}
/// <summary>
/// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo.
/// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo.
/// </summary>
private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task<AgentResponse> completedTask, BackgroundAgentRuntimeState runtimeState)
private static void FinalizeTask(SubTaskInfo taskInfo, Task<AgentResponse> completedTask, SubAgentRuntimeState runtimeState)
{
if (completedTask.Status == TaskStatus.RanToCompletion)
{
taskInfo.Status = BackgroundTaskStatus.Completed;
taskInfo.Status = SubTaskStatus.Completed;
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed
taskInfo.ResultText = completedTask.Result.Text;
#pragma warning restore VSTHRD002
}
else if (completedTask.IsFaulted)
{
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error";
}
else if (completedTask.IsCanceled)
{
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.ErrorText = "Task was canceled.";
}
runtimeState.InFlightTasks.Remove(taskInfo.Id);
}
private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
@@ -221,43 +221,43 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
[
AIFunctionFactory.Create(
async (
[Description("The name of the background agent to delegate the task to.")] string agentName,
[Description("The request to pass to the background agent.")] string input,
[Description("The name of the sub agent to delegate the task to.")] string agentName,
[Description("The request to pass to the sub agent.")] string input,
[Description("A description of the task used to identify the task later.")] string description) =>
{
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
{
return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
return $"Error: No sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
}
int taskId = state.NextTaskId++;
var taskInfo = new BackgroundTaskInfo
var taskInfo = new SubTaskInfo
{
Id = taskId,
AgentName = agentName,
Description = description,
Status = BackgroundTaskStatus.Running,
Status = SubTaskStatus.Running,
};
state.Tasks.Add(taskInfo);
// Create a dedicated session for this background task so it can be continued later.
// Create a dedicated session for this sub-task so it can be continued later.
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
// this isolation, the sub-agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
runtimeState.BackgroundTaskSessions[taskId] = subSession;
runtimeState.SubTaskSessions[taskId] = subSession;
this._sessionState.SaveState(session, state);
return $"Background task {taskId} started on agent '{agentName}'.";
return $"Sub-task {taskId} started on agent '{agentName}'.";
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_StartTask",
Description = "Start a background task on a named background agent. Returns a confirmation message containing the task ID.",
Name = "SubAgents_StartTask",
Description = "Start a sub-task on a named sub-agent. Returns a confirmation message containing the task ID.",
SerializerOptions = serializerOptions,
}),
@@ -287,7 +287,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
this._sessionState.SaveState(session, state);
// Check if any of the requested IDs are already complete.
BackgroundTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != BackgroundTaskStatus.Running);
SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.Running);
if (alreadyComplete is not null)
{
return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}.";
@@ -303,7 +303,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
var completedEntry = waitableTasks.First(t => t.Task == completedTask);
// Finalize the completed task.
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
if (taskInfo is not null)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
@@ -314,8 +314,8 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified background tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
Name = "SubAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
SerializerOptions = serializerOptions,
}),
@@ -324,7 +324,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
@@ -332,17 +332,17 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
return taskInfo.Status switch
{
BackgroundTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
BackgroundTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
BackgroundTaskStatus.Lost => "Task state was lost (reference unavailable).",
BackgroundTaskStatus.Running => $"Task {taskId} is still running.",
SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
SubTaskStatus.Lost => "Task state was lost (reference unavailable).",
SubTaskStatus.Running => $"Task {taskId} is still running.",
_ => $"Task {taskId} has status: {taskInfo.Status}.",
};
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_GetTaskResults",
Description = "Get the text output of a background task by its ID. Returns the result text if complete, or status information if still running or failed.",
Name = "SubAgents_GetTaskResults",
Description = "Get the text output of a sub-task by its ID. Returns the result text if complete, or status information if still running or failed.",
SerializerOptions = serializerOptions,
}),
@@ -358,7 +358,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
var sb = new StringBuilder();
sb.AppendLine("Tasks:");
foreach (BackgroundTaskInfo task in state.Tasks)
foreach (SubTaskInfo task in state.Tasks)
{
sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description);
}
@@ -367,8 +367,8 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_GetAllTasks",
Description = "List all background tasks with their IDs, statuses, agent names, and descriptions.",
Name = "SubAgents_GetAllTasks",
Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.",
SerializerOptions = serializerOptions,
}),
@@ -377,18 +377,18 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == BackgroundTaskStatus.Lost)
if (taskInfo.Status == SubTaskStatus.Lost)
{
return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead.";
}
if (taskInfo.Status == BackgroundTaskStatus.Running)
if (taskInfo.Status == SubTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
}
@@ -398,17 +398,17 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
}
if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
if (!runtimeState.SubTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
{
return $"Error: Session for task {taskId} is no longer available.";
}
// Reset task state and start a new run on the existing session.
taskInfo.Status = BackgroundTaskStatus.Running;
taskInfo.Status = SubTaskStatus.Running;
taskInfo.ResultText = null;
taskInfo.ErrorText = null;
// Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment).
// Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask comment).
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
this._sessionState.SaveState(session, state);
@@ -416,8 +416,8 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_ContinueTask",
Description = "Send follow-up input to a completed or failed background task to resume its work. The background task's session is preserved, so the agent retains conversational context.",
Name = "SubAgents_ContinueTask",
Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.",
SerializerOptions = serializerOptions,
}),
@@ -426,13 +426,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == BackgroundTaskStatus.Running)
if (taskInfo.Status == SubTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before clearing.";
}
@@ -442,15 +442,15 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
// Clean up runtime references.
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.BackgroundTaskSessions.Remove(taskId);
runtimeState.SubTaskSessions.Remove(taskId);
this._sessionState.SaveState(session, state);
return $"Task {taskId} cleared.";
},
new AIFunctionFactoryOptions
{
Name = "BackgroundAgents_ClearCompletedTask",
Description = "Remove a completed or failed background task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
Name = "SubAgents_ClearCompletedTask",
Description = "Remove a completed or failed sub-task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
SerializerOptions = serializerOptions,
}),
];
@@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="BackgroundAgentsProvider"/>.
/// Options controlling the behavior of <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundAgentsProviderOptions
public sealed class SubAgentsProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the background agent tools.
/// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
/// </summary>
/// <remarks>
/// Use the <c>{background_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available background agents.
/// Use the <c>{sub_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available sub agents.
/// </remarks>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use the background agent tools.
/// that guide the agent on how to use the sub-agent tools.
/// The agent list is always appended after the instructions regardless of this setting.
/// </value>
public string? Instructions { get; set; }
@@ -33,7 +33,7 @@ public sealed class BackgroundAgentsProviderOptions
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard list of agent names and descriptions.
/// When set, this function receives the dictionary of available agents (keyed by name) and should return
/// a formatted string describing the available background agents.
/// a formatted string describing the available sub-agents.
/// </value>
public Func<IReadOnlyDictionary<string, AIAgent>, string>? AgentListBuilder { get; set; }
}
@@ -7,43 +7,43 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the metadata and result of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// Represents the metadata and result of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundTaskInfo
public sealed class SubTaskInfo
{
/// <summary>
/// Gets or sets the unique identifier for this background task.
/// Gets or sets the unique identifier for this sub-task.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the agent that is executing this background task.
/// Gets or sets the name of the agent that is executing this sub-task.
/// </summary>
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a description of what this background task is doing.
/// Gets or sets a description of what this sub-task is doing.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current status of this background task.
/// Gets or sets the current status of this sub-task.
/// </summary>
[JsonPropertyName("status")]
public BackgroundTaskStatus Status { get; set; }
public SubTaskStatus Status { get; set; }
/// <summary>
/// Gets or sets the text result of the background task, populated when the task completes successfully.
/// Gets or sets the text result of the sub-task, populated when the task completes successfully.
/// </summary>
[JsonPropertyName("resultText")]
public string? ResultText { get; set; }
/// <summary>
/// Gets or sets the error message if the background task failed.
/// Gets or sets the error message if the sub-task failed.
/// </summary>
[JsonPropertyName("errorText")]
public string? ErrorText { get; set; }
@@ -6,28 +6,28 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the status of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// Represents the status of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum BackgroundTaskStatus
public enum SubTaskStatus
{
/// <summary>
/// The background task is currently running.
/// The sub-task is currently running.
/// </summary>
Running,
/// <summary>
/// The background task completed successfully.
/// The sub-task completed successfully.
/// </summary>
Completed,
/// <summary>
/// The background task failed with an error.
/// The sub-task failed with an error.
/// </summary>
Failed,
/// <summary>
/// The background task's in-flight reference was lost (e.g., after a restart),
/// The sub-task's in-flight reference was lost (e.g., after a restart),
/// and its final state cannot be determined.
/// </summary>
Lost,
@@ -1,26 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the input for completing a single todo item via the <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class TodoCompleteInput
{
/// <summary>
/// Gets or sets the ID of the todo item to mark as complete.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the reason describing how or why the item was completed.
/// </summary>
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
@@ -48,13 +48,13 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
You have access to a todo list for tracking work items.
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
Ask questions from the user where clarification is needed to create effective todos.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant ones.
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed.
Use these tools to manage your tasks:
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
- Use TodoList_Complete to mark items as done when finished (supports one or many at once).
- Use TodoList_GetRemaining to check what work is still pending.
- Use TodoList_GetAll to review the full list including completed items.
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
@@ -235,14 +235,14 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
}),
AIFunctionFactory.Create(
async (List<TodoCompleteInput> items) =>
async (List<int> ids) =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(items.Select(i => i.Id));
var idSet = new HashSet<int>(ids);
int completed = 0;
foreach (TodoItem item in state.Items)
{
@@ -268,7 +268,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
new AIFunctionFactoryOptions
{
Name = "TodoList_Complete",
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.",
SerializerOptions = serializerOptions,
}),
@@ -20,7 +20,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
@@ -1380,133 +1380,6 @@ public sealed class AzureAIProjectChatClientExtensionsTests
#endregion
#region AsAIAgent(AIProjectClient, Uri agentEndpoint) Tests
private const string TestAgentEndpointUrl = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when AIProjectClient is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullClient_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient? client = null;
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.AsAIAgent(new Uri(TestAgentEndpointUrl)));
Assert.Equal("aiProjectClient", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when agentEndpoint is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullEndpoint_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client.AsAIAgent((Uri)null!));
Assert.Equal("agentEndpoint", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) populates Name/Id from the parsed endpoint slug
/// and exposes the supplied <see cref="AIProjectClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_PopulatesNameAndIdFromSlugAndReusesProjectClient()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl));
// Assert
Assert.NotNull(agent);
Assert.IsType<FoundryAgent>(agent);
Assert.Equal("it-happy-path", agent.Name);
Assert.Equal("it-happy-path", agent.Id);
Assert.Same(client, agent.GetService<AIProjectClient>());
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) applies the supplied client factory exactly once.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.AsAIAgent(
new Uri(TestAgentEndpointUrl),
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) forwards the supplied tools to the inner
/// <see cref="ChatClientAgent"/>'s <see cref="ChatOptions.Tools"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_ForwardsToolsToInnerChatOptions()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
var tool1 = AIFunctionFactory.Create(() => "result-1", "tool_1", "First test tool.");
var tool2 = AIFunctionFactory.Create(() => "result-2", "tool_2", "Second test tool.");
List<AITool> tools = [tool1, tool2];
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: tools);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.NotNull(chatOptions!.Tools);
Assert.Equal(2, chatOptions.Tools!.Count);
Assert.Same(tool1, chatOptions.Tools[0]);
Assert.Same(tool2, chatOptions.Tools[1]);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) accepts a null tools argument without throwing
/// and produces an agent whose inner <see cref="ChatOptions.Tools"/> is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullTools_DoesNotThrow()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: null);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.Null(chatOptions!.Tools);
}
#endregion
#region Helper Methods
/// <summary>
@@ -2,7 +2,6 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -357,7 +356,7 @@ public class FoundryAgentTests
bool userAgentFound = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
{
foreach (string value in values)
{
@@ -432,23 +431,23 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.NotNull(agent.GetService<AIProjectClient>());
Assert.Null(agent.GetService<AIProjectClient>());
}
[Fact]
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
{
FoundryAgent agent = new(
s_testEndpoint,
@@ -456,7 +455,7 @@ public class FoundryAgentTests
model: "gpt-4o-mini",
instructions: "Test");
Assert.Null(agent.GetService<ProjectOpenAIClient>());
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
@@ -666,82 +665,21 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
{
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
// application-id stamp in the outbound request. Verify the value is propagated onto the
// project-level client's options via the public ProjectOpenAIClient surface.
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
Assert.NotNull(projectClient);
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
Assert.NotNull(agent);
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
}
[Fact]
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
{
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
}
[Fact]
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
{
// Arrange
var transport = new FakePipelineTransport();
var retryPolicy = new FakeRetryPolicy();
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
var networkTimeout = TimeSpan.FromSeconds(42);
ProjectOpenAIClientOptions callerOptions = new()
{
UserAgentApplicationId = "my-app-id",
Transport = transport,
RetryPolicy = retryPolicy,
MessageLoggingPolicy = messageLoggingPolicy,
ClientLoggingOptions = clientLoggingOptions,
NetworkTimeout = networkTimeout,
};
// Act
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
// Assert: every settable pipeline behavior the caller configured is forwarded
// onto the project-level options bag, not silently dropped.
Assert.NotNull(projectOptions);
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
Assert.Same(transport, projectOptions.Transport);
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
}
private sealed class FakeRetryPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakePipelineTransport : PipelineTransport
{
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
}
#endregion
#region ParseAgentEndpoint tests
@@ -824,13 +762,13 @@ public class FoundryAgentTests
private readonly string _value;
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class HarnessAgentOptionsTests
@@ -20,22 +18,8 @@ public class HarnessAgentOptionsTests
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.HarnessInstructions);
Assert.Null(options.ChatHistoryProvider);
Assert.Null(options.AIContextProviders);
Assert.False(options.DisableToolApproval);
Assert.False(options.DisableFileMemory);
Assert.False(options.DisableFileAccess);
Assert.False(options.DisableWebSearch);
Assert.False(options.DisableTodoProvider);
Assert.False(options.DisableAgentModeProvider);
Assert.False(options.DisableAgentSkillsProvider);
Assert.False(options.DisableOpenTelemetry);
Assert.Null(options.MaximumIterationsPerRequest);
Assert.Null(options.FileMemoryStore);
Assert.Null(options.FileAccessStore);
Assert.Null(options.AgentModeProviderOptions);
Assert.Null(options.AgentSkillsSource);
}
/// <summary>
@@ -47,10 +31,6 @@ public class HarnessAgentOptionsTests
// Arrange
var chatHistoryProvider = new InMemoryChatHistoryProvider();
var contextProviders = new AIContextProvider[] { new TodoProvider() };
var fileMemoryStore = new Mock<AgentFileStore>().Object;
var fileAccessStore = new Mock<AgentFileStore>().Object;
var agentModeOptions = new AgentModeProviderOptions();
var skillsSource = new Mock<AgentSkillsSource>().Object;
// Act
var options = new HarnessAgentOptions
@@ -59,22 +39,8 @@ public class HarnessAgentOptionsTests
Name = "test-name",
Description = "test-description",
ChatOptions = new() { Temperature = 0.5f, Instructions = "custom instructions" },
HarnessInstructions = "custom harness instructions",
ChatHistoryProvider = chatHistoryProvider,
AIContextProviders = contextProviders,
MaximumIterationsPerRequest = 42,
DisableToolApproval = true,
DisableFileMemory = true,
FileMemoryStore = fileMemoryStore,
DisableFileAccess = true,
FileAccessStore = fileAccessStore,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
AgentModeProviderOptions = agentModeOptions,
DisableAgentSkillsProvider = true,
AgentSkillsSource = skillsSource,
DisableOpenTelemetry = true,
};
// Assert
@@ -84,21 +50,7 @@ public class HarnessAgentOptionsTests
Assert.NotNull(options.ChatOptions);
Assert.Equal(0.5f, options.ChatOptions!.Temperature);
Assert.Equal("custom instructions", options.ChatOptions.Instructions);
Assert.Equal("custom harness instructions", options.HarnessInstructions);
Assert.Same(chatHistoryProvider, options.ChatHistoryProvider);
Assert.Same(contextProviders, options.AIContextProviders);
Assert.Equal(42, options.MaximumIterationsPerRequest);
Assert.True(options.DisableToolApproval);
Assert.True(options.DisableFileMemory);
Assert.Same(fileMemoryStore, options.FileMemoryStore);
Assert.True(options.DisableFileAccess);
Assert.Same(fileAccessStore, options.FileAccessStore);
Assert.True(options.DisableWebSearch);
Assert.True(options.DisableTodoProvider);
Assert.True(options.DisableAgentModeProvider);
Assert.Same(agentModeOptions, options.AgentModeProviderOptions);
Assert.True(options.DisableAgentSkillsProvider);
Assert.Same(skillsSource, options.AgentSkillsSource);
Assert.True(options.DisableOpenTelemetry);
}
}
@@ -15,21 +15,6 @@ public class HarnessAgentTests
private const int TestMaxContextWindowTokens = 100_000;
private const int TestMaxOutputTokens = 10_000;
/// <summary>
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
/// </summary>
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
{
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
#region Constructor Validation
/// <summary>
@@ -96,12 +81,13 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "TestAgent";
options.Description = "A test agent";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
Name = "TestAgent",
Description = "A test agent",
});
// Assert
Assert.Equal("TestAgent", agent.Name);
@@ -116,11 +102,12 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Id = "my-agent-id";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
Id = "my-agent-id",
});
// Assert
Assert.Equal("my-agent-id", agent.Id);
@@ -140,7 +127,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -149,18 +136,19 @@ public class HarnessAgentTests
}
/// <summary>
/// Verify that default instructions are used when options is provided but neither HarnessInstructions nor ChatOptions.Instructions is set.
/// Verify that default instructions are used when options is provided but ChatOptions.Instructions is null.
/// </summary>
[Fact]
public void Instructions_DefaultsWhenBothNull()
public void Instructions_DefaultsWhenChatOptionsInstructionsIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatOptions = new ChatOptions { Temperature = 0.5f },
});
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -169,106 +157,24 @@ public class HarnessAgentTests
}
/// <summary>
/// Verify that ChatOptions.Instructions is appended to the default HarnessInstructions.
/// Verify that ChatOptions.Instructions overrides the defaults.
/// </summary>
[Fact]
public void Instructions_CombinesDefaultHarnessWithAgentInstructions()
public void Instructions_CanBeOverriddenViaChatOptions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
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);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nYou are a custom assistant.";
Assert.Equal(expected, innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions replaces the default.
/// </summary>
[Fact]
public void Instructions_CustomHarnessInstructionsReplacesDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions and ChatOptions.Instructions are combined.
/// </summary>
[Fact]
public void Instructions_CombinesCustomHarnessWithAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.\n\nYou are a research agent.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions omits harness portion, using only agent instructions.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsUsesOnlyAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Agent only instructions.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions with no agent instructions results in empty string.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsWithNoAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal(string.Empty, innerAgent!.Instructions);
Assert.Equal("You are a custom assistant.", innerAgent!.Instructions);
}
#endregion
@@ -285,7 +191,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -302,11 +208,12 @@ public class HarnessAgentTests
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customProvider = new InMemoryChatHistoryProvider();
var options = CreateAllDisabledOptions();
options.ChatHistoryProvider = customProvider;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatHistoryProvider = customProvider,
});
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -328,7 +235,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -349,7 +256,7 @@ public class HarnessAgentTests
var rawClient = mockClient.Object;
// Act
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
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.
@@ -362,45 +269,45 @@ public class HarnessAgentTests
#region AIContextProviders
/// <summary>
/// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent.
/// 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 customProvider = new TodoProvider();
var options = CreateAllDisabledOptions();
options.AIContextProviders = [customProvider];
var todoProvider = new TodoProvider();
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
AIContextProviders = [todoProvider],
});
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
// Assert — the TodoProvider should appear in the inner agent's AIContextProviders.
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Contains(customProvider, innerAgent.AIContextProviders!);
Assert.Contains(todoProvider, innerAgent.AIContextProviders!);
}
/// <summary>
/// Verify that when all default providers are disabled and no user AIContextProviders are specified,
/// the inner agent has an empty providers list.
/// Verify that when no AIContextProviders are specified, the inner agent has no additional providers.
/// </summary>
[Fact]
public void AIContextProviders_IsEmptyWhenAllDisabledAndNoneSpecified()
public void AIContextProviders_IsNullWhenNoneSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Empty(innerAgent.AIContextProviders!);
Assert.Null(innerAgent!.AIContextProviders);
}
#endregion
@@ -425,10 +332,13 @@ public class HarnessAgentTests
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [tool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatOptions = new ChatOptions
{
Tools = [tool],
},
});
var session = await agent.CreateSessionAsync();
// Act
@@ -479,7 +389,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
// Assert
Assert.Same(agent, agent.GetService<HarnessAgent>());
@@ -495,7 +405,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
// Assert
Assert.NotNull(agent.GetService<ChatClientAgent>());
@@ -520,7 +430,7 @@ public class HarnessAgentTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
var session = await agent.CreateSessionAsync();
// Act
@@ -577,19 +487,19 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "ExtensionAgent";
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
// Act
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options);
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);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nCustom instructions";
Assert.Equal(expected, innerAgent!.Instructions);
Assert.Equal("Custom instructions", innerAgent!.Instructions);
}
/// <summary>
@@ -603,579 +513,4 @@ public class HarnessAgentTests
}
#endregion
#region Feature: ToolApproval
/// <summary>
/// Verify that ToolApprovalAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void ToolApproval_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableToolApproval = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgent is excluded when disabled.
/// </summary>
[Fact]
public void ToolApproval_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<ToolApprovalAgent>());
}
#endregion
#region Feature: OpenTelemetry
/// <summary>
/// Verify that OpenTelemetryAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void OpenTelemetry_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that OpenTelemetryAgent is excluded when disabled.
/// </summary>
[Fact]
public void OpenTelemetry_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<OpenTelemetryAgent>());
}
#endregion
#region Feature: WebSearch
/// <summary>
/// Verify that HostedWebSearchTool is added to ChatOptions.Tools by default.
/// </summary>
[Fact]
public async Task WebSearch_IncludedByDefaultAsync()
{
// Arrange
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 options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
/// <summary>
/// Verify that HostedWebSearchTool is not added when disabled.
/// </summary>
[Fact]
public async Task WebSearch_ExcludedWhenDisabledAsync()
{
// Arrange
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, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions);
if (capturedOptions!.Tools != null)
{
Assert.DoesNotContain(capturedOptions.Tools, t => t is HostedWebSearchTool);
}
}
/// <summary>
/// Verify that user-provided tools are preserved alongside the default HostedWebSearchTool.
/// </summary>
[Fact]
public async Task WebSearch_CoexistsWithUserToolsAsync()
{
// Arrange
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 userTool = AIFunctionFactory.Create(() => "test", "UserTool");
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
options.ChatOptions = new ChatOptions { Tools = [userTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
Assert.Contains(capturedOptions.Tools!, t => t == userTool);
}
#endregion
#region Feature: TodoProvider
/// <summary>
/// Verify that TodoProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void TodoProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableTodoProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is TodoProvider);
}
/// <summary>
/// Verify that TodoProvider is excluded when disabled.
/// </summary>
[Fact]
public void TodoProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is TodoProvider);
}
}
#endregion
#region Feature: AgentModeProvider
/// <summary>
/// Verify that AgentModeProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentModeProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
/// <summary>
/// Verify that AgentModeProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentModeProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentModeProvider);
}
}
/// <summary>
/// Verify that custom AgentModeProviderOptions are passed through.
/// </summary>
[Fact]
public void AgentModeProvider_UsesCustomOptions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
options.AgentModeProviderOptions = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("custom-mode", "A custom mode for testing"),
],
DefaultMode = "custom-mode",
};
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
// but we verify it is created and present).
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
#endregion
#region Feature: FileMemoryProvider
/// <summary>
/// Verify that FileMemoryProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileMemoryProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
/// <summary>
/// Verify that FileMemoryProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileMemoryProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileMemoryProvider);
}
}
/// <summary>
/// Verify that a custom FileMemoryStore is used when provided.
/// </summary>
[Fact]
public void FileMemoryProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
options.FileMemoryStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileMemoryProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
#endregion
#region Feature: FileAccessProvider
/// <summary>
/// Verify that FileAccessProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileAccessProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
/// <summary>
/// Verify that FileAccessProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileAccessProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileAccessProvider);
}
}
/// <summary>
/// Verify that a custom FileAccessStore is used when provided.
/// </summary>
[Fact]
public void FileAccessProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
options.FileAccessStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileAccessProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
#endregion
#region Feature: AgentSkillsProvider
/// <summary>
/// Verify that AgentSkillsProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentSkillsProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
/// <summary>
/// Verify that AgentSkillsProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentSkillsProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentSkillsProvider);
}
}
/// <summary>
/// Verify that a custom AgentSkillsSource is used when provided.
/// </summary>
[Fact]
public void AgentSkillsProvider_UsesCustomSource()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customSource = new Mock<AgentSkillsSource>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
options.AgentSkillsSource = customSource;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentSkillsProvider should be present.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
#endregion
#region Feature: MaximumIterationsPerRequest
/// <summary>
/// Verify that MaximumIterationsPerRequest configures the FunctionInvokingChatClient.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_ConfiguresFunctionInvokingChatClient()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.MaximumIterationsPerRequest = 42;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert
Assert.NotNull(ficc);
Assert.Equal(42, ficc!.MaximumIterationsPerRequest);
}
/// <summary>
/// Verify that the default MaximumIterationsPerRequest is used when not set.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_UsesDefaultWhenNotSet()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert — default is not 0 and not our custom value.
Assert.NotNull(ficc);
Assert.NotEqual(0, ficc!.MaximumIterationsPerRequest);
}
#endregion
#region Feature: All Defaults Enabled
/// <summary>
/// Verify that when no options are provided, all default features are enabled.
/// </summary>
[Fact]
public async Task AllDefaults_AllFeaturesEnabledAsync()
{
// Arrange
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")));
// Act
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — agent wrappers
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
// Assert — default context providers
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
var providers = innerAgent.AIContextProviders!.ToList();
Assert.Contains(providers, p => p is TodoProvider);
Assert.Contains(providers, p => p is AgentModeProvider);
Assert.Contains(providers, p => p is FileMemoryProvider);
Assert.Contains(providers, p => p is FileAccessProvider);
Assert.Contains(providers, p => p is AgentSkillsProvider);
// Assert — HostedWebSearchTool is present in the tools sent to the model
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
#endregion
}
@@ -13,9 +13,9 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="BackgroundAgentsProvider"/> class.
/// Unit tests for the <see cref="SubAgentsProvider"/> class.
/// </summary>
public class BackgroundAgentsProviderTests
public class SubAgentsProviderTests
{
#region Constructor Tests
@@ -26,7 +26,7 @@ public class BackgroundAgentsProviderTests
public void Constructor_NullAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new BackgroundAgentsProvider(null!));
Assert.Throws<ArgumentNullException>(() => new SubAgentsProvider(null!));
}
/// <summary>
@@ -36,7 +36,7 @@ public class BackgroundAgentsProviderTests
public void Constructor_EmptyAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(Array.Empty<AIAgent>()));
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(Array.Empty<AIAgent>()));
}
/// <summary>
@@ -49,7 +49,7 @@ public class BackgroundAgentsProviderTests
var agent = CreateMockAgent(null!, "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
}
/// <summary>
@@ -62,7 +62,7 @@ public class BackgroundAgentsProviderTests
var agent = CreateMockAgent("", "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
}
/// <summary>
@@ -76,7 +76,7 @@ public class BackgroundAgentsProviderTests
var agent2 = CreateMockAgent("research", "Agent 2");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent1, agent2 }));
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent1, agent2 }));
}
/// <summary>
@@ -90,7 +90,7 @@ public class BackgroundAgentsProviderTests
var agent2 = CreateMockAgent("Writer", "Writer agent");
// Act
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
// Assert
Assert.NotNull(provider);
@@ -108,7 +108,7 @@ public class BackgroundAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
@@ -129,7 +129,7 @@ public class BackgroundAgentsProviderTests
// Arrange
var agent1 = CreateMockAgent("Research", "Performs research");
var agent2 = CreateMockAgent("Writer", "Writes content");
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
var context = CreateInvokingContext();
// Act
@@ -144,22 +144,22 @@ public class BackgroundAgentsProviderTests
#endregion
#region StartBackgroundTask Tests
#region StartSubTask Tests
/// <summary>
/// Verify that StartBackgroundTask returns a task ID.
/// Verify that StartSubTask returns a task ID.
/// </summary>
[Fact]
public async Task StartBackgroundTask_ReturnsTaskIdAsync()
public async Task StartSubTask_ReturnsTaskIdAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Find information about AI",
@@ -175,18 +175,18 @@ public class BackgroundAgentsProviderTests
}
/// <summary>
/// Verify that StartBackgroundTask with invalid agent name returns an error.
/// Verify that StartSubTask with invalid agent name returns an error.
/// </summary>
[Fact]
public async Task StartBackgroundTask_InvalidAgentName_ReturnsErrorAsync()
public async Task StartSubTask_InvalidAgentName_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "NonExistent",
["input"] = "Some input",
@@ -200,10 +200,10 @@ public class BackgroundAgentsProviderTests
}
/// <summary>
/// Verify that StartBackgroundTask assigns sequential IDs.
/// Verify that StartSubTask assigns sequential IDs.
/// </summary>
[Fact]
public async Task StartBackgroundTask_AssignsSequentialIdsAsync()
public async Task StartSubTask_AssignsSequentialIdsAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
@@ -215,16 +215,16 @@ public class BackgroundAgentsProviderTests
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
// Act
object? result1 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
object? result1 = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
object? result2 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
object? result2 = await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 2",
@@ -253,11 +253,11 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
// Start one task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
@@ -288,7 +288,7 @@ public class BackgroundAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
@@ -302,24 +302,24 @@ public class BackgroundAgentsProviderTests
#endregion
#region GetBackgroundTaskResults Tests
#region GetSubTaskResults Tests
/// <summary>
/// Verify that GetBackgroundTaskResults returns the result text of a completed task.
/// Verify that GetSubTaskResults returns the result text of a completed task.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_CompletedTask_ReturnsResultTextAsync()
public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -346,20 +346,20 @@ public class BackgroundAgentsProviderTests
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a still-running task returns status info.
/// Verify that GetSubTaskResults for a still-running task returns status info.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_RunningTask_ReturnsStatusAsync()
public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -379,15 +379,15 @@ public class BackgroundAgentsProviderTests
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a nonexistent task returns an error.
/// Verify that GetSubTaskResults for a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_NonexistentTask_ReturnsErrorAsync()
public async Task GetSubTaskResults_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
@@ -400,21 +400,21 @@ public class BackgroundAgentsProviderTests
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a failed task returns the error.
/// Verify that GetSubTaskResults for a failed task returns the error.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_FailedTask_ReturnsErrorTextAsync()
public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -456,11 +456,11 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -490,12 +490,12 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -525,7 +525,7 @@ public class BackgroundAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
@@ -554,13 +554,13 @@ public class BackgroundAgentsProviderTests
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -606,11 +606,11 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -639,7 +639,7 @@ public class BackgroundAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
@@ -666,13 +666,13 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -711,11 +711,11 @@ public class BackgroundAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
await startSubTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -743,7 +743,7 @@ public class BackgroundAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
@@ -767,7 +767,7 @@ public class BackgroundAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
var provider = new SubAgentsProvider(new[] { agent });
// Act
var keys = provider.StateKeys;
@@ -782,23 +782,23 @@ public class BackgroundAgentsProviderTests
#region CurrentRunContext Isolation Tests
/// <summary>
/// Verify that StartBackgroundTask does not corrupt CurrentRunContext of the calling agent.
/// Verify that StartSubTask does not corrupt CurrentRunContext of the calling agent.
/// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal
/// CurrentRunContext, the provider must isolate the background agent call to prevent overwriting
/// CurrentRunContext, the provider must isolate the sub-agent call to prevent overwriting
/// the outer agent's context.
/// </summary>
[Fact]
public async Task StartBackgroundTask_DoesNotCorruptCurrentRunContextAsync()
public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
var startTool = GetTool(tools, "BackgroundAgents_StartTask");
var startTool = GetTool(tools, "SubAgents_StartTask");
AgentRunContext? contextBefore = AIAgent.CurrentRunContext;
// Act — invoke StartBackgroundTask; this calls agent.RunAsync internally.
// Act — invoke StartSubTask; this calls agent.RunAsync internally.
var args = new AIFunctionArguments(new Dictionary<string, object?>
{
["agentName"] = "Research",
@@ -826,16 +826,16 @@ public class BackgroundAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
const string CustomInstructions = "These are custom background agent instructions.\n{background_agents}";
var options = new BackgroundAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
const string CustomInstructions = "These are custom sub-agent instructions.\n{sub_agents}";
var options = new SubAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new SubAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder
Assert.Contains("These are custom background agent instructions.", result.Instructions);
Assert.Contains("These are custom sub-agent instructions.", result.Instructions);
Assert.Contains("Research", result.Instructions);
}
@@ -847,15 +847,15 @@ public class BackgroundAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions contain tool usage guidance and agent list
Assert.Contains("BackgroundAgents_*", result.Instructions);
Assert.Contains("BackgroundAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("SubAgents_*", result.Instructions);
Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("Research", result.Instructions);
Assert.Contains("Research agent", result.Instructions);
}
@@ -868,11 +868,11 @@ public class BackgroundAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var options = new BackgroundAgentsProviderOptions
var options = new SubAgentsProviderOptions
{
AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}",
};
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
var provider = new SubAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
@@ -880,7 +880,7 @@ public class BackgroundAgentsProviderTests
// Assert — custom agent list builder output is in instructions
Assert.Contains("Custom list: Research", result.Instructions);
Assert.DoesNotContain("Available background agents:", result.Instructions);
Assert.DoesNotContain("Available sub-agents:", result.Instructions);
}
#endregion
@@ -935,9 +935,9 @@ public class BackgroundAgentsProviderTests
return mock.Object;
}
private static async Task<(IEnumerable<AITool> Tools, BackgroundAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
private static async Task<(IEnumerable<AITool> Tools, SubAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
{
var provider = new BackgroundAgentsProvider(new[] { agent });
var provider = new SubAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
AIContext result = await provider.InvokingAsync(context);
@@ -116,7 +116,7 @@ public class TodoProviderTests
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Assert
Assert.True(state.Items[0].IsComplete);
@@ -139,7 +139,7 @@ public class TodoProviderTests
});
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 3 } });
// Assert
Assert.True(state.Items[0].IsComplete);
@@ -159,35 +159,12 @@ public class TodoProviderTests
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
// Assert
Assert.Equal(0, GetIntResult(result));
}
/// <summary>
/// Verify that CompleteTodos accepts an optional reason parameter.
/// </summary>
[Fact]
public async Task CompleteTodos_AcceptsReasonParameterAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments()
{
["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Found the answer in the documentation." } },
});
// Assert
Assert.True(state.Items[0].IsComplete);
Assert.Equal(1, GetIntResult(result));
}
#endregion
#region RemoveTodos Tests
@@ -272,7 +249,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act
object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments());
@@ -302,7 +279,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act
object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments());
@@ -399,7 +376,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act
var remaining = await provider.GetRemainingTodosAsync(session);
@@ -566,7 +543,7 @@ public class TodoProviderTests
new() { Title = "Second", Description = "Has details" },
},
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act — second invocation should see the updated list in messages
AIContext result2 = await provider.InvokingAsync(context);
@@ -785,7 +762,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "New C" } },
}).AsTask(),
completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 2, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } }).AsTask());
completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 2, 3 } }).AsTask());
// Assert
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
@@ -27,12 +27,15 @@ from __future__ import annotations
import locale
import logging
import os
import re
import sys
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from decimal import Decimal as _Decimal
from enum import Enum
from types import MappingProxyType
from typing import Any, Literal, cast
from agent_framework import (
@@ -58,6 +61,100 @@ else:
logger = logging.getLogger(__name__)
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
@dataclass(frozen=True)
class DeclarativeEnvConfig:
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
Configuration values are always exposed under ``Env.<name>``;
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` AND the YAML literally references the name in a PowerFx
expression (the allowlist enforced via ``referenced_names``).
Attributes:
values: Caller-supplied configuration resolved by name when the
workflow YAML references ``=Env.NAME``. Always exposed in
the ``Env`` symbol regardless of ``restrict_to_configuration``.
restrict_to_configuration: When ``True`` (default), the ``Env``
symbol is populated exclusively from ``values``; ``os.environ``
is never consulted. Set to ``False`` to additionally fall back
to ``os.environ`` for names absent from ``values`` that the
workflow YAML explicitly references.
referenced_names: The set of ``Env.NAME`` symbols discovered in
PowerFx expressions inside the workflow definition. The
``os.environ`` fallback is constrained to this allowlist so
unrelated environment variables never enter the PowerFx scope.
"""
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
restrict_to_configuration: bool = True
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
def __post_init__(self) -> None:
# Defensive snapshots so the frozen guarantee extends to the
# contents of ``values`` / ``referenced_names``: caller mutations
# to the original objects after construction cannot leak into
# ``resolve()``.
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
def resolve(self) -> dict[str, str]:
"""Return the resolved ``Env`` symbol mapping for the workflow.
Configuration values are always included (stringified).
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` and the name appears in ``referenced_names``, so
unrelated environment variables never enter the PowerFx scope.
Configuration values always win over the environment fallback.
"""
resolved = {name: str(value) for name, value in self.values.items()}
if self.restrict_to_configuration:
return resolved
for name in self.referenced_names.difference(resolved):
env_value = os.environ.get(name)
if env_value is not None:
resolved[name] = env_value
return resolved
def discover_env_references(node: Any) -> set[str]:
"""Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
Walks any nested ``Mapping``/``list``/scalar structure and inspects every
string value. To avoid false positives from doc/description fields that
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
strings that begin with ``=`` (PowerFx expression marker, matching the
convention enforced by :meth:`DeclarativeWorkflowState.eval`).
Args:
node: A parsed workflow definition (typically the dict produced by
``yaml.safe_load``).
Returns:
The set of ``Env`` identifier names referenced in PowerFx
expressions inside ``node``.
"""
names: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, str):
if value.startswith("="):
names.update(_ENV_REFERENCE_RE.findall(value))
return
if isinstance(value, Mapping):
for inner in cast(Mapping[Any, Any], value).values(): # type: ignore[redundant-cast]
visit(inner)
return
if isinstance(value, list):
for item in cast(list[Any], value): # type: ignore[redundant-cast]
visit(item)
visit(node)
return names
class ConversationData(TypedDict):
"""Structure for conversation-related state data.
@@ -169,13 +266,18 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
def __init__(self, state: State):
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
"""Initialize with a State instance.
Args:
state: The workflow's state for persistence
env_config: Configuration that populates the PowerFx ``Env``
symbol when ``_to_powerfx_symbols`` is called. Defaults to
an empty configuration which results in no ``Env`` binding,
matching the safe default of the :class:`WorkflowFactory`.
"""
self._state = state
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
"""Initialize the declarative state with inputs.
@@ -714,6 +816,14 @@ class DeclarativeWorkflowState:
# Custom namespaces
**state_data.get("Custom", {}),
}
# Resolve the ``Env`` symbol from the workflow-level
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
# ``os.environ`` allowlist produce no entries the symbol is
# omitted so ``=Env.X`` falls back to the literal expression
# string (preserving the legacy "unbound identifier" behaviour).
env_bound = self._env_config.resolve()
if env_bound:
symbols["Env"] = env_bound
# Debug log the Local symbols to help diagnose type issues
if local_data:
for key, value in local_data.items():
@@ -867,6 +977,11 @@ class DeclarativeActionExecutor(Executor):
action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
super().__init__(id=action_id, defer_discovery=True)
self._action_def = action_def
# The active :class:`DeclarativeEnvConfig` is stamped onto the
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
# Manually register handlers after initialization
self._handlers = {}
@@ -874,6 +989,16 @@ class DeclarativeActionExecutor(Executor):
self._discover_handlers()
self._discover_response_handlers()
def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
"""Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
Called by :class:`DeclarativeWorkflowBuilder` after each executor is
created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
according to the caller-supplied configuration on the
:class:`WorkflowFactory`.
"""
self._declarative_env_config = env_config
@property
def action_def(self) -> dict[str, Any]:
"""Get the action definition."""
@@ -886,7 +1011,7 @@ class DeclarativeActionExecutor(Executor):
def _get_state(self, state: State) -> DeclarativeWorkflowState:
"""Get the declarative workflow state wrapper."""
return DeclarativeWorkflowState(state)
return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
async def _ensure_state_initialized(
self,
@@ -24,6 +24,7 @@ from agent_framework import (
from ._declarative_base import (
ConditionResult,
DeclarativeActionExecutor,
DeclarativeEnvConfig,
LoopIterationResult,
)
from ._errors import DeclarativeWorkflowError
@@ -140,6 +141,7 @@ class DeclarativeWorkflowBuilder:
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
env_config: DeclarativeEnvConfig | None = None,
):
"""Initialize the builder.
@@ -158,6 +160,10 @@ class DeclarativeWorkflowBuilder:
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
Must be supplied when the workflow contains any InvokeMcpTool;
otherwise build raises ``DeclarativeWorkflowError``.
env_config: Optional :class:`DeclarativeEnvConfig` controlling
how the ``Env`` PowerFx symbol is populated for every
executor built by this builder. Defaults to an empty
configuration (``Env`` not exposed).
"""
self._yaml_def = yaml_definition
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
@@ -171,6 +177,7 @@ class DeclarativeWorkflowBuilder:
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
@@ -221,6 +228,15 @@ class DeclarativeWorkflowBuilder:
# Resolve pending gotos (back-edges for loops, forward-edges for jumps)
self._resolve_pending_gotos(builder)
# Stamp the resolved DeclarativeEnvConfig onto every executor so they
# expose the configured Env binding through their _get_state(). This
# happens after _create_executors_for_actions and _resolve_pending_gotos
# so it covers the entry node, join nodes, evaluators, foreach
# init/next/exit nodes, and goto placeholders.
for executor in self._executors.values():
if isinstance(executor, DeclarativeActionExecutor):
executor.set_declarative_env_config(self._env_config)
return builder.build()
def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
@@ -26,6 +26,7 @@ from agent_framework import (
)
from .._loader import AgentFactory
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
from ._declarative_builder import DeclarativeWorkflowBuilder
from ._errors import DeclarativeWorkflowError
from ._http_handler import HttpRequestHandler
@@ -93,6 +94,8 @@ class WorkflowFactory:
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
configuration: Mapping[str, str] | None = None,
restrict_env_to_configuration: bool = True,
) -> None:
"""Initialize the workflow factory.
@@ -119,6 +122,23 @@ class WorkflowFactory:
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
or supply your own implementation to enforce SSRF guards, allowlisting,
or auth/connection resolution.
configuration: Optional mapping that populates the PowerFx ``Env``
symbol referenced from workflow YAML expressions (e.g.
``=Env.MY_KEY``). Keys supplied here are always exposed
under ``Env.<key>``; the process ``os.environ`` is consulted
only when ``restrict_env_to_configuration`` is ``False``.
When neither source produces a value the ``Env`` symbol is
omitted so ``=Env.X`` evaluates to the literal expression
string.
restrict_env_to_configuration: When ``True`` (default), the
``Env`` PowerFx symbol is populated exclusively from
``configuration``; ``os.environ`` is never consulted. Set to
``False`` to additionally fall back to ``os.environ`` for
names absent from ``configuration`` that the workflow YAML
explicitly references. The fallback is constrained to names
discovered in PowerFx expressions inside the workflow
definition so unrelated environment variables never enter
the PowerFx scope.
Examples:
.. code-block:: python
@@ -151,6 +171,18 @@ class WorkflowFactory:
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
env_file=".env",
)
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Inject named values for =Env.* references in the workflow YAML
factory = WorkflowFactory(
configuration={
"MY_SERVER_URL": "https://example.com",
"MY_TOOL_NAME": "search",
},
)
"""
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
@@ -160,6 +192,8 @@ class WorkflowFactory:
self._max_iterations = max_iterations
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
self._restrict_env_to_configuration = restrict_env_to_configuration
def create_workflow_from_yaml_path(
self,
@@ -394,6 +428,16 @@ class WorkflowFactory:
if description:
normalized_def["description"] = description
# Build the DeclarativeEnvConfig from the factory's configuration and the
# set of Env references actually used in the workflow PowerFx expressions.
# The referenced-name allowlist constrains ``os.environ`` fallback (when
# enabled) so unrelated variables never enter the PowerFx scope.
env_config = DeclarativeEnvConfig(
values=dict(self._configuration),
restrict_to_configuration=self._restrict_env_to_configuration,
referenced_names=frozenset(discover_env_references(normalized_def)),
)
# Build the graph-based workflow, passing agents and tools for specialized executors
try:
graph_builder = DeclarativeWorkflowBuilder(
@@ -405,6 +449,7 @@ class WorkflowFactory:
max_iterations=self._max_iterations,
http_request_handler=self._http_request_handler,
mcp_tool_handler=self._mcp_tool_handler,
env_config=env_config,
)
workflow = graph_builder.build()
except ValueError as e:
@@ -33,7 +33,7 @@ import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
import httpx
@@ -194,6 +194,21 @@ class DefaultMCPToolHandler:
Defaults to ``32``.
"""
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
to the MCP protocol ``tools/list`` discovery operation.
The constant matches the underlying MCP method name so a single
string travels unchanged through host code, YAML, and the protocol
wire. When this handler receives an invocation with this name it
pages through ``session.list_tools()`` and returns the catalog as a
single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
Workflows can reference this name from an ``InvokeMcpTool`` declarative
action to introspect a server's tool surface without an extra round-trip
from host code.
"""
def __init__(
self,
*,
@@ -217,10 +232,27 @@ class DefaultMCPToolHandler:
self._closed = False
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
intercepted client-side: instead of being forwarded as a tool call,
it is translated to an MCP ``session.list_tools()`` discovery
operation (paginated automatically) and returned as a single
``TextContent`` containing a JSON tool catalog.
"""
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
# Reserved-name args validation runs before connect: rejecting bad
# input shouldn't require establishing an MCP session.
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
entry = await self._get_or_create_entry(invocation)
except Exception as exc:
@@ -240,6 +272,8 @@ class DefaultMCPToolHandler:
)
try:
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
return await self._invoke_list_tools(entry)
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
except ToolExecutionException as exc:
logger.info(
@@ -284,6 +318,59 @@ class DefaultMCPToolHandler:
outputs = list(raw)
return MCPToolResult(outputs=outputs)
@staticmethod
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
Pages through ``session.list_tools()`` (mirroring the pagination loop
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
full catalog as a single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
The output shape, property names, and property order are stable so
downstream PowerFx expressions can rely on the schema. ``indent=2``
produces human-readable JSON for the conversation log;
``allow_nan=False`` guards against producing non-conformant JSON
``NaN``/``Infinity`` tokens if a misbehaving server returns such
values in a schema.
"""
from agent_framework import Content
session = getattr(entry.tool, "session", None)
if session is None:
message = "MCP session is not connected; cannot list tools."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
# Lazy import keeps ``mcp`` types out of module import time.
from mcp import types as mcp_types
collected: list[Any] = []
params: mcp_types.PaginatedRequestParams | None = None
while True:
tool_list = await session.list_tools(params=params)
collected.extend(tool_list.tools)
next_cursor = getattr(tool_list, "nextCursor", None)
if not next_cursor:
break
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
payload = {
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
"outputSchema": tool.outputSchema,
}
for tool in collected
],
}
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
async def aclose(self) -> None:
"""Close all cached MCP clients and the owned httpx clients.
@@ -13,6 +13,7 @@ owned-vs-caller httpx close semantics.
from __future__ import annotations
import asyncio
import json
import sys
from typing import Any
from unittest.mock import patch
@@ -33,6 +34,55 @@ pytestmark = pytest.mark.skipif(
)
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
self.tools = tools
self.nextCursor = next_cursor
class FakeMcpTool:
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
def __init__(
self,
name: str,
description: str | None = None,
inputSchema: dict[str, Any] | None = None,
outputSchema: dict[str, Any] | None = None,
) -> None:
self.name = name
self.description = description
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
self.outputSchema = outputSchema
class FakeMcpSession:
"""Stand-in for ``mcp.ClientSession``.
``list_tools_pages`` lets a test enqueue multiple paginated responses;
when None (default), an empty single-page result is returned. ``list_tools_error``
raises a synthetic error on the next call when set.
"""
def __init__(self) -> None:
self.list_tools_pages: list[FakeListToolsResult] | None = None
self.list_tools_calls: list[Any] = []
self.list_tools_error: BaseException | None = None
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
self.list_tools_calls.append(params)
if self.list_tools_error is not None:
raise self.list_tools_error
if self.list_tools_pages is None:
return FakeListToolsResult(tools=[])
index = len(self.list_tools_calls) - 1
if index >= len(self.list_tools_pages):
return FakeListToolsResult(tools=[])
return self.list_tools_pages[index]
class FakeTool:
"""Stand-in for ``MCPStreamableHTTPTool``.
@@ -50,6 +100,7 @@ class FakeTool:
self.connect_error: BaseException | None = None
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
self._httpx_client: httpx.AsyncClient | None = None
self.session: FakeMcpSession | None = None
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
# is set, lazily allocate an owned httpx client during connect.
FakeTool.instances.append(self)
@@ -63,6 +114,9 @@ class FakeTool:
# Mimic lazy httpx allocation when no client provided AND header_provider set.
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
self._httpx_client = httpx.AsyncClient()
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
if self.session is None:
self.session = FakeMcpSession()
async def close(self) -> None:
self.close_count += 1
@@ -541,3 +595,185 @@ class TestCacheKey:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
assert k1 != k2
# ---------- tools/list reserved name --------------------------------------
class TestListTools:
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
@pytest.mark.asyncio
async def test_list_tools_returns_json_catalog(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
# Prime the cache so the FakeTool session exists.
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(
tools=[
FakeMcpTool(
name="search",
description="Search docs",
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
outputSchema={"type": "object"},
),
FakeMcpTool(name="echo", description=None, outputSchema=None),
],
),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is False
assert len(result.outputs) == 1
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
assert payload == {
"tools": [
{
"name": "search",
"description": "Search docs",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
"outputSchema": {"type": "object"},
},
{
"name": "echo",
"description": None,
"inputSchema": {"type": "object", "properties": {}},
"outputSchema": None,
},
],
}
@pytest.mark.asyncio
async def test_list_tools_property_order_is_stable(self) -> None:
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
name_idx = text.find('"name"')
desc_idx = text.find('"description"')
input_idx = text.find('"inputSchema"')
output_idx = text.find('"outputSchema"')
assert 0 <= name_idx < desc_idx < input_idx < output_idx
@pytest.mark.asyncio
async def test_list_tools_indented_output(self) -> None:
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
# Indented output contains newlines and a 2-space indented key.
assert "\n " in text
@pytest.mark.asyncio
async def test_list_tools_rejects_arguments(self) -> None:
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
handler = DefaultMCPToolHandler()
with _patch_tool():
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
)
assert result.is_error is True
assert "does not accept tool arguments" in (result.error_message or "")
# Args validation runs before connect, so no tool was instantiated.
assert FakeTool.instances == []
@pytest.mark.asyncio
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
"""An empty arguments dict is equivalent to no arguments."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
)
assert result.is_error is False
@pytest.mark.asyncio
async def test_list_tools_paginates(self) -> None:
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
session = FakeTool.instances[0].session
assert session is not None
assert len(session.list_tools_calls) == 3
# First call has no cursor; second/third use the cursor from the prior page.
assert session.list_tools_calls[0] is None
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
@pytest.mark.asyncio
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
"""tools/list reuses the same cached MCP session as a regular call_tool."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(tool_name="search"))
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "ReadTimeout" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session = None
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "not connected" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_does_not_call_call_tool(self) -> None:
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
handler = DefaultMCPToolHandler()
call_tool_invoked = False
def fail(**_a: Any) -> Any:
nonlocal call_tool_invoked
call_tool_invoked = True
raise AssertionError("call_tool should not run for tools/list")
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].call_handler = fail
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
FakeListToolsResult(tools=[]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert call_tool_invoked is False
assert result.is_error is False
def test_class_attribute_value(self) -> None:
# Constant must equal the MCP protocol method name so a single
# string travels unchanged through host code, YAML, and the wire.
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,139 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke a Foundry toolbox MCP endpoint from a declarative workflow.
The workflow calls ``microsoft_docs_search`` (the Microsoft Learn Docs
MCP server, bundled into a sample toolbox by ``toolbox_provisioning``)
through the toolbox proxy and asks a Foundry agent to summarise the
result.
Required env vars:
FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL.
Run with:
python samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py
"""
import asyncio
import os
from collections.abc import Generator
from pathlib import Path
import httpx
from agent_framework import Agent
from agent_framework.declarative import (
DefaultMCPToolHandler,
MCPToolInvocation,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from azure.core.credentials import TokenCredential
from azure.identity import AzureCliCredential, get_bearer_token_provider
from toolbox_provisioning import FOUNDRY_FEATURES_HEADERS, create_sample_toolbox
AGENT_NAME = "FoundryToolboxMcpAgent"
TOOLBOX_NAME = "declarative_foundry_toolbox_mcp"
DOCS_SERVER_LABEL = "microsoft_docs"
AGENT_INSTRUCTIONS = """\
Answer the user's question using ONLY the Microsoft Learn docs search
result already present in the conversation. Cite document titles or
URLs when available. If the result does not contain an answer, say so
plainly rather than guessing.
"""
class _BearerAuth(httpx.Auth):
"""Inject a fresh Azure AD bearer token on every request."""
def __init__(self, credential: TokenCredential) -> None:
self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
async def main() -> None:
"""Run the Foundry toolbox MCP workflow."""
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
model = os.environ["FOUNDRY_MODEL"]
print("=" * 60)
print("Invoke Foundry Toolbox MCP Workflow Demo")
print("=" * 60)
print(f"Provisioning toolbox '{TOOLBOX_NAME}' in Foundry...")
create_sample_toolbox(
name=TOOLBOX_NAME,
docs_server_label=DOCS_SERVER_LABEL,
project_endpoint=project_endpoint,
)
toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1"
print(f"Toolbox endpoint: {toolbox_endpoint}")
print()
credential = AzureCliCredential()
chat_client = FoundryChatClient(project_endpoint=project_endpoint, model=model, credential=credential)
summary_agent = Agent(client=chat_client, name=AGENT_NAME, instructions=AGENT_INSTRUCTIONS)
# ``headers=`` attaches the Foundry-Features preview flag on every
# request, including the MCP ``initialize`` handshake (the YAML's
# per-action ``headers`` only takes effect during ``call_tool``).
# ``timeout=`` matches the MCP-recommended values; httpx's 5s
# default breaks long-running tool calls.
http_client = httpx.AsyncClient(
auth=_BearerAuth(credential),
headers=FOUNDRY_FEATURES_HEADERS,
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
)
async def _client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient | None:
# Fail closed when the YAML resolves a different ``serverUrl``
# so the bearer-bound client cannot be reused against an
# unexpected endpoint and ``DefaultMCPToolHandler`` cannot
# silently fall back to an unauthenticated client.
if invocation.server_url.casefold() != toolbox_endpoint.casefold():
raise ValueError(
f"Refusing to attach Foundry bearer token to unexpected MCP URL: "
f"{invocation.server_url!r}. Expected: {toolbox_endpoint!r}."
)
return http_client
async with (
http_client,
DefaultMCPToolHandler(client_provider=_client_provider) as mcp_handler,
):
factory = WorkflowFactory(
agents={AGENT_NAME: summary_agent},
mcp_tool_handler=mcp_handler,
configuration={
"FOUNDRY_TOOLBOX_MCP_SERVER_URL": toolbox_endpoint,
"FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL": DOCS_SERVER_LABEL,
},
)
workflow = factory.create_workflow_from_yaml_path(Path(__file__).parent / "workflow.yaml")
print("Ask a question that can be answered from the Microsoft Learn docs.")
print()
user_input = input("You: ").strip() or "How do I configure logging in the Agent Framework?" # noqa: ASYNC250
printed_prefix = False
async for event in workflow.run({"text": user_input}, stream=True):
if event.type == "executor_invoked":
if event.executor_id == "search_docs_with_toolbox":
print("[Searching Microsoft Learn docs...]")
elif event.executor_id == "summarize_toolbox_result":
print("[Summarizing results...]")
elif event.type == "output" and isinstance(event.data, str):
if not printed_prefix:
print("\nAgent: ", end="", flush=True)
printed_prefix = True
print(event.data, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,62 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry toolbox provisioning helper for ``invoke_foundry_toolbox_mcp``.
Toolboxes are normally created through the Foundry portal or a separate
deployment script. Bundling the one-off setup here lets the sample run
end-to-end without manual steps. ``main.py`` owns the workflow
execution path.
"""
from collections.abc import Mapping
from azure.identity import AzureCliCredential
# Toolbox admin and MCP runtime traffic are both gated by a preview
# feature flag. The Python ``AIProjectClient`` does not add it
# automatically, so we attach it to every admin call here AND to the
# ``httpx.AsyncClient`` in ``main.py`` so the MCP ``initialize``
# handshake carries it too. Without the flag on admin calls,
# provisioning succeeds at the HTTP layer but the toolbox is never
# wired to the MCP endpoint — surfacing later as "MCP server failed to
# initialize: Session terminated" on the first ``InvokeMcpTool`` call.
FOUNDRY_FEATURES_HEADERS: Mapping[str, str] = {"Foundry-Features": "Toolboxes=V1Preview"}
def create_sample_toolbox(*, name: str, docs_server_label: str, project_endpoint: str) -> None:
"""Provision a toolbox version (delete-then-create; idempotent).
Bundles the Microsoft Learn Docs MCP server under ``docs_server_label``.
Uses ``AzureCliCredential`` because the sample expects ``az login``;
switch to a managed identity or service principal for production
deployments.
"""
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import MCPTool, Tool
from azure.core.exceptions import ResourceNotFoundError
with (
AzureCliCredential() as credential,
AIProjectClient(credential=credential, endpoint=project_endpoint) as project_client,
):
try:
project_client.beta.toolboxes.delete(name, headers=FOUNDRY_FEATURES_HEADERS)
print(f"Toolbox '{name}' deleted (replacing with a fresh version).")
except ResourceNotFoundError:
pass
tools: list[Tool] = [
MCPTool(
server_label=docs_server_label,
server_url="https://learn.microsoft.com/api/mcp",
require_approval="never",
),
]
created = project_client.beta.toolboxes.create_version(
name=name,
description="Sample toolbox exposing the Microsoft Learn Docs MCP server.",
tools=tools,
headers=FOUNDRY_FEATURES_HEADERS,
)
print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s)).")
@@ -0,0 +1,48 @@
#
# Calls the Microsoft Learn Docs MCP server through a Foundry toolbox
# proxy from a declarative workflow, then asks a Foundry agent to
# summarise the result. The toolbox surfaces MCP-server-backed tools
# as ``<server_label>___<tool_name>``.
#
# Workflow inputs:
# text: The user's question (required).
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =Workflow.Inputs.text
# ``autoSend: false`` so the raw JSON tool result is not echoed to
# the host's output stream; ``conversationId`` still appends it to
# the conversation so the summarising agent can read it.
- kind: InvokeMcpTool
id: search_docs_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
query: =Local.SearchQuery
output:
autoSend: false
result: Local.SearchResult
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: '=Concat("Answer the query using the Microsoft Learn docs result already in the conversation: ", Local.SearchQuery)'
output:
autoSend: true
messages: Local.Summary