mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea7b45290 | ||
|
|
894b2c6ce0 | ||
|
|
111648ee5b | ||
|
|
41eac64ee3 | ||
|
|
e642371a20 | ||
|
|
c30f104b20 | ||
|
|
800a58d6c1 |
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+9
-9
@@ -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");
|
||||
+1
-45
@@ -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);
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+19
-31
@@ -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
|
||||
{
|
||||
+15
-15
@@ -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.
|
||||
+1
-1
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+4
-6
@@ -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();
|
||||
|
||||
|
||||
+5
-4
@@ -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();
|
||||
|
||||
+2
-3
@@ -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();
|
||||
|
||||
|
||||
+5
-4
@@ -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();
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+5
-4
@@ -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
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
-41
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
-17
@@ -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();
|
||||
|
||||
+5
-5
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+11
-16
@@ -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://<host>/.../projects/<project>/agents/<agentName>/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://<host>/.../projects/<project>/agents/<agentName>/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://<host>/.../projects/<project>/agents/<agentName>/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.
|
||||
|
||||
+5
-5
@@ -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; } = [];
|
||||
}
|
||||
+5
-5
@@ -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; } = [];
|
||||
}
|
||||
+81
-81
@@ -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,
|
||||
}),
|
||||
];
|
||||
+7
-7
@@ -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; }
|
||||
}
|
||||
+9
-9
@@ -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
-6
@@ -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,
|
||||
}),
|
||||
|
||||
|
||||
+1
-1
@@ -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" />
|
||||
|
||||
-127
@@ -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
|
||||
}
|
||||
|
||||
+93
-93
@@ -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());
|
||||
|
||||
+128
-3
@@ -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,
|
||||
|
||||
+16
@@ -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())
|
||||
+62
@@ -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
|
||||
Reference in New Issue
Block a user