Compare commits

..
198 changed files with 4463 additions and 15863 deletions
-163
View File
@@ -1,163 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
function getPullRequest(context) {
const pullRequest = context.payload.pull_request;
if (!pullRequest?.number || !pullRequest.user?.login) {
throw new Error('This script must be run from a pull_request_target event.');
}
return {
author: pullRequest.user.login,
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
number: pullRequest.number,
};
}
async function ensureLabel({ github, owner, repo, labelName }) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
try {
await github.rest.issues.createLabel({
owner,
repo,
name: labelName,
color: 'd93f0b',
description: 'Community author has exceeded the open pull request limit.',
});
} catch (createError) {
if (createError.status !== 422) {
throw createError;
}
}
}
}
function hasLabel(labels, labelName) {
if (!labelName) {
return false;
}
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
}
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
return [
`Thank you for your contribution, @${author}.`,
'',
`To keep the review queue manageable, we currently limit community contributors to ${maxOpenPrs} `
+ `open pull requests at a time. This PR would put you at ${openPrCount} open pull requests, `
+ 'so we are closing it automatically.',
'',
'Please focus on getting your existing PRs reviewed, merged, or closed before opening another one. '
+ `If a maintainer asked you to open this PR, they can apply the \`${exemptLabelName}\` label and reopen it.`,
].join('\n');
}
async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) {
const query = `repo:${owner}/${repo} is:pr is:open author:${author}`;
const response = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 100,
});
const indexedPrNumbers = response.data.items.map((item) => item.number);
const currentPrIsIndexed = indexedPrNumbers.includes(pullRequestNumber);
if (currentPrIsIndexed || response.data.total_count >= 100) {
return response.data.total_count;
}
return response.data.total_count + 1;
}
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
const { owner, repo } = context.repo;
const { author, labels, number } = getPullRequest(context);
if (hasLabel(labels, exemptLabelName)) {
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
return {
author,
closed: false,
exempt: true,
openPrCount: null,
};
}
const openPrCount = await getOpenPrCount({
github,
owner,
repo,
author,
pullRequestNumber: number,
});
if (openPrCount <= maxOpenPrs) {
core.info(
`${author} has ${openPrCount} open pull request(s), which is within the limit of ${maxOpenPrs}.`,
);
return {
author,
closed: false,
openPrCount,
};
}
await ensureLabel({
github,
owner,
repo,
labelName,
});
await github.rest.issues.addLabels({
owner,
repo,
issue_number: number,
labels: [labelName],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: number,
body: buildLimitMessage({
author,
exemptLabelName,
maxOpenPrs,
openPrCount,
}),
});
await github.rest.pulls.update({
owner,
repo,
pull_number: number,
state: 'closed',
});
core.info(
`${author} has ${openPrCount} open pull request(s), which exceeds the limit of ${maxOpenPrs}. `
+ `Closed PR #${number}.`,
);
return {
author,
closed: true,
openPrCount,
};
}
module.exports = {
buildLimitMessage,
enforcePrLimit,
getOpenPrCount,
};
-286
View File
@@ -1,286 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for pr_limit_moderation.js.
*
* Run with: node --test .github/tests/test_pr_limit_moderation.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
return {
repo: {
owner: 'microsoft',
repo: 'agent-framework',
},
payload: {
pull_request: {
number,
labels: labels.map((name) => ({ name })),
user: {
login: author,
},
},
},
};
}
function createCore() {
const messages = [];
return {
messages,
info(message) {
messages.push(message);
},
};
}
function createGithub({ totalCount, itemNumbers, labelExists = true }) {
const calls = [];
return {
calls,
rest: {
search: {
async issuesAndPullRequests(params) {
calls.push({ api: 'search.issuesAndPullRequests', params });
return {
data: {
total_count: totalCount,
items: itemNumbers.map((number) => ({ number })),
},
};
},
},
issues: {
async getLabel(params) {
calls.push({ api: 'issues.getLabel', params });
if (!labelExists) {
const error = new Error('Not Found');
error.status = 404;
throw error;
}
return { data: { name: params.name } };
},
async createLabel(params) {
calls.push({ api: 'issues.createLabel', params });
return { data: { name: params.name } };
},
async addLabels(params) {
calls.push({ api: 'issues.addLabels', params });
return { data: [] };
},
async createComment(params) {
calls.push({ api: 'issues.createComment', params });
return { data: { id: 1 } };
},
},
pulls: {
async update(params) {
calls.push({ api: 'pulls.update', params });
return { data: { state: params.state } };
},
},
},
};
}
// ---------------------------------------------------------------------------
// PR limit enforcement
// ---------------------------------------------------------------------------
describe('PR limit enforcement', () => {
it('does not close the PR when the author is at the open PR limit', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123],
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, false);
assert.equal(result.openPrCount, 10);
assert.deepEqual(
github.calls.map((call) => call.api),
['search.issuesAndPullRequests'],
);
});
it('counts the new PR when search has not indexed it yet', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.equal(result.openPrCount, 11);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
});
it('creates the label when it does not already exist', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
assert.equal(
github.calls.find((call) => call.api === 'issues.createLabel').params.name,
'too-many-prs',
);
});
it('tolerates a 422 race when creating the label', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
github.rest.issues.createLabel = async (params) => {
github.calls.push({ api: 'issues.createLabel', params });
const error = new Error('Validation Failed');
error.status = 422;
throw error;
};
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
});
it('uses a diplomatic close message with the configured limit', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
});
await enforcePrLimit({
github,
context: createContext({ author: 'octo-contributor' }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
assert.match(comment, /Thank you for your contribution/);
assert.match(comment, /limit community contributors to 10 open pull requests/);
assert.match(comment, /@octo-contributor/);
assert.match(comment, /`pr-limit-exempt` label and reopen/);
});
it('does not close an exempt PR when it is reopened', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
});
const result = await enforcePrLimit({
github,
context: createContext({ labels: ['PR-LIMIT-EXEMPT'] }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, false);
assert.equal(result.exempt, true);
assert.equal(result.openPrCount, null);
assert.deepEqual(github.calls, []);
});
it('does not over-count when the current PR is not on the first search page', async () => {
const github = createGithub({
totalCount: 101,
itemNumbers: Array.from({ length: 100 }, (_, index) => index + 1),
});
const result = await enforcePrLimit({
github,
context: createContext({ number: 123 }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.equal(result.openPrCount, 101);
});
});
-83
View File
@@ -1,83 +0,0 @@
name: Limit community pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: pr-limit-${{ github.repository }}-${{ github.event.pull_request.user.login }}
cancel-in-progress: false
env:
MAX_OPEN_PULL_REQUESTS: '10'
PR_LIMIT_EXEMPT_LABEL: pr-limit-exempt
TOO_MANY_PRS_LABEL: too-many-prs
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.PR_NUMBER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; skipping open PR limit.`);
} else {
core.info(`Author ${author} is not a team member; checking open PR limit.`);
}
limit_open_prs:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Enforce open PR limit
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
await enforcePrLimit({
github,
context,
core,
exemptLabelName: process.env.PR_LIMIT_EXEMPT_LABEL,
maxOpenPrs: Number.parseInt(process.env.MAX_OPEN_PULL_REQUESTS, 10),
labelName: process.env.TOO_MANY_PRS_LABEL,
});
@@ -23,14 +23,6 @@ jobs:
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Chrome for Puppeteer
run: npx puppeteer browsers install chrome
# Checks the status of hyperlinks in all files
- name: Run linkspector
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
+5 -5
View File
@@ -22,14 +22,14 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.25" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<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.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.56.0" />
<PackageVersion Include="Azure.Core" Version="1.55.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.12.0" />
<PackageVersion Include="System.ClientModel" Version="1.11.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" />
-2
View File
@@ -174,7 +174,6 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -605,7 +604,6 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
+1 -4
View File
@@ -20,17 +20,14 @@
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.8.0</VersionPrefix>
<VersionPrefix>1.7.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260528</DateSuffix>
<DateSuffix>260526</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.8.0</GitTag>
<GitTag>1.7.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -1,93 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox MCP Skills.
//
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
// Toolbox endpoint and inject them as AIContextProviders so the agent can
// discover and use them at runtime.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using ModelContextProtocol.Client;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
TokenCredential credential = new DefaultAzureCredential();
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
// --- Connect to the Foundry Toolbox MCP endpoint ---
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// --- Discover MCP-based skills ---
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// --- Create the agent ---
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
AIAgent agent = aiProjectClient.AsAIAgent(
options: new ChatClientAgentOptions
{
Name = "ToolboxMcpSkillsAgent",
ChatOptions = new()
{
ModelId = deploymentName,
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
});
// --- Interactive prompt ---
Console.Write("User: ");
string? query = Console.ReadLine();
if (string.IsNullOrWhiteSpace(query))
{
Console.WriteLine("No input provided.");
return;
}
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -1,32 +0,0 @@
# Foundry Toolbox MCP Skills
This sample uses
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
## Prerequisites
- A Microsoft Foundry project with a toolbox already configured
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
```
## Run the sample
```powershell
dotnet run
```
@@ -74,7 +74,6 @@ Some samples require extra tool-specific environment variables. See each sample
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
## Running the samples
@@ -72,95 +72,6 @@ public static class AnsiEscapes
/// </summary>
public static string ResetAttributes => "\x1b[0m";
/// <summary>
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
/// </summary>
/// <remarks>
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
/// combining characters, variation selectors, and East Asian wide characters may be
/// measured incorrectly. For the console harness this is acceptable since content is
/// predominantly ASCII, and emoji are padded with surrounding spaces.
/// </remarks>
public static int VisibleLength(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int length = 0;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
{
// Skip the ESC[ and all characters up to and including the final byte (0x400x7E).
i += 2;
while (i < text.Length && text[i] < 0x40)
{
i++;
}
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
}
else if (text[i] != '\n' && text[i] != '\r')
{
length++;
}
}
return length;
}
/// <summary>
/// Counts the number of physical terminal rows a text item will occupy,
/// accounting for both explicit newlines and terminal line wrapping.
/// </summary>
/// <param name="text">The text to measure.</param>
/// <param name="terminalWidth">The terminal width in columns. If &lt;= 0, wrapping is ignored (1 row per logical line).</param>
/// <returns>The number of physical rows the text occupies.</returns>
public static int CountPhysicalLines(string text, int terminalWidth)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int physicalLines = 0;
int lineStart = 0;
for (int i = 0; i <= text.Length; i++)
{
if (i == text.Length || text[i] == '\n')
{
if (terminalWidth <= 0)
{
// No wrapping — each logical line is one physical row
physicalLines += 1;
}
else
{
string logicalLine = text[lineStart..i];
int visibleWidth = VisibleLength(logicalLine);
physicalLines += visibleWidth == 0
? 1
: (visibleWidth - 1) / terminalWidth + 1;
}
lineStart = i + 1;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
physicalLines--;
}
return physicalLines;
}
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
{
ConsoleColor.Black => 30,
@@ -23,18 +23,16 @@ public record TextPanelProps : ConsoleReactiveProps
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in lines) needed to render all items,
/// accounting for terminal line wrapping at the specified width.
/// Calculates the height (in lines) needed to render all items.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
/// <returns>The total number of physical lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
total += CountLines(items[i]);
}
return total;
@@ -49,20 +47,13 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
{
string text = props.Items[i];
string[] lines = text.Split('\n');
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
int itemRow = 0;
int lineCount = CountLines(text);
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
for (int j = 0; j < lineCount; j++)
{
int linePhysicalRows = props.Width > 0
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
: 1;
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(lines[j]);
currentRow += linePhysicalRows;
itemRow += linePhysicalRows;
currentRow++;
}
}
@@ -75,4 +66,29 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
}
}
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -77,12 +77,36 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
Console.Write(props.Items[i]);
}
// Calculate the offset from bottom for the start of the new last item,
// accounting for terminal line wrapping at the available width.
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
// Calculate the offset from bottom for the start of the new last item
int lastItemLines = CountLines(props.Items[^1]);
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
// Update rendered count
this._renderedCount = props.Items.Count;
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -13,11 +13,6 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
/// </summary>
protected static object RenderLock { get; } = new();
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
@@ -45,6 +40,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
where TProps : ConsoleReactiveProps
where TState : ConsoleReactiveState
{
private readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;
@@ -78,7 +74,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// </summary>
public override void Render()
{
lock (RenderLock)
lock (this._renderLock)
{
if (this.Props is null)
{
@@ -101,7 +97,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <inheritdoc/>
public override void Invalidate()
{
lock (RenderLock)
lock (this._renderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
@@ -28,7 +28,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
private BottomPanelMode _lastRenderedBottomPanelMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
@@ -342,7 +341,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
@@ -407,14 +406,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
bottomChild = this._textInput;
}
// When the bottom panel mode changes, the new child must repaint even if its
// props haven't changed — the screen area was overwritten by the previous child.
if (state.Mode != this._lastRenderedBottomPanelMode)
{
bottomChild.Invalidate();
this._lastRenderedBottomPanelMode = state.Mode;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
@@ -88,17 +88,16 @@ public sealed class PlanningOutputObserver : ConsoleObserver
{
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
}
catch (JsonException)
catch (JsonException ex)
{
// JSON parsing failed — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
return null;
}
if (planningResponse is null)
{
// Null result — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
return null;
}
@@ -119,8 +118,7 @@ public sealed class PlanningOutputObserver : ConsoleObserver
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
}
// Unexpected type — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
return null;
}
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
/// and structured output for complete/remove operations.
/// </summary>
public sealed class TodoToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"todos_add" => FormatAddTodos(call),
"todos_complete" => FormatCompleteTodos(call),
"todos_remove" => FormatIdList(call, "ids", "Remove"),
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatCompleteTodos(call),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
@@ -2,7 +2,6 @@
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text.Json;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -54,94 +53,9 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
case StreamingResponseIncompleteUpdate incompleteUpdate:
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
{
string detail = GetContentFilterDetails(incompleteUpdate);
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
await ux.WriteInfoLineAsync(
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
ConsoleColor.Yellow);
}
else
{
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
}
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
break;
}
}
/// <summary>
/// Extracts content filter details from the serialized response JSON and returns
/// a formatted string showing which specific categories were triggered.
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
/// </summary>
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
{
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
using var doc = JsonDocument.Parse(data.ToString());
var root = doc.RootElement;
// Navigate into the nested response object if present.
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|| filtersArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
foreach (var filter in filtersArray.EnumerateArray())
{
if (!filter.TryGetProperty("content_filter_results", out var results)
|| results.ValueKind != JsonValueKind.Object)
{
continue;
}
// Collect category data for aligned output.
var categories = new List<(string Name, bool Filtered, string? Severity)>();
foreach (var category in results.EnumerateObject())
{
if (category.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
categories.Add((category.Name, filtered, severity));
}
// Build all category lines into a single string.
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
var lines = new List<string>();
foreach (var (name, filtered, severity) in categories)
{
string paddedName = name.PadRight(maxNameLen);
string icon = filtered ? "❌" : "✅";
string statusText = filtered ? "Filtered " : "Not Filtered";
string severityText = severity is not null ? $" Severity: {severity}" : "";
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
}
if (lines.Count > 0)
{
return string.Join("\n", lines);
}
}
return string.Empty;
}
catch
{
// Parsing not critical — skip silently if it fails.
return string.Empty;
}
}
}
@@ -13,10 +13,8 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -13,10 +13,8 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -101,10 +101,6 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -49,10 +49,6 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
@@ -28,10 +28,6 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -152,10 +148,6 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
app.MapOpenApi();
@@ -297,7 +297,7 @@ public class AgentResponse
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt ?? this.CreatedAt,
CreatedAt = this.CreatedAt,
};
}
@@ -27,7 +27,6 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -28,23 +28,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
/// dimension, so when a persistent store is registered any caller who knows or
/// guesses another caller's <c>contextId</c> can resume that other caller's
/// persisted thread. Hosts that serve more than one user must compose a principal
/// dimension into the lookup key — typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
/// conversation identifier, which is appropriate for first-run / single-user /
/// prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// </remarks>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
@@ -63,13 +46,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -89,13 +65,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -114,13 +83,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -152,13 +114,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -185,17 +140,9 @@ public static class A2AServerServiceCollectionExtensions
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
}
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore);
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
agentHandler = new A2AAgentHandler(hostAgent, runMode);
}
@@ -73,26 +73,6 @@ public static class AGUIEndpointRouteBuilderExtensions
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
/// </para>
/// <para>
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
/// principal/owner dimension, so when a persistent store is registered any caller
/// who knows or guesses another caller's <c>ThreadId</c> can resume that other
/// caller's persisted thread. Hosts that serve more than one user must compose a
/// principal dimension into the lookup key. The recommended way is to wrap the
/// keyed <see cref="AgentSessionStore"/> in
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
/// isolation provider is registered, behavior is unchanged — the bare
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
/// for first-run / single-user / prototyping scenarios but unsafe for
/// multi-user hosts.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
@@ -1,78 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// <para>
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
/// </para>
/// <para>
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
/// to provide access to the current <see cref="HttpContext"/>.
/// </para>
/// </remarks>
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly string _claimType;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
/// </param>
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
/// <exception cref="ArgumentException">
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// </exception>
public ClaimsIdentitySessionIsolationKeyProvider(
IHttpContextAccessor? httpContextAccessor,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
this._httpContextAccessor = httpContextAccessor;
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
}
/// <summary>
/// Extracts the session isolation key from the current user's claims.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the value of the
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
/// is not present or the HTTP context is unavailable.
/// </returns>
/// <remarks>
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
/// of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
Claim? claim = this._httpContextAccessor?
.HttpContext?
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
return new ValueTask<string?>(claim?.Value);
}
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Security.Claims;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderOptions
{
/// <summary>
/// Gets or sets the claim type to extract from the user's identity for session isolation.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
/// the user's name or unique identifier claim.
/// </para>
/// <para>
/// Common alternatives include:
/// <list type="bullet">
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
/// <item><description>Custom claim types specific to your authentication provider</description></item>
/// </list>
/// </para>
/// </remarks>
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
}
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn)</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
</PropertyGroup>
</Project>
@@ -1,42 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate session isolation keys.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
/// </remarks>
public static IServiceCollection UseClaimsBasedSessionIsolation(
this IServiceCollection services,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new();
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
services.Add(descriptor);
return services;
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
{
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
}
}
}
@@ -1,9 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -11,39 +9,9 @@ namespace Microsoft.Agents.AI.Hosting;
/// Defines the contract for storing and retrieving agent conversation threads.
/// </summary>
/// <remarks>
/// <para>
/// Implementations of this interface enable persistent storage of conversation threads,
/// allowing conversations to be resumed across HTTP requests, application restarts,
/// or different service instances in hosted scenarios.
/// </para>
/// <para>
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
/// dimension. Hosts that serve more than one user from the same registered store must
/// therefore compose a principal dimension into the lookup key, otherwise any caller
/// who knows or guesses another caller's <c>conversationId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>conversationId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
/// store behaves as a single-namespace persistence layer — appropriate for
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// <para>
/// <strong>Implementer guidance.</strong> Implementations should treat
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
/// or character-set constraints on it, and do not assume it round-trips to the value
/// the caller originally supplied (decorators such as
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
/// Be aware that any logging, telemetry, or audit sink that surfaces
/// <c>conversationId</c> will also surface the isolation prefix when a
/// scoping decorator is in the chain.
/// </para>
/// </remarks>
public abstract class AgentSessionStore
{
@@ -75,35 +43,4 @@ public abstract class AgentSessionStore
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
/// to verify that specific store implementations are present.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
/// to verify that specific store implementations are present.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}
@@ -1,81 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
/// instance while allowing for extensibility and customization.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
/// underlying store.
/// </para>
/// <para>
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
/// interface.
/// </para>
/// </remarks>
public abstract class DelegatingAgentSessionStore : AgentSessionStore
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
/// store.
/// </summary>
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
/// derived classes will be forwarded to this store.
/// </remarks>
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
{
this.InnerStore = Throw.IfNull(innerStore);
}
/// <summary>
/// Gets the inner session store instance that receives delegated operations.
/// </summary>
/// <value>
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
/// </value>
/// <remarks>
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
/// or to forward operations with additional processing.
/// </remarks>
protected AgentSessionStore InnerStore { get; }
/// <inheritdoc/>
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
/// <inheritdoc/>
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
/// <inheritdoc/>
/// <remarks>
/// This implementation first checks if this instance satisfies the service request.
/// If not, it chains the request to the inner store, allowing services to be retrieved
/// from any store in the delegation chain.
/// </remarks>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
// First, check if this instance satisfies the request
object? service = base.GetService(serviceType, serviceKey);
if (service is not null)
{
return service;
}
// Chain to the inner store
return this.InnerStore.GetService(serviceType, serviceKey);
}
}
@@ -3,7 +3,6 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -17,11 +16,12 @@ public static class HostedAgentBuilderExtensions
/// Configures the host agent builder to use an in-memory session store for agent session management.
/// </summary>
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
{
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
return builder;
}
/// <summary>
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
@@ -29,11 +29,12 @@ public static class HostedAgentBuilderExtensions
/// </summary>
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
{
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
return builder;
}
/// <summary>
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
@@ -43,36 +44,16 @@ public static class HostedAgentBuilderExtensions
/// name.</param>
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
return createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
// Best efforts options getting
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
if (options is null)
{
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
options = optionsProvider?.Value;
}
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
}
return store;
}, lifetime);
return builder;
}
@@ -1,109 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
/// </summary>
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="innerStore"/> is <see langword="null"/>.
/// </exception>
public IsolationKeyScopedAgentSessionStore(
AgentSessionStore innerStore,
SessionIsolationKeyProvider? keyProvider,
IsolationKeyScopedAgentSessionStoreOptions? options = null)
: base(innerStore)
{
this._keyProvider = keyProvider;
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
this._strict = options.Strict;
}
/// <summary>
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The provider returned <see langword="null"/> and strict mode is enabled.
/// </exception>
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
}
return key;
}
/// <summary>
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
/// </summary>
/// <param name="key">The raw isolation key.</param>
/// <returns>The escaped isolation key.</returns>
/// <remarks>
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
/// </remarks>
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
/// <summary>
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
/// </summary>
/// <param name="bareConversationId">The original conversation ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
/// if no isolation key is available and non-strict mode is enabled.
/// </returns>
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
if (key == null)
{
return bareConversationId;
}
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
}
/// <inheritdoc />
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
}
}
@@ -1,25 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
/// </summary>
public class IsolationKeyScopedAgentSessionStoreOptions
{
/// <summary>
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
/// </summary>
/// <remarks>
/// <para>
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
/// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
/// or mixed environments where not all requests have isolation keys.
/// </para>
/// </remarks>
public bool Strict { get; set; } = true;
}
@@ -24,20 +24,6 @@ namespace Microsoft.Agents.AI.Hosting;
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
/// such as Redis, SQL Server, or Azure Cosmos DB.
/// </para>
/// <para>
/// <strong>Multi-user warning.</strong> This store keys threads by
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
/// the conversation identifier originates from the wire (for example, an AG-UI
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
/// scoped per principal. See the trust-model remarks on
/// <see cref="AgentSessionStore"/> for the full background.
/// </para>
/// </remarks>
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
@@ -1,39 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
/// </summary>
/// <remarks>
/// <para>
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
/// implement the key resolution logic appropriate to their hosting environment.
/// </para>
/// <para>
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
/// to unscoped storage based on its configuration.
/// </para>
/// </remarks>
public abstract class SessionIsolationKeyProvider
{
/// <summary>
/// Asynchronously retrieves the session isolation key for the current request or execution context.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
/// or <see langword="null"/> if no key is available in the current context.
/// </returns>
/// <remarks>
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
/// the caller to decide on strict vs. pass-through behavior.
/// </remarks>
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
}
@@ -75,14 +75,18 @@ internal sealed class InvokeMcpToolExecutor(
if (requireApproval)
{
// Create tool call content for approval request.
// Transport headers (e.g. Authorization) are intentionally excluded from the
// approval event: they must not cross into the externally-surfaced approval request.
// Create tool call content for approval request
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
{
Arguments = arguments
};
if (headers != null)
{
toolCall.AdditionalProperties ??= [];
toolCall.AdditionalProperties.Add(headers);
}
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -20,28 +19,6 @@ public sealed class AgentResponseEvent : WorkflowOutputEvent
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tag.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
/// <param name="tag">The output tag to associate with this event.</param>
public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag)
{
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tags.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable<OutputTag>? tags) : base(response, executorId, tags)
{
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Gets the agent response.
/// </summary>
@@ -20,28 +20,6 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tag.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
/// <param name="tag">The output tag to associate with this event.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag)
{
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tags.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable<OutputTag>? tags) : base(update, executorId, tags)
{
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Gets the agent run response update.
/// </summary>
@@ -2,6 +2,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -33,10 +37,31 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNullOrEmpty(agents);
SequentialWorkflowBuilder builder = new(agents);
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
builder.AddEdge(previous, next);
previous = next;
}
OutputMessagesExecutor end = new();
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
if (workflowName is not null)
{
builder.WithName(workflowName);
builder = builder.WithName(workflowName);
}
return builder.Build();
}
@@ -82,14 +107,41 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNull(agents);
ConcurrentWorkflowBuilder builder = new(agents);
// A workflow needs a starting executor, so we create one that forwards everything to each agent.
ChatForwardingExecutor start = new("Start");
WorkflowBuilder builder = new(start);
// For each agent, we create an executor to host it and an accumulator to batch up its output messages,
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorBinding[] agentExecutors = (from agent in agents
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
}
// Create the accumulating executor that will gather the results from each agent, and connect
// each agent's accumulator to it. If no aggregation function was provided, we default to returning
// the last message from each agent
aggregator ??= static lists => (from list in lists where list.Count > 0 select list.Last()).ToList();
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInBarrierEdge(accumulators, end);
builder = builder.WithOutputFrom(end);
if (workflowName is not null)
{
builder.WithName(workflowName);
}
if (aggregator is not null)
{
builder.WithAggregator(aggregator);
builder = builder.WithName(workflowName);
}
return builder.Build();
}
@@ -103,6 +155,7 @@ public static partial class AgentWorkflowBuilder
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
@@ -126,31 +179,4 @@ public static partial class AgentWorkflowBuilder
Throw.IfNull(managerFactory);
return new GroupChatWorkflowBuilder(managerFactory);
}
/// <summary>Creates a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline of <paramref name="agents"/>.</summary>
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
/// <returns>The builder for creating a sequential workflow.</returns>
public static SequentialWorkflowBuilder CreateSequentialBuilderWith(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
return new SequentialWorkflowBuilder(agents);
}
/// <summary>Creates a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating <paramref name="agents"/>.</summary>
/// <param name="agents">The set of agents to compose into a concurrent workflow.</param>
/// <returns>The builder for creating a concurrent workflow.</returns>
public static ConcurrentWorkflowBuilder CreateConcurrentBuilderWith(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
return new ConcurrentWorkflowBuilder(agents);
}
/// <summary>Creates a new <see cref="MagenticWorkflowBuilder"/> with the given <paramref name="managerAgent"/>.</summary>
/// <param name="managerAgent">The LLM-powered manager agent that coordinates the team.</param>
/// <returns>The builder for creating a Magentic workflow.</returns>
public static MagenticWorkflowBuilder CreateMagenticBuilderWith(AIAgent managerAgent)
{
Throw.IfNull(managerAgent);
return new MagenticWorkflowBuilder(managerAgent);
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
@@ -16,14 +15,14 @@ internal sealed class WorkflowInfo
Dictionary<string, List<EdgeInfo>> edges,
HashSet<RequestPortInfo> requestPorts,
string startExecutorId,
Dictionary<string, HashSet<OutputTag>>? outputExecutorIds)
HashSet<string>? outputExecutorIds)
{
this.Executors = Throw.IfNull(executors);
this.Edges = Throw.IfNull(edges);
this.RequestPorts = Throw.IfNull(requestPorts);
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
this.OutputExecutorIds = outputExecutorIds ?? new Dictionary<string, HashSet<OutputTag>>(StringComparer.Ordinal);
this.OutputExecutorIds = outputExecutorIds ?? [];
}
public Dictionary<string, ExecutorInfo> Executors { get; }
@@ -33,15 +32,7 @@ internal sealed class WorkflowInfo
public TypeId? InputType { get; }
public string StartExecutorId { get; }
/// <summary>
/// Map of executor id to the set of <see cref="OutputTag"/>s under which the executor is registered.
/// An empty set means the executor is registered as a regular (untagged) output source.
/// JSON shape: <c>{ "executorId": ["intermediate"], ... }</c>. Legacy payloads using the
/// older <c>string[]</c> shape are read by <see cref="WorkflowInfoOutputExecutorsConverter"/> and
/// each id is treated as registered with an empty tag set.
/// </summary>
[JsonConverter(typeof(WorkflowInfoOutputExecutorsConverter))]
public Dictionary<string, HashSet<OutputTag>> OutputExecutorIds { get; }
public HashSet<string> OutputExecutorIds { get; }
public bool IsMatch(Workflow workflow)
{
@@ -89,12 +80,9 @@ internal sealed class WorkflowInfo
return false;
}
// Validate the outputs (key set + tag set per id must match)
// Validate the outputs
if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count ||
this.OutputExecutorIds.Any(kvp =>
!workflow.OutputExecutors.TryGetValue(kvp.Key, out HashSet<OutputTag>? tags) ||
tags.Count != kvp.Value.Count ||
!tags.SetEquals(kvp.Value)))
this.OutputExecutorIds.Any(id => !workflow.OutputExecutors.Contains(id)))
{
return false;
}
@@ -1,122 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <summary>
/// JSON converter for <see cref="WorkflowInfo.OutputExecutorIds"/> that supports both the new
/// map shape (<c>{ "id": ["intermediate"] }</c>) and the legacy array shape
/// (<c>["id1", "id2"]</c>). Legacy-shaped payloads are read as if every id had been registered
/// as a regular (untagged) output source; output is always written in the new map shape.
/// </summary>
internal sealed class WorkflowInfoOutputExecutorsConverter : JsonConverter<Dictionary<string, HashSet<OutputTag>>>
{
public override Dictionary<string, HashSet<OutputTag>> Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Dictionary<string, HashSet<OutputTag>> result = new(StringComparer.Ordinal);
if (reader.TokenType == JsonTokenType.Null)
{
return result;
}
if (reader.TokenType == JsonTokenType.StartArray)
{
// Legacy shape: a flat array of executor ids. Treat each as a registered
// (untagged) output executor.
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
return result;
}
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string in legacy outputExecutorIds array, got {reader.TokenType}.");
}
string id = reader.GetString()!;
result[id] = [];
}
throw new JsonException("Unexpected end of legacy outputExecutorIds array.");
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException($"Expected object or array for outputExecutorIds, got {reader.TokenType}.");
}
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
return result;
}
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException($"Expected property name in outputExecutorIds object, got {reader.TokenType}.");
}
string id = reader.GetString()!;
reader.Read();
HashSet<OutputTag> tags = [];
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string tag, got {reader.TokenType}.");
}
tags.Add(ReadTag(reader.GetString()!));
}
}
else
{
throw new JsonException($"Expected array of tags for outputExecutorIds[{id}], got {reader.TokenType}.");
}
result[id] = tags;
}
throw new JsonException("Unexpected end of outputExecutorIds object.");
}
private static OutputTag ReadTag(string value)
{
if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal))
{
return OutputTag.Intermediate;
}
return new OutputTag(value);
}
public override void Write(
Utf8JsonWriter writer,
Dictionary<string, HashSet<OutputTag>> value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in value)
{
writer.WritePropertyName(kvp.Key);
writer.WriteStartArray();
foreach (OutputTag tag in kvp.Value)
{
writer.WriteStringValue(tag.Value);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
}
@@ -1,104 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for concurrent agent workflows: a fan-out start that broadcasts the
/// incoming messages to every participating agent, a per-agent accumulator that batches
/// each agent's outgoing messages, and a fan-in aggregator that reduces them into a
/// single output list.
/// </summary>
/// <remarks>
/// When no explicit output designations are made, the default is the Python-aligned
/// shape: the terminal aggregator is the workflow output, and every participating agent
/// (plus its per-agent accumulator) is designated as an intermediate output source.
/// Calling <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(IEnumerable{AIAgent})"/>
/// at all suppresses these defaults.
/// </remarks>
public sealed class ConcurrentWorkflowBuilder : OrchestrationBuilderBase<ConcurrentWorkflowBuilder>
{
private readonly List<AIAgent> _agents = [];
private Func<IList<List<ChatMessage>>, List<ChatMessage>>? _aggregator;
/// <summary>
/// Initializes a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating
/// <paramref name="agents"/>.
/// </summary>
public ConcurrentWorkflowBuilder(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
this._agents.Add(agent);
}
}
/// <summary>
/// Sets the aggregator function. If not called, defaults to returning the last message
/// from each agent that produced at least one message.
/// </summary>
public ConcurrentWorkflowBuilder WithAggregator(Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
{
this._aggregator = Throw.IfNull(aggregator);
return this;
}
/// <summary>Builds the configured concurrent workflow.</summary>
public Workflow Build()
{
if (this._agents.Count == 0)
{
throw new ArgumentException("At least one agent must be provided to the ConcurrentWorkflowBuilder.", "agents");
}
ChatForwardingExecutor start = new("Start");
WorkflowBuilder builder = new(start);
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
ExecutorBinding[] agentExecutors = new ExecutorBinding[this._agents.Count];
ExecutorBinding[] accumulators = new ExecutorBinding[this._agents.Count];
AIAgentHostOptions options = new() { ReassignOtherAgentsAsUsers = true };
for (int i = 0; i < this._agents.Count; i++)
{
AIAgent agent = this._agents[i];
ExecutorBinding binding = agent.BindAsExecutor(options);
agentExecutors[i] = binding;
agentMap[agent] = binding;
accumulators[i] = new AggregateTurnMessagesExecutor($"Batcher/{binding.Id}");
}
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
}
Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator =
this._aggregator ?? (static lists => (from list in lists where list.Count > 0 select list.Last()).ToList());
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInBarrierEdge(accumulators, end);
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "concurrent", () =>
{
builder.WithOutputFrom(end);
builder.WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]);
});
return builder.Build();
}
}
@@ -1,17 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class OutputFilter(Workflow workflow)
{
public bool CanOutput(string sourceExecutorId, object output)
{
return workflow.OutputExecutors.ContainsKey(sourceExecutorId);
return workflow.OutputExecutors.Contains(sourceExecutorId);
}
public bool TryGetTags(string sourceExecutorId, [NotNullWhen(true)] out HashSet<OutputTag>? tags)
=> workflow.OutputExecutors.TryGetValue(sourceExecutorId, out tags);
}
@@ -1,40 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Process-wide opt-in switches for in-development behavior changes that will become
/// the default in a future major release. Each flag defaults to <see langword="false"/>
/// and should be toggled once at application startup.
/// </summary>
public static class Futures
{
/// <summary>
/// When <see langword="true"/>, <see cref="AgentResponse"/> and
/// <see cref="AgentResponseUpdate"/> payloads yielded by an executor participate
/// in the normal output-filter pipeline (i.e. they must be designated via
/// <see cref="WorkflowBuilder.WithOutputFrom(ExecutorBinding[])"/> or
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>
/// to surface), and the resulting <see cref="WorkflowOutputEvent"/>s carry
/// <see cref="WorkflowOutputEvent.Tags"/> reflecting that designation.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="false"/> (the current default), the runner emits
/// <see cref="AgentResponseEvent"/> and <see cref="AgentResponseUpdateEvent"/> unconditionally,
/// bypassing the output filter (historical behavior). Lifecycle: opt-in today, marked
/// <c>[Obsolete]</c> in v2.0.0 when the new behavior becomes default, and removed in v3.0.0.
/// </para>
/// <para>
/// <b>Interaction with <see cref="WorkflowHostingExtensions.AsAIAgent"/>.</b> When this flag
/// is <see langword="true"/>, <see cref="AgentResponseEvent"/> joins
/// <see cref="AgentResponseUpdateEvent"/> in being forwarded out of the agent surface
/// unconditionally — neither honors the host's <c>includeWorkflowOutputsInResponse</c>
/// switch. That switch only governs the generic <see cref="WorkflowOutputEvent"/> path for
/// non-AIAgent payloads. When this flag is <see langword="false"/>, the legacy asymmetry
/// is preserved: <see cref="AgentResponseUpdateEvent"/> is always forwarded but
/// <see cref="AgentResponseEvent"/> stays gated by <c>includeWorkflowOutputsInResponse</c>.
/// </para>
/// </remarks>
public static bool EnableAgentResponseOutputTaggingAndFiltering { get; set; }
}
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -15,16 +13,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public abstract class GroupChatManager
{
// The state key under which GroupChatManager persists its own (non-subclass) state on the
// raw IWorkflowContext supplied by the hosting GroupChatHost executor.
internal const string BaseStateKey = "GroupChatManager";
// Prefix automatically applied to every key a subclass writes through the wrapped context
// supplied to OnCheckpointingAsync / OnCheckpointRestoredAsync. Keeps subclass-defined
// state in its own namespace so it cannot collide with the host's state keys nor with
// BaseStateKey itself.
internal const string SubclassStateKeyPrefix = "GroupChatManager_";
/// <summary>
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
/// </summary>
@@ -60,22 +48,12 @@ public abstract class GroupChatManager
CancellationToken cancellationToken = default);
/// <summary>
/// Filters the messages broadcast to participants for the current turn.
/// Filters the chat history before it's passed to the next agent.
/// </summary>
/// <remarks>
/// Under the broadcast model, each participant maintains its own per-agent session (history)
/// through its <see cref="Specialized.AIAgentHostExecutor"/>. The host distributes new messages
/// (initial user input on the first turn, the most recent speaker's response on subsequent turns)
/// to every participant — except the speaker that produced them — so every participant's session
/// stays synchronized. This method lets the manager shape that broadcast payload (for example,
/// to omit certain messages or to inject orchestrator-visible annotations). The full canonical
/// conversation is still available to <see cref="SelectNextAgentAsync"/> and
/// <see cref="ShouldTerminateAsync"/>.
/// </remarks>
/// <param name="history">The new messages about to be broadcast to participants this turn.</param>
/// <param name="history">The chat history to filter.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The filtered message list to broadcast.</returns>
/// <returns>The filtered chat history.</returns>
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default) =>
@@ -100,125 +78,4 @@ public abstract class GroupChatManager
{
this.IterationCount = 0;
}
/// <summary>
/// Invoked when the hosting group chat workflow is checkpointing, giving subclasses a chance to
/// persist any additional state they maintain (e.g., a round-robin cursor or an LLM session).
/// </summary>
/// <remarks>
/// <para>
/// The default implementation is a no-op. Base-class state (currently
/// <see cref="IterationCount"/>) is persisted automatically by the hosting
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
/// need to call <c>base.OnCheckpointingAsync</c>.
/// </para>
/// <para>
/// The supplied <paramref name="context"/> is a wrapper that transparently prefixes every
/// state key with <c>"GroupChatManager_"</c>, isolating subclass state from the host's own
/// state keys (and from the reserved base-state key). Implementations therefore may use any
/// human-readable key (e.g., <c>"next_index"</c>) without worrying about collisions.
/// </para>
/// </remarks>
/// <param name="context">A wrapped workflow context that scopes state keys to the
/// <see cref="GroupChatManager"/> subclass namespace.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
protected virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
/// <summary>
/// Invoked when the hosting group chat workflow is being restored from a checkpoint, giving
/// subclasses a chance to hydrate any additional state they persisted in
/// <see cref="OnCheckpointingAsync"/>.
/// </summary>
/// <remarks>
/// The default implementation is a no-op. Base-class state (currently
/// <see cref="IterationCount"/>) is restored automatically by the hosting
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
/// need to call <c>base.OnCheckpointRestoredAsync</c>. The supplied <paramref name="context"/>
/// uses the same key-prefixing wrapper as <see cref="OnCheckpointingAsync"/>.
/// </remarks>
/// <param name="context">A wrapped workflow context that scopes state keys to the
/// <see cref="GroupChatManager"/> subclass namespace.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
protected virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
// Root checkpoint entry point invoked by the hosting GroupChatHost. Persists the manager's
// own base state under the reserved BaseStateKey on the raw context, then delegates to the
// subclass-facing OnCheckpointingAsync hook with a wrapped context that prefixes every key
// with SubclassStateKeyPrefix.
internal async ValueTask CheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(BaseStateKey, new GroupChatManagerState(this.IterationCount), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.OnCheckpointingAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
}
// Root restore entry point invoked by the hosting GroupChatHost. Symmetric to CheckpointAsync.
internal async ValueTask RestoreCheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
GroupChatManagerState? state = await context.ReadStateAsync<GroupChatManagerState>(BaseStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
this.IterationCount = state?.IterationCount ?? 0;
await this.OnCheckpointRestoredAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
}
}
internal sealed record GroupChatManagerState(int IterationCount);
// IWorkflowContext decorator that prepends a fixed prefix to every state key passed through it.
// All non-state members (events, message sending, output yielding, halt requests, trace context,
// and runtime characteristics) delegate directly to the wrapped context.
internal sealed class PrefixingWorkflowContext(IWorkflowContext inner, string prefix) : IWorkflowContext
{
private readonly IWorkflowContext _inner = Throw.IfNull(inner);
private readonly string _prefix = Throw.IfNullOrEmpty(prefix);
public IReadOnlyDictionary<string, string>? TraceContext => this._inner.TraceContext;
public bool ConcurrentRunsEnabled => this._inner.ConcurrentRunsEnabled;
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
=> this._inner.AddEventAsync(workflowEvent, cancellationToken);
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default)
=> this._inner.SendMessageAsync(message, targetId, cancellationToken);
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
=> this._inner.YieldOutputAsync(output, cancellationToken);
public ValueTask RequestHaltAsync() => this._inner.RequestHaltAsync();
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._inner.ReadStateAsync<T>(this.Wrap(key), scopeName, cancellationToken);
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._inner.ReadOrInitStateAsync(this.Wrap(key), initialStateFactory, scopeName, cancellationToken);
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
HashSet<string> rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
return [.. rawKeys.Where(k => k.StartsWith(this._prefix, StringComparison.Ordinal))
.Select(k => k.Substring(this._prefix.Length))];
}
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._inner.QueueStateUpdateAsync(this.Wrap(key), value, scopeName, cancellationToken);
public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
// Clearing the entire underlying scope would also remove keys owned by the host and other
// subsystems sharing the executor's default scope. Restrict the clear to keys carrying
// this wrapper's prefix.
HashSet<string> rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
foreach (string rawKey in rawKeys)
{
if (rawKey.StartsWith(this._prefix, StringComparison.Ordinal))
{
await this._inner.QueueStateUpdateAsync<object>(rawKey, null, scopeName, cancellationToken).ConfigureAwait(false);
}
}
}
private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key);
}
@@ -12,10 +12,12 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow.
/// </summary>
public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupChatWorkflowBuilder>
public sealed class GroupChatWorkflowBuilder
{
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
private string _name = string.Empty;
private string _description = string.Empty;
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
@@ -42,6 +44,28 @@ public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupCha
return this;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
/// <param name="name">The name of the workflow.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <summary>
/// Sets the description for the workflow.
/// </summary>
/// <param name="description">The description of what the workflow does.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
/// agent to process messages selected by the group chat manager.
@@ -51,14 +75,10 @@ public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupCha
{
AIAgent[] agents = this._participants.ToArray();
// GroupChatHost owns the canonical conversation and broadcasts messages directly to every
// participant. Participants therefore must not echo their incoming messages back to the host
// (which would cause duplicates), but must still reframe other agents' assistant messages as
// user messages so each agent's own session reads coherently.
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = false
ForwardIncomingMessages = true
};
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
@@ -69,7 +89,15 @@ public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupCha
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
this.ApplyMetadata(builder);
if (!string.IsNullOrEmpty(this._name))
{
builder = builder.WithName(this._name);
}
if (!string.IsNullOrEmpty(this._description))
{
builder = builder.WithDescription(this._description);
}
foreach (var participant in agentMap.Values)
{
@@ -78,15 +106,6 @@ public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupCha
.AddEdge(participant, host);
}
this.ApplyOutputDesignations(builder, agentMap, "group chat", () =>
{
builder.WithOutputFrom(host);
if (agentMap.Count > 0)
{
builder.WithIntermediateOutputFrom([.. agentMap.Values]);
}
});
return builder.Build();
return builder.WithOutputFrom(host).Build();
}
}
@@ -4,7 +4,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -15,6 +14,11 @@ using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorCo
namespace Microsoft.Agents.AI.Workflows;
internal static class DiagnosticConstants
{
public const string ExperimentalFeatureDiagnostic = "MAAIW001";
}
/// <inheritdoc/>
[ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s")
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
@@ -25,6 +29,7 @@ public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkf
}
/// <inheritdoc/>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
{
}
@@ -32,8 +37,8 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl
/// <summary>
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBuilder>
where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
@@ -49,22 +54,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
// handoff triggers another invocation of that same agent with the continuation prompt, up to
// the configured turn limit per workflow turn. Optional per-agent overrides may further restrict
// which agents have autonomous mode enabled, or override the turn limit / continuation prompt
// on a per-agent basis.
private bool _autonomousMode;
private int _autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
private string _autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
private HashSet<string>? _autonomousEnabledAgentIds;
private readonly Dictionary<string, int> _autonomousTurnLimitsByAgentId = [];
private readonly Dictionary<string, string> _autonomousContinuationPromptsByAgentId = [];
// Termination condition. Evaluated after an agent response that does not request a handoff;
// if true, the workflow ends (and the autonomous loop, if any, terminates).
private Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? _terminationCondition;
private string? _name;
private string? _description;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -108,6 +99,20 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public TBuilder WithName(string name)
{
this._name = name;
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public TBuilder WithDescription(string description)
{
this._description = description;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
@@ -253,204 +258,12 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
return (TBuilder)this;
}
/// <summary>
/// Adds the specified <paramref name="agents"/> as participants in the handoff workflow without
/// defining handoff relationships for them.
/// </summary>
/// <param name="agents">The agents to add as participants.</param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// Use this method when you want a participant to be part of the workflow but you have not
/// explicitly defined handoff edges via <see cref="WithHandoff(AIAgent, AIAgent, string?)"/>.
/// When no handoffs are explicitly defined (default handoffs), all registered participants are
/// automatically wired so that every agent can hand off to every other agent.
/// </remarks>
public TBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
if (agent is null)
{
Throw.ArgumentNullException(nameof(agents), "One or more agents are null.");
}
this._allAgents.Add(agent);
}
return (TBuilder)this;
}
/// <summary>
/// Enables autonomous mode for the handoff workflow.
/// </summary>
/// <remarks>
/// <para>
/// In autonomous mode, an agent whose response does not include a handoff is invoked again with
/// a continuation prompt, up to a configured turn limit. The autonomous loop for a given agent
/// ends when the agent invokes a handoff tool, the configured termination condition fires, or
/// the per-agent turn limit is reached — at which point the workflow yields control back to the
/// caller.
/// </para>
/// <para>
/// <b>Per-agent turn counting.</b> Autonomous-turn counters are tracked independently per agent
/// in the shared handoff state. A counter is incremented each time the End executor loops
/// control back to its source agent, and reset to zero in three cases: (1) when that agent
/// requests a handoff, (2) when its autonomous loop terminates (limit reached, termination
/// fires, or autonomous mode disabled for that agent), and (3) at the start of every fresh user
/// turn. As a consequence, if agent A loops twice and then hands off to B, A's counter resets
/// to zero; should control later return to A within the same user turn, A starts a new
/// autonomous run from zero.
/// </para>
/// </remarks>
/// <param name="turnLimit">
/// The default maximum number of autonomous continuation iterations per agent per workflow
/// turn. Applies to agents not listed in <paramref name="agentTurnLimits"/>. If
/// <see langword="null"/>, defaults to
/// <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit"/> (50).
/// </param>
/// <param name="continuationPrompt">
/// The default user-role prompt fed to an agent on each autonomous continuation. Applies to
/// agents not listed in <paramref name="agentContinuationPrompts"/>. If <see langword="null"/>,
/// defaults to <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt"/>.
/// </param>
/// <param name="agents">
/// Optional allow-list restricting autonomous mode to a specific subset of agents. If
/// <see langword="null"/> or empty, autonomous mode is enabled for <i>every</i> participant.
/// Agents not in the allow-list always yield control back to the caller after a single
/// invocation (when they do not request a handoff).
/// </param>
/// <param name="agentTurnLimits">
/// Optional per-agent turn-limit overrides. Each entry's key is the agent and its value the
/// turn limit that overrides <paramref name="turnLimit"/> for that agent. Agents not present
/// fall back to the default.
/// </param>
/// <param name="agentContinuationPrompts">
/// Optional per-agent continuation-prompt overrides. Each entry's key is the agent and its
/// value the continuation prompt used for that agent. Agents not present fall back to the
/// default.
/// </param>
/// <returns>The updated builder instance.</returns>
public TBuilder WithAutonomousMode(
int? turnLimit = null,
string? continuationPrompt = null,
IEnumerable<AIAgent>? agents = null,
IReadOnlyDictionary<AIAgent, int>? agentTurnLimits = null,
IReadOnlyDictionary<AIAgent, string>? agentContinuationPrompts = null)
{
if (turnLimit is { } limit && limit <= 0)
{
Throw.ArgumentOutOfRangeException(nameof(turnLimit), "Turn limit must be greater than zero.");
}
this._autonomousMode = true;
this._autonomousTurnLimit = turnLimit ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit;
this._autonomousContinuationPrompt = continuationPrompt ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt;
// Allow-list: null or empty means every participant has autonomous mode enabled. A non-empty
// list restricts autonomous mode to exactly those agents.
this._autonomousEnabledAgentIds = null;
if (agents is not null)
{
HashSet<string> ids = [];
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, $"{nameof(agents)} element");
ids.Add(agent.Id);
}
if (ids.Count > 0)
{
this._autonomousEnabledAgentIds = ids;
}
}
this._autonomousTurnLimitsByAgentId.Clear();
if (agentTurnLimits is not null)
{
foreach (KeyValuePair<AIAgent, int> kvp in agentTurnLimits)
{
Throw.IfNull(kvp.Key, $"{nameof(agentTurnLimits)} key");
if (kvp.Value <= 0)
{
Throw.ArgumentOutOfRangeException(
nameof(agentTurnLimits),
$"Turn limit for agent '{kvp.Key.Name ?? kvp.Key.Id}' must be greater than zero.");
}
this._autonomousTurnLimitsByAgentId[kvp.Key.Id] = kvp.Value;
}
}
this._autonomousContinuationPromptsByAgentId.Clear();
if (agentContinuationPrompts is not null)
{
foreach (KeyValuePair<AIAgent, string> kvp in agentContinuationPrompts)
{
Throw.IfNull(kvp.Key, $"{nameof(agentContinuationPrompts)} key");
Throw.IfNullOrEmpty(kvp.Value, $"{nameof(agentContinuationPrompts)} value");
this._autonomousContinuationPromptsByAgentId[kvp.Key.Id] = kvp.Value;
}
}
return (TBuilder)this;
}
/// <summary>
/// Sets a synchronous termination condition for the handoff workflow.
/// </summary>
/// <param name="terminationCondition">
/// A predicate that receives the current conversation and returns <see langword="true"/> if the
/// workflow should terminate (preventing further autonomous continuation). The synchronous
/// predicate is wrapped and forwarded to the async overload.
/// </param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// The termination condition is evaluated after the agent produces a response that does not
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
/// another autonomous continuation.
/// </remarks>
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, bool> terminationCondition)
{
Throw.IfNull(terminationCondition);
return this.WithTerminationCondition(
messages => new ValueTask<bool>(terminationCondition(messages)));
}
/// <summary>
/// Sets an asynchronous termination condition for the handoff workflow.
/// </summary>
/// <param name="terminationCondition">
/// A predicate that receives the current conversation and asynchronously returns
/// <see langword="true"/> if the workflow should terminate (preventing further autonomous
/// continuation).
/// </param>
/// <returns>The updated builder instance.</returns>
/// <remarks>
/// The termination condition is evaluated after the agent produces a response that does not
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
/// another autonomous continuation.
/// </remarks>
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, ValueTask<bool>> terminationCondition)
{
Throw.IfNull(terminationCondition);
this._terminationCondition = terminationCondition;
return (TBuilder)this;
}
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder, Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets)
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
{
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior)
{
TerminationCondition = this._terminationCondition,
};
this._toolCallFilteringBehavior);
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
@@ -464,7 +277,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
ExecutorBinding CreateFactoryBinding(AIAgent agent)
{
if (!effectiveTargets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
{
handoffs = new();
}
@@ -474,16 +287,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
{
foreach (HandoffTarget handoff in handoffs)
{
// Each handoff case also requires the turn to NOT be terminated; otherwise the
// turn falls through to the default branch, which routes to HandoffEndExecutor.
string targetAgentId = handoff.Target.Id;
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == targetAgentId // Use AgentId for target matching
&& state.IsTerminated != true,
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching
HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level
}
// Default branch catches: (a) turns with no handoff requested, and (b) terminated turns
// (whose handoff cases have been excluded above via the !IsTerminated guard).
sb.WithDefault(HandoffEndExecutor.ExecutorId);
});
@@ -502,47 +309,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
}
}
private Dictionary<AIAgent, HashSet<HandoffTarget>> BuildDefaultHandoffTargets()
{
// Default handoffs: when the caller has not explicitly registered any handoffs via
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
// participant.
// The handoff "reason" is derived from the target agent's description/name/instructions,
// matching the resolution rules used in WithHandoff(). If no reason can be derived, we throw —
// same contract as the explicit handoff path.
Dictionary<AIAgent, HashSet<HandoffTarget>> defaultTargets = [];
foreach (AIAgent source in this._allAgents)
{
HashSet<HandoffTarget> targets = [];
foreach (AIAgent target in this._allAgents)
{
if (AIAgentIDEqualityComparer.Instance.Equals(source, target))
{
continue;
}
string? reason = (string.IsNullOrWhiteSpace(target.Description) ? null : target.Description)
?? (string.IsNullOrWhiteSpace(target.Name) ? null : $"handoff to {target.Name}")
?? target.GetService<ChatClientAgent>()?.Instructions;
if (string.IsNullOrWhiteSpace(reason))
{
Throw.InvalidOperationException(
$"Cannot build default handoffs: target agent '{(string.IsNullOrWhiteSpace(target.Name) ? target.Id : target.Name)}' " +
"has no description, name, or instructions from which to derive a handoff reason. Either provide one of these " +
"on the agent, or define handoffs explicitly via WithHandoff/WithHandoffs.");
}
targets.Add(new HandoffTarget(target, reason));
}
defaultTargets[source] = targets;
}
return defaultTargets;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via handoffs, with the next
/// agent to process messages selected by the current agent.
@@ -551,25 +317,11 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
public Workflow Build()
{
HandoffStartExecutor start = new(this._returnToPrevious);
HandoffEndExecutor end = new(
returnToPrevious: this._returnToPrevious,
autonomousMode: this._autonomousMode,
autonomousTurnLimit: this._autonomousTurnLimit,
autonomousContinuationPrompt: this._autonomousContinuationPrompt,
autonomousEnabledAgentIds: this._autonomousEnabledAgentIds,
autonomousTurnLimitsByAgentId: this._autonomousTurnLimitsByAgentId,
autonomousContinuationPromptsByAgentId: this._autonomousContinuationPromptsByAgentId);
HandoffEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
// Default handoffs: when the caller has not explicitly registered any handoffs via
// WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other
// participant.
Dictionary<AIAgent, HashSet<HandoffTarget>> effectiveTargets = this._targets.Count == 0
? this.BuildDefaultHandoffTargets()
: this._targets;
// Create an factory-based ExecutorBinding for each agent.
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder, effectiveTargets);
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
@@ -594,46 +346,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBu
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Autonomous-mode loop-back: when enabled, the End executor may emit a HandoffState targeting
// the source agent (carrying the synthesized continuation prompt in the shared conversation).
// A switch downstream of End routes that message back to the matching agent executor.
if (this._autonomousMode)
if (!string.IsNullOrWhiteSpace(this._name))
{
builder.AddSwitch(end, sb =>
{
foreach (AIAgent agent in this._allAgents)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == agentId, executors[agentId]);
}
});
builder.WithName(this._name);
}
// Ensure the end executor is bound regardless of whether it ends up as an output
// designation source — the user may take full control of output designations.
builder.BindExecutor(end);
// Build the AIAgent -> ExecutorBinding map the base helper expects.
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in this._allAgents)
if (!string.IsNullOrWhiteSpace(this._description))
{
agentMap[agent] = executors[agent.Id];
builder.WithDescription(this._description);
}
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "handoff", () =>
{
// Defaults (matches Python's Handoff orchestration):
// end -> terminal output
// every handoff agent -> intermediate output
builder.WithOutputFrom(end);
List<ExecutorBinding> agentBindings = [.. executors.Values];
if (agentBindings.Count > 0)
{
builder.WithIntermediateOutputFrom(agentBindings);
}
});
return builder.Build();
return builder.WithOutputFrom(end).Build();
}
}
@@ -241,47 +241,30 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this.CheckEnded();
Throw.IfNull(output);
bool isAgentResponseShaped = output is AgentResponse or AgentResponseUpdate;
if (isAgentResponseShaped && !Futures.EnableAgentResponseOutputTaggingAndFiltering)
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
// and bypass the output filter (for backwards compatibility - these events were previously
// emitted directly via AddEventAsync without filtering)
if (output is AgentResponseUpdate update)
{
// Legacy bypass: AgentResponse/AgentResponseUpdate skip the output filter and are
// emitted as their typed event subclasses with no tags. Preserved verbatim for
// back-compat; once Futures.EnableAgentResponseOutputTaggingAndFiltering becomes the
// default in v2.0.0, this branch goes away.
WorkflowEvent typedEvent = output switch
{
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u),
AgentResponse r => new AgentResponseEvent(sourceId, r),
_ => throw new InvalidOperationException("Unexpected AIAgent-shaped payload type."),
};
await this.AddEventAsync(typedEvent, cancellationToken).ConfigureAwait(false);
await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false);
return;
}
else if (output is AgentResponse response)
{
await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false);
return;
}
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
if (!isAgentResponseShaped && !sourceExecutor.CanOutput(output.GetType()))
if (!sourceExecutor.CanOutput(output.GetType()))
{
// AIAgent-shaped payloads bypass the per-executor declared-yield check (matching the
// legacy bypass branch above). The AIAgent host executor relays the agent's output
// without declaring AgentResponse(Update) in its Yields set, so a CanOutput probe
// here would always reject — but those payloads are always a valid output shape.
throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}].");
}
if (!this._outputFilter.TryGetTags(sourceId, out HashSet<OutputTag>? tags))
if (this._outputFilter.CanOutput(sourceId, output))
{
// Not designated as an output source — drop silently.
return;
await this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken).ConfigureAwait(false);
}
WorkflowOutputEvent evt = output switch
{
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u, tags),
AgentResponse r => new AgentResponseEvent(sourceId, r, tags),
_ => new WorkflowOutputEvent(output, sourceId, tags),
};
await this.AddEventAsync(evt, cancellationToken).ConfigureAwait(false);
}
public IExternalRequestContext BindExternalRequestContext(string executorId)
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
@@ -15,6 +16,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
/// a loop.</param>
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
{
/// <summary>
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -12,6 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="Review">
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
/// </param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
{
internal bool IsApproved => this.Review.Count == 0;
@@ -14,6 +14,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Maintains a ledger of progress made by the Magentic workflow.
/// </summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticProgressLedger
{
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
@@ -75,7 +76,7 @@ public class MagenticProgressLedger
this.InstructionOrQuestion = instructionOrQuestion!;
}
// TODO: To what extent do we want to enforce that the additional questions are also answered?
// TODO: To what extent do we want to enforce that the additional questions are also answered?
return requiredQuestionsAnswered;
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
@@ -26,9 +27,12 @@ namespace Microsoft.Agents.AI.Workflows;
/// not supported on the ManagerAgent.
/// </summary>
/// <param name="managerAgent"></param>
public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase<MagenticWorkflowBuilder>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticWorkflowBuilder(AIAgent managerAgent)
{
private readonly List<AIAgent> _team = new();
private string? _name;
private string? _description;
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
private int? _maxRounds;
private int? _maxResets;
@@ -41,6 +45,20 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public MagenticWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public MagenticWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
/// </summary>
@@ -97,29 +115,28 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
ForwardIncomingMessages = false
};
Dictionary<AIAgent, ExecutorBinding> teamMap = new(AIAgentIDEqualityComparer.Instance);
List<ExecutorBinding> teamBindings = [];
foreach (AIAgent agent in team)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
teamBindings.Add(binding);
teamMap[agent] = binding;
result.AddEdge(binding, orchestrator);
}
result.AddFanOutEdge(orchestrator, teamBindings);
result.AddFanOutEdge(orchestrator, teamBindings)
.WithOutputFrom(orchestrator);
this.ApplyOutputDesignations(result, teamMap, "Magentic", () =>
if (!string.IsNullOrWhiteSpace(this._name))
{
result.WithOutputFrom(orchestrator);
if (teamMap.Count > 0)
{
result.WithIntermediateOutputFrom([.. teamMap.Values]);
}
});
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
this.ApplyMetadata(result);
return result;
}
@@ -1,154 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Common fluent surface shared by every orchestration-style workflow builder:
/// human-readable name + description, and the
/// <see cref="WithOutputFrom"/> / <see cref="WithIntermediateOutputFrom"/> output-designation
/// pair with memoized defaults-suppression semantics.
/// </summary>
/// <typeparam name="TBuilder">The concrete builder type, for fluent self-return.</typeparam>
public abstract class OrchestrationBuilderBase<TBuilder>
where TBuilder : OrchestrationBuilderBase<TBuilder>
{
/// <summary>Optional workflow name; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
protected string? Name { get; private set; }
/// <summary>Optional workflow description; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
protected string? Description { get; private set; }
/// <summary>
/// Memoized output designations. <see langword="null"/> means the user has not made any
/// explicit designation, and the orchestration-specific defaults will be applied at
/// <c>Build()</c> time. A non-<see langword="null"/> (possibly empty) map means the user took
/// control and only these designations will be replayed onto the inner
/// <see cref="WorkflowBuilder"/>. An entry's value is the set of tags requested for the
/// agent — an empty set encodes a terminal-only designation.
/// </summary>
protected Dictionary<AIAgent, HashSet<OutputTag>>? OutputDesignations { get; private set; }
/// <summary>Sets the human-readable name for the workflow.</summary>
public TBuilder WithName(string name)
{
this.Name = name;
return (TBuilder)this;
}
/// <summary>Sets the description for the workflow.</summary>
public TBuilder WithDescription(string description)
{
this.Description = description;
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
/// suppresses the orchestration-specific defaults: only the user-specified designations
/// reach the inner <see cref="WorkflowBuilder"/>.
/// </summary>
public TBuilder WithOutputFrom(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this.OutputDesignations.ContainsKey(agent))
{
this.OutputDesignations[agent] = [];
}
}
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow
/// output. See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
/// </summary>
public TBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this.OutputDesignations.TryGetValue(agent, out HashSet<OutputTag>? tags))
{
tags = [];
this.OutputDesignations[agent] = tags;
}
tags.Add(OutputTag.Intermediate);
}
return (TBuilder)this;
}
/// <summary>
/// Applies the optional <see cref="Name"/> and <see cref="Description"/> to <paramref name="builder"/>.
/// Subclasses should call this from their <c>Build()</c> implementation.
/// </summary>
protected void ApplyMetadata(WorkflowBuilder builder)
{
Throw.IfNull(builder);
if (!string.IsNullOrWhiteSpace(this.Name))
{
builder.WithName(this.Name!);
}
if (!string.IsNullOrWhiteSpace(this.Description))
{
builder.WithDescription(this.Description!);
}
}
/// <summary>
/// Applies the user's memoized output designations to <paramref name="builder"/>, or invokes
/// <paramref name="applyDefaults"/> if the user made no explicit designation.
/// </summary>
/// <param name="builder">The inner <see cref="WorkflowBuilder"/>.</param>
/// <param name="agentMap">Map from participating <see cref="AIAgent"/> to its bound executor.</param>
/// <param name="orchestrationKind">Used in the not-a-participant error message (e.g. "sequential", "group chat").</param>
/// <param name="applyDefaults">Action invoked when no explicit designation was made.</param>
protected void ApplyOutputDesignations(
WorkflowBuilder builder,
IReadOnlyDictionary<AIAgent, ExecutorBinding> agentMap,
string orchestrationKind,
Action applyDefaults)
{
Throw.IfNull(builder);
Throw.IfNull(agentMap);
Throw.IfNull(applyDefaults);
if (this.OutputDesignations is null)
{
applyDefaults();
return;
}
foreach (AIAgent agent in this.OutputDesignations.Keys)
{
if (!agentMap.TryGetValue(agent, out ExecutorBinding? binding))
{
throw new InvalidOperationException(
$"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this {orchestrationKind} workflow.");
}
HashSet<OutputTag> tags = this.OutputDesignations[agent];
if (tags.Count == 0)
{
builder.WithOutputFrom(binding);
}
else
{
foreach (OutputTag tag in tags)
{
builder.WithOutputFrom(binding, tag);
}
}
}
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Identifies the kind of output that a <see cref="WorkflowOutputEvent"/> represents.
/// A thin <c>ChatRole</c>-style wrapper around a normalized string <see cref="Value"/>,
/// with value equality and a closed set of well-known singletons (the constructor is
/// <see langword="internal"/> for now).
/// </summary>
[JsonConverter(typeof(OutputTagJsonConverter))]
public readonly struct OutputTag : IEquatable<OutputTag>
{
/// <summary>
/// The string identifier of the tag. Compared with ordinal equality.
/// </summary>
public string? Value { get; }
internal OutputTag(string value)
{
this.Value = Throw.IfNullOrEmpty(value);
}
/// <summary>
/// The tag denoting an intermediate workflow output &#x2014; emitted by executors
/// registered via <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>.
/// Terminal (non-intermediate) outputs carry no tag.
/// </summary>
public static OutputTag Intermediate { get; } = new("intermediate");
/// <inheritdoc />
public bool Equals(OutputTag other) => string.Equals(this.Value, other.Value, StringComparison.Ordinal);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is OutputTag other && this.Equals(other);
/// <inheritdoc />
public override int GetHashCode() => this.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(this.Value);
/// <summary>Determines whether two <see cref="OutputTag"/> values are equal.</summary>
public static bool operator ==(OutputTag left, OutputTag right) => left.Equals(right);
/// <summary>Determines whether two <see cref="OutputTag"/> values are not equal.</summary>
public static bool operator !=(OutputTag left, OutputTag right) => !left.Equals(right);
/// <inheritdoc />
public override string ToString() => this.Value ?? string.Empty;
}
@@ -1,43 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// JSON converter for <see cref="OutputTag"/> that round-trips the underlying
/// <see cref="OutputTag.Value"/> as a bare JSON string.
/// </summary>
internal sealed class OutputTagJsonConverter : JsonConverter<OutputTag>
{
public override OutputTag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (string.IsNullOrEmpty(value))
{
return default;
}
// Reuse the well-known singleton where possible so callers can do reference
// comparisons on the common case without paying the extra allocation cost.
if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal))
{
return OutputTag.Intermediate;
}
return new OutputTag(value!);
}
public override void Write(Utf8JsonWriter writer, OutputTag value, JsonSerializerOptions options)
{
if (value.Value is null)
{
writer.WriteNullValue();
return;
}
writer.WriteStringValue(value.Value);
}
}
@@ -69,23 +69,4 @@ public class RoundRobinGroupChatManager : GroupChatManager
base.Reset();
this._nextIndex = 0;
}
/// <inheritdoc />
protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> context.QueueStateUpdateAsync(StateKey, new RoundRobinGroupChatManagerState(this._nextIndex), cancellationToken: cancellationToken);
/// <inheritdoc />
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
RoundRobinGroupChatManagerState? state = await context.ReadStateAsync<RoundRobinGroupChatManagerState>(StateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
this._nextIndex = state?.NextIndex ?? 0;
if (this._nextIndex < 0 || this._nextIndex >= this._agents.Count)
{
this._nextIndex = 0;
}
}
private const string StateKey = "next_index";
}
internal sealed record RoundRobinGroupChatManagerState(int NextIndex);
@@ -1,84 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for sequential agent workflows: a pipeline where the output of one
/// agent is the input to the next, terminating in an aggregator that yields the
/// accumulated <see cref="Extensions.AI.ChatMessage"/>s as the workflow output.
/// </summary>
/// <remarks>
/// When no explicit output designations are made, the default is the Python-aligned
/// shape: the terminal aggregator is the workflow output, and every participating agent
/// is designated as an intermediate output source. Calling
/// <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(IEnumerable{AIAgent})"/>
/// at all suppresses these defaults.
/// </remarks>
public sealed class SequentialWorkflowBuilder : OrchestrationBuilderBase<SequentialWorkflowBuilder>
{
private readonly List<AIAgent> _agents = [];
/// <summary>
/// Initializes a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline
/// of <paramref name="agents"/>.
/// </summary>
public SequentialWorkflowBuilder(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
this._agents.Add(agent);
}
}
/// <summary>Builds the configured sequential workflow.</summary>
public Workflow Build()
{
if (this._agents.Count == 0)
{
throw new ArgumentException("At least one agent must be provided to the SequentialWorkflowBuilder.", "agents");
}
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
List<ExecutorBinding> agentExecutors = new(this._agents.Count);
foreach (AIAgent agent in this._agents)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
agentExecutors.Add(binding);
agentMap[agent] = binding;
}
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
builder.AddEdge(previous, next);
previous = next;
}
OutputMessagesExecutor end = new();
builder.AddEdge(previous, end).BindExecutor(end);
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "sequential", () =>
{
builder.WithOutputFrom(end);
builder.WithIntermediateOutputFrom(agentExecutors);
});
return builder.Build();
}
}
@@ -20,25 +20,12 @@ internal sealed class GroupChatHost(
AutoSendTurnToken = false
};
private const string HistoryStateKey = nameof(_history);
private const string CurrentSpeakerStateKey = nameof(_currentSpeakerExecutorId);
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private GroupChatManager? _manager;
// Canonical conversation accumulated across turns. Each participant maintains its own per-agent
// session/thread; the host keeps this only as the source of truth for the manager hooks
// (SelectNextAgentAsync / ShouldTerminateAsync) and for the workflow's final output.
private List<ChatMessage> _history = [];
// Executor id of the participant we most recently dispatched a TurnToken to i.e., the current
// speaker whose response is about to arrive. Used to exclude that participant from the next
// broadcast (its own session already contains the message it produced).
private string? _currentSpeakerExecutorId;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
@@ -46,105 +33,30 @@ internal sealed class GroupChatHost(
{
this._manager ??= this._managerFactory(this._agents);
// The delta arriving here is either the initial user input (turn 0) or the most recent speaker's
// response (subsequent turns) participants no longer echo incoming messages back to the host.
if (messages.Count > 0)
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
this._history.AddRange(messages);
}
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.ShouldTerminateAsync(this._history, cancellationToken).ConfigureAwait(false))
{
await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false);
return;
}
if (messages.Count > 0)
{
IEnumerable<ChatMessage> filteredDelta = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
List<ChatMessage> broadcastMessages = filteredDelta is null
? messages
: (ReferenceEquals(filteredDelta, messages) ? messages : [.. filteredDelta]);
if (broadcastMessages.Count > 0)
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
await this.BroadcastAsync(broadcastMessages, context, cancellationToken).ConfigureAwait(false);
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
if (await this._manager.SelectNextAgentAsync(this._history, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor))
{
this._manager.IterationCount++;
this._currentSpeakerExecutorId = executor.Id;
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false);
}
private ValueTask BroadcastAsync(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
List<Task>? sendTasks = null;
foreach (ExecutorBinding participant in this._agentMap.Values)
{
if (string.Equals(participant.Id, this._currentSpeakerExecutorId, StringComparison.Ordinal))
{
continue;
}
(sendTasks ??= []).Add(context.SendMessageAsync(messages, participant.Id, cancellationToken).AsTask());
}
return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks));
}
private async ValueTask CompleteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage> output = this._history;
this._history = [];
this._currentSpeakerExecutorId = null;
this._manager = null;
await context.YieldOutputAsync(output, cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
}
protected override ValueTask ResetAsync()
{
this._manager = null;
this._history = [];
this._currentSpeakerExecutorId = null;
return base.ResetAsync();
}
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task historyTask = context.QueueStateUpdateAsync(HistoryStateKey, this._history, cancellationToken: cancellationToken).AsTask();
Task currentSpeakerTask = context.QueueStateUpdateAsync(CurrentSpeakerStateKey, this._currentSpeakerExecutorId, cancellationToken: cancellationToken).AsTask();
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
// Eagerly materialize the manager so subclass state (e.g., the round-robin cursor) gets
// persisted on every checkpoint, even if no turn has been taken yet since the host was constructed.
this._manager ??= this._managerFactory(this._agents);
Task managerTask = this._manager.CheckpointAsync(context, cancellationToken).AsTask();
await Task.WhenAll(historyTask, currentSpeakerTask, baseTask, managerTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
this._history = await context.ReadStateAsync<List<ChatMessage>>(HistoryStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? [];
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(CurrentSpeakerStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
// Instantiate the manager eagerly so its restore hook can rehydrate IterationCount and any
// subclass-defined state (e.g., RoundRobinGroupChatManager._nextIndex).
this._manager = this._managerFactory(this._agents);
await this._manager.RestoreCheckpointAsync(context, cancellationToken).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
}
}
@@ -30,17 +30,6 @@ internal sealed class HandoffAgentExecutorOptions
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
// Termination condition. When provided, evaluated after the agent responds and no handoff was
// requested. If it returns true, the outgoing HandoffState is stamped with IsTerminated = true
// so the per-agent routing switch routes the turn to HandoffEndExecutor instead of continuing.
public Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? TerminationCondition { get; set; }
}
internal static class HandoffWorkflowBuilderDefaults
{
public const string DefaultAutonomousContinuationPrompt = "User did not respond. Continue assisting autonomously.";
public const int DefaultAutonomousTurnLimit = 50;
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
@@ -81,6 +70,7 @@ internal sealed record StateRef<TState>(string Key, string? ScopeName)
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
@@ -260,7 +250,6 @@ internal sealed class HandoffAgentExecutor :
}
int newConversationBookmark = state.ConversationBookmark;
List<ChatMessage>? conversationSnapshot = null;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
@@ -296,25 +285,12 @@ internal sealed class HandoffAgentExecutor :
}
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
// Reset this agent's autonomous-turn counter when it chooses to hand off, so that
// if control returns to this agent later in the turn (e.g. via another handoff),
// its autonomous loop starts fresh rather than carrying over prior iterations.
sharedState.AutonomousTurnsByAgent[this._agent.Id] = 0;
}
else
{
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
}
// Snapshot the conversation for termination evaluation while we still hold shared state access.
// Termination is only relevant when no handoff was requested — a requested handoff always
// routes to the target agent regardless of termination.
if (this._options.TerminationCondition is not null && !result.IsHandoffRequested)
{
conversationSnapshot = sharedState.Conversation.CloneHistory();
}
return new ValueTask();
},
context,
@@ -322,27 +298,18 @@ internal sealed class HandoffAgentExecutor :
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
// happens if we have no outstanding requests.
if (this.HasOutstandingRequests)
if (!this.HasOutstandingRequests)
{
return state with { ConversationBookmark = newConversationBookmark };
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the
// agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.)
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
}
// Evaluate the termination condition (when configured and no handoff was requested) and stamp
// the result onto the outgoing HandoffState so the per-agent routing switch can route the turn
// to HandoffEndExecutor instead of dispatching another handoff or autonomous continuation.
bool isTerminated = false;
if (conversationSnapshot is not null)
{
isTerminated = await this._options.TerminationCondition!(conversationSnapshot).ConfigureAwait(false);
}
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id, isTerminated);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// Reset the turn-local state; keep the conversation bookmark and the agent session so the
// next invocation (handoff back, autonomous loop-back, or new user turn) resumes cleanly.
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
return state;
}
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -8,76 +8,18 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event,
/// and in autonomous mode to loop control back to the source agent.</summary>
/// <remarks>
/// Autonomous-turn counters are tracked per source agent in <see cref="HandoffSharedState.AutonomousTurnsByAgent"/>.
/// On each invocation where the source agent did not request a handoff and termination has not fired,
/// the counter for that agent is incremented and control is sent back to that agent (via the
/// autonomous-return switch wired downstream of this executor). When the counter reaches the per-agent
/// turn limit — or when termination fires, or when autonomous mode is disabled for that agent — the
/// counter is reset to zero and the conversation is yielded as workflow output.
/// </remarks>
internal sealed class HandoffEndExecutor : Executor, IResettableExecutor
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
private readonly bool _returnToPrevious;
private readonly bool _autonomousMode;
private readonly int _autonomousTurnLimit;
private readonly string _autonomousContinuationPrompt;
private readonly HashSet<string>? _autonomousEnabledAgentIds;
private readonly IReadOnlyDictionary<string, int> _autonomousTurnLimitsByAgentId;
private readonly IReadOnlyDictionary<string, string> _autonomousContinuationPromptsByAgentId;
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
public HandoffEndExecutor(
bool returnToPrevious,
bool autonomousMode = false,
int autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit,
string autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt,
HashSet<string>? autonomousEnabledAgentIds = null,
IReadOnlyDictionary<string, int>? autonomousTurnLimitsByAgentId = null,
IReadOnlyDictionary<string, string>? autonomousContinuationPromptsByAgentId = null)
: base(ExecutorId, declareCrossRunShareable: true)
{
this._returnToPrevious = returnToPrevious;
this._autonomousMode = autonomousMode;
this._autonomousTurnLimit = autonomousTurnLimit;
this._autonomousContinuationPrompt = autonomousContinuationPrompt;
this._autonomousEnabledAgentIds = autonomousEnabledAgentIds;
this._autonomousTurnLimitsByAgentId = autonomousTurnLimitsByAgentId ?? new Dictionary<string, int>();
this._autonomousContinuationPromptsByAgentId = autonomousContinuationPromptsByAgentId ?? new Dictionary<string, string>();
}
private bool IsAutonomousEnabledFor(string agentId) =>
// Null allow-list means every participant has autonomous mode enabled.
this._autonomousEnabledAgentIds?.Contains(agentId) ?? true;
private int TurnLimitFor(string agentId) =>
this._autonomousTurnLimitsByAgentId.TryGetValue(agentId, out int limit) ? limit : this._autonomousTurnLimit;
private string ContinuationPromptFor(string agentId) =>
this._autonomousContinuationPromptsByAgentId.TryGetValue(agentId, out string? prompt) ? prompt : this._autonomousContinuationPrompt;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
ProtocolBuilder pb = protocolBuilder
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
// Only advertise the outgoing-message capability when autonomous mode is enabled, since the
// downstream return switch (Builder.AddSwitch on End) is only wired in that case.
if (this._autonomousMode)
{
pb = pb.SendsMessage<HandoffState>();
}
return pb;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
@@ -89,56 +31,7 @@ internal sealed class HandoffEndExecutor : Executor, IResettableExecutor
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
// Autonomous mode: when the agent did not request a handoff and termination has not fired,
// loop control back to the same agent (up to that agent's turn limit). Per-agent overrides
// (enabled-agents allow-list, turn limit, continuation prompt) are honored here.
bool canContinueAutonomously = this._autonomousMode
&& !handoff.IsTerminated
&& handoff.RequestedHandoffTargetAgentId is null
&& handoff.PreviousAgentId is not null
&& this.IsAutonomousEnabledFor(handoff.PreviousAgentId!);
if (canContinueAutonomously)
{
string agentId = handoff.PreviousAgentId!;
int turns = sharedState.AutonomousTurnsByAgent.TryGetValue(agentId, out int existing) ? existing : 0;
int limit = this.TurnLimitFor(agentId);
if (turns < limit)
{
sharedState.AutonomousTurnsByAgent[agentId] = turns + 1;
// Append a synthetic user message containing the continuation prompt so the agent
// has fresh input to act on for the next autonomous iteration.
sharedState.Conversation.AddMessage(new ChatMessage(ChatRole.User, this.ContinuationPromptFor(agentId))
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
// Send a HandoffState targeting the source agent. The downstream
// HandoffAutonomousReturnSwitch routes it to the matching agent executor.
HandoffState loopBack = new(
handoff.TurnToken,
RequestedHandoffTargetAgentId: agentId,
PreviousAgentId: agentId,
IsTerminated: false);
await context.SendMessageAsync(loopBack, cancellationToken).ConfigureAwait(false);
return sharedState;
}
}
// Terminal path: either termination fired, autonomous mode is disabled, or the turn
// limit is reached. Reset this agent's autonomous counter so a subsequent user turn
// starts fresh, then yield the conversation as workflow output.
if (handoff.PreviousAgentId is not null)
{
sharedState.AutonomousTurnsByAgent[handoff.PreviousAgentId] = 0;
}
if (this._returnToPrevious)
if (returnToPrevious)
{
sharedState.PreviousAgentId = handoff.PreviousAgentId;
}
@@ -2,10 +2,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffMessagesFilter
{
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
@@ -15,6 +17,7 @@ internal sealed class HandoffMessagesFilter
this._filteringBehavior = filteringBehavior;
}
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
@@ -25,32 +25,21 @@ internal static class HandoffConstants
internal sealed class HandoffSharedState
{
[JsonConstructor]
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId, Dictionary<string, int>? autonomousTurnsByAgent)
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId)
{
this.Conversation = conversation;
this.PreviousAgentId = previousAgentId;
this.AutonomousTurnsByAgent = autonomousTurnsByAgent ?? [];
}
public HandoffSharedState()
{
this.Conversation = new([]);
this.AutonomousTurnsByAgent = [];
}
[JsonInclude]
public MultiPartyConversation Conversation { get; internal set; }
public string? PreviousAgentId { get; set; }
/// <summary>
/// Tracks the number of autonomous-mode continuation iterations consumed by each agent in the current
/// "active" autonomous run. The counter is incremented by <see cref="HandoffEndExecutor"/> each time
/// the End executor loops control back to the source agent in autonomous mode, and reset to 0 once
/// the autonomous loop terminates (limit reached or termination condition fired).
/// </summary>
[JsonInclude]
public Dictionary<string, int> AutonomousTurnsByAgent { get; internal set; }
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
@@ -75,10 +64,6 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol
sharedState ??= new HandoffSharedState();
sharedState.Conversation.AddMessages(messages);
// Reset all autonomous-mode counters at the start of every fresh user turn so that a
// prior turn's counters cannot prematurely terminate the new turn's autonomous loop.
sharedState.AutonomousTurnsByAgent.Clear();
string? previousAgentId = sharedState.PreviousAgentId;
// If we are configured to return to the previous agent, include the previous agent id in the handoff state.
@@ -5,5 +5,4 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? RequestedHandoffTargetAgentId,
string? PreviousAgentId = null,
bool IsTerminated = false);
string? PreviousAgentId = null);
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -17,6 +18,7 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
[JsonDerivedType(typeof(MagenticReplannedEvent))]
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
{
}
@@ -25,6 +27,7 @@ public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(da
/// Represents the creation of the initial plan
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
@@ -37,6 +40,7 @@ public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : Magent
/// Represents the creation of a new plan in response to a stall.
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
@@ -49,6 +53,7 @@ public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : Magentic
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
/// </summary>
/// <param name="progressLedger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
{
/// <summary>
@@ -133,6 +138,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
to the conversation and enters the inner loop.
- If revision requested, append the review comments to the chat history,
trigger replanning via the manager, emit a REPLANNED event, then run the outer loop.
*/
if (this._taskContext == null || this._taskContext.TaskLedger == null)
{
@@ -195,12 +201,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
}
else
{
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan).
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
if (messages is { Count: > 0 })
{
this._taskContext.ChatHistory.AddRange(messages);
}
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
@@ -24,7 +24,7 @@ public class Workflow
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
internal Dictionary<string, HashSet<OutputTag>> OutputExecutors { get; init; } = new(StringComparer.Ordinal);
internal HashSet<string> OutputExecutors { get; init; } = [];
/// <summary>
/// Gets the collection of edges grouped by their source node identifier.
@@ -221,7 +221,7 @@ public class Workflow
startExecutor.AttachRequestContext(new NoOpExternalRequestContext());
ProtocolDescriptor inputProtocol = startExecutor.DescribeProtocol();
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Keys.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false);
IEnumerable<Type> yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields);
@@ -33,7 +33,7 @@ public class WorkflowBuilder
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, RequestPort> _requestPorts = [];
private readonly Dictionary<string, HashSet<OutputTag>> _outputExecutors = new(StringComparer.Ordinal);
private readonly HashSet<string> _outputExecutors = [];
private readonly string _startExecutorId;
private string? _name;
@@ -97,89 +97,22 @@ public class WorkflowBuilder
}
/// <summary>
/// Register executors as a source of terminal workflow outputs. Executors can use
/// <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values; yielded values from
/// registered executors are surfaced as <see cref="WorkflowOutputEvent"/> (or one of its
/// subclasses) with an empty <see cref="WorkflowOutputEvent.Tags"/> set.
/// By default, message handlers with a non-void return type will also be yielded, unless
/// <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/> is set to <see langword="false"/>.
/// Register executors as an output source. Executors can use <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values.
/// By default, message handlers with a non-void return type will also be yielded, unless <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/>
/// is set to <see langword="false"/>.
/// </summary>
/// <remarks>
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
/// participate in this designation when
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
/// <see langword="true"/>; otherwise they are emitted unconditionally and untagged.
/// </remarks>
/// <param name="executors">The executors to register as output sources.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
/// <param name="executors"></param>
/// <returns></returns>
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
{
foreach (ExecutorBinding executor in executors)
{
this.EnsureOutputExecutor(this.Track(executor).Id);
this._outputExecutors.Add(this.Track(executor).Id);
}
return this;
}
/// <summary>
/// Register executors as a source of workflow outputs carrying the given <paramref name="tag"/>.
/// Tags accumulate across repeated calls; the registered id always exists with the union of all
/// tags applied across all calls (and an empty set if only the untagged
/// <see cref="WithOutputFrom(ExecutorBinding[])"/> overload was used).
/// </summary>
/// <remarks>
/// Forward-looking surface for when the <see cref="OutputTag"/> constructor opens to
/// user-defined tags. Today, prefer
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, IEnumerable{ExecutorBinding})"/>
/// for the <see cref="OutputTag.Intermediate"/> case.
/// </remarks>
/// <param name="executors">The executors to register.</param>
/// <param name="tag">The tag to apply to events yielded by the listed executors.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithOutputFrom(IEnumerable<ExecutorBinding> executors, OutputTag tag)
{
Throw.IfNull(executors);
foreach (ExecutorBinding executor in executors)
{
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
}
return this;
}
/// <summary>
/// Register a single executor as a source of workflow outputs carrying the given <paramref name="tag"/>.
/// Convenience overload for the single-executor case; equivalent to passing a one-element sequence
/// to <see cref="WithOutputFrom(IEnumerable{ExecutorBinding}, OutputTag)"/>.
/// </summary>
/// <param name="executor">The executor to register.</param>
/// <param name="tag">The tag to apply to events yielded by the executor.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithOutputFrom(ExecutorBinding executor, OutputTag tag)
{
Throw.IfNull(executor);
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
return this;
}
/// <summary>
/// Ensures the executor id is present in <see cref="_outputExecutors"/>; if newly added,
/// initializes with an empty tag set. Returns the tag set for the id (mutable).
/// </summary>
private HashSet<OutputTag> EnsureOutputExecutor(string executorId)
{
if (!this._outputExecutors.TryGetValue(executorId, out HashSet<OutputTag>? tags))
{
tags = [];
this._outputExecutors[executorId] = tags;
}
return tags;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
@@ -211,28 +211,4 @@ public static class WorkflowBuilderExtensions
return switchBuilder.ReduceToFanOut(builder, source);
}
/// <summary>
/// Register executors as a source of <b>intermediate</b> workflow outputs. The resulting
/// <see cref="WorkflowOutputEvent"/>s carry <see cref="OutputTag.Intermediate"/> in their
/// <see cref="WorkflowOutputEvent.Tags"/> set, and
/// <see cref="WorkflowOutputEventExtensions.IsIntermediate(WorkflowOutputEvent)"/> returns
/// <see langword="true"/>. Use this for progress updates, partial results, and other
/// non-terminal emissions that downstream consumers (DevUI, logging, Workflow-as-Agent
/// surfaces) should see distinctly from the workflow's final output.
/// </summary>
/// <remarks>
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
/// participate in this designation when
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
/// <see langword="true"/>; otherwise they bypass the filter and are emitted untagged.
/// </remarks>
/// <param name="builder">The workflow builder to register executors on.</param>
/// <param name="executors">The executors to register as intermediate output sources.</param>
/// <returns>The <paramref name="builder"/>, enabling fluent configuration.</returns>
public static WorkflowBuilder WithIntermediateOutputFrom(this WorkflowBuilder builder, IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(builder);
return builder.WithOutputFrom(executors, OutputTag.Intermediate);
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
@@ -14,39 +13,14 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(AgentResponseUpdateEvent))]
public class WorkflowOutputEvent : WorkflowEvent
{
private readonly HashSet<OutputTag> _tags;
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class with no tags.
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class.
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
public WorkflowOutputEvent(object data, string executorId) : this(data, executorId, tags: null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
/// given output tag.
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
/// <param name="tag">The single output tag to associate with this event.</param>
public WorkflowOutputEvent(object data, string executorId, OutputTag tag) : this(data, executorId, tags: new[] { tag })
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
/// given output tags (deduplicated).
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty (the event is then untagged).</param>
public WorkflowOutputEvent(object data, string executorId, IEnumerable<OutputTag>? tags) : base(data)
public WorkflowOutputEvent(object data, string executorId) : base(data)
{
this.ExecutorId = executorId;
this._tags = tags is null ? new HashSet<OutputTag>() : new HashSet<OutputTag>(tags);
}
/// <summary>
@@ -58,21 +32,8 @@ public class WorkflowOutputEvent : WorkflowEvent
/// The unique identifier of the executor that yielded this output.
/// </summary>
[Obsolete("Use ExecutorId instead.")]
[JsonIgnore]
public string SourceId => this.ExecutorId;
/// <summary>
/// The set of output tags associated with this event. Never <see langword="null"/>;
/// empty for terminal/regular outputs. The presence of <see cref="OutputTag.Intermediate"/>
/// marks this event as an intermediate output.
/// </summary>
public IEnumerable<OutputTag> Tags => this._tags;
/// <summary>
/// Returns <see langword="true"/> if this event carries the given tag.
/// </summary>
public bool HasTag(OutputTag tag) => this._tags.Contains(tag);
/// <summary>
/// Determines whether the underlying data is of the specified type or a derived type.
/// </summary>
@@ -1,21 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension helpers for inspecting <see cref="WorkflowOutputEvent"/> tag membership.
/// </summary>
public static class WorkflowOutputEventExtensions
{
/// <summary>
/// Returns <see langword="true"/> if the event carries
/// <see cref="OutputTag.Intermediate"/> in its <see cref="WorkflowOutputEvent.Tags"/>.
/// </summary>
public static bool IsIntermediate(this WorkflowOutputEvent evt)
{
Throw.IfNull(evt);
return evt.HasTag(OutputTag.Intermediate);
}
}
@@ -520,20 +520,11 @@ internal sealed class WorkflowSession : AgentSession
goto default;
case AgentResponseEvent agentResponse:
// Under Futures.EnableAgentResponseOutputTaggingAndFiltering=true, mirror
// AgentResponseUpdateEvent's behavior: always forward, regardless of the
// _includeWorkflowOutputsInResponse host flag / "intermediate" tag. Under
// the legacy default, keep today's behavior — gated by the include flag.
if (!Futures.EnableAgentResponseOutputTaggingAndFiltering && !this._includeWorkflowOutputsInResponse)
if (!this._includeWorkflowOutputsInResponse)
{
goto default;
}
// Either EnableAgentResponseOutputTaggingAndFiltering -- so yield the Response
// regardless of whether it is tagged "intermediate" or whether the
// _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies
// exclusion of an event by enabling filtering and then _not_ marking an Executor
// as an output executor.
foreach (ChatMessage message in agentResponse.Response.Messages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
@@ -548,11 +539,7 @@ internal sealed class WorkflowSession : AgentSession
_ => null
};
// Same assymetry as with AgentResponseEvent, but there is no EnableFiltering flag
// to consider. If this made it here (and since it is not an AgentResponse[Update]),
// it means it is already been selected as an Output() from the user. Intermediate
// is irrelevant here.
if (updateMessages == null || !this._includeWorkflowOutputsInResponse)
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
{
goto default;
}
@@ -80,8 +80,9 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(ExecutorIdentity))]
[JsonSerializable(typeof(RunnerStateData))]
// Workflow Output Types
[JsonSerializable(typeof(OutputTag))]
// Workflow Representation Types
[JsonSerializable(typeof(WorkflowInfo))]
[JsonSerializable(typeof(EdgeConnection))]
// Workflow-as-Agent
[JsonSerializable(typeof(WorkflowChatHistoryProvider.StoreState))]
@@ -100,8 +101,6 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(MagenticPlanReviewRequest))]
[JsonSerializable(typeof(MagenticPlanReviewResponse))]
[JsonSerializable(typeof(MagenticTaskState))]
[JsonSerializable(typeof(GroupChatManagerState))]
[JsonSerializable(typeof(RoundRobinGroupChatManagerState))]
[JsonSerializable(typeof(ResetChatSignal))]
// Event Types
@@ -26,11 +26,11 @@ namespace Microsoft.Agents.AI;
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
/// <item><description><c>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
/// </list>
/// </para>
/// <para>
@@ -53,11 +53,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
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.
Use these tools to manage your tasks:
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
- Use todos_get_remaining to check what work is still pending.
- Use todos_get_all to review the full list including completed items.
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
- 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_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).
""";
private readonly ProviderSessionState<TodoState> _sessionState;
@@ -229,7 +229,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
},
new AIFunctionFactoryOptions
{
Name = "todos_add",
Name = "TodoList_Add",
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
SerializerOptions = serializerOptions,
}),
@@ -267,7 +267,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
},
new AIFunctionFactoryOptions
{
Name = "todos_complete",
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.",
SerializerOptions = serializerOptions,
}),
@@ -297,7 +297,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
},
new AIFunctionFactoryOptions
{
Name = "todos_remove",
Name = "TodoList_Remove",
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
SerializerOptions = serializerOptions,
}),
@@ -319,7 +319,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
},
new AIFunctionFactoryOptions
{
Name = "todos_get_remaining",
Name = "TodoList_GetRemaining",
Description = "Retrieve the list of incomplete todo items.",
SerializerOptions = serializerOptions,
}),
@@ -341,7 +341,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
},
new AIFunctionFactoryOptions
{
Name = "todos_get_all",
Name = "TodoList_GetAll",
Description = "Retrieve the full list of todo items, both complete and incomplete.",
SerializerOptions = serializerOptions,
}),
@@ -1,46 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides contextual information about a discovered file to the
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillFilterContext
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillFilterContext"/> class.
/// </summary>
/// <param name="skillName">The name of the skill (from SKILL.md frontmatter).</param>
/// <param name="relativeFilePath">
/// The path to the script or resource file relative to the skill directory (using forward slashes).
/// </param>
internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
{
this.SkillName = Throw.IfNullOrWhitespace(skillName);
this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
}
/// <summary>
/// Gets the name of the skill as declared in the SKILL.md frontmatter.
/// </summary>
/// <example><c>unit-converter</c></example>
public string SkillName { get; }
/// <summary>
/// Gets the path to the script or resource file relative to the skill directory (using forward slashes).
/// For root-level files this is just the filename; for nested files it includes the subdirectory.
/// </summary>
/// <example>
/// <c>run.py</c> for a script at skill root,
/// <c>scripts/convert.js</c> for a nested script, or
/// <c>references/guide.md</c> for a nested resource.
/// </example>
public string RelativeFilePath { get; }
}
@@ -30,12 +30,18 @@ namespace Microsoft.Agents.AI;
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
private const string SkillFileName = "SKILL.md";
private const int DefaultSearchDepth = 2;
private const int MaxSkillDirectorySearchDepth = 2;
private const int MaxSearchDepth = 2;
// "." means the skill directory root itself (no subdirectory descent constraint)
private const string RootDirectoryIndicator = ".";
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Standard subdirectory names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
private static readonly string[] s_defaultResourceDirectories = ["references", "assets"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
@@ -57,9 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly int _searchDepth;
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
private readonly Func<AgentFileSkillFilterContext, bool>? _resourceFilter;
private readonly IReadOnlyList<string> _scriptDirectories;
private readonly IReadOnlyList<string> _resourceDirectories;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -106,9 +111,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1);
this._scriptFilter = options?.ScriptFilter;
this._resourceFilter = options?.ResourceFilter;
this._scriptDirectories = options?.ScriptDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
: s_defaultScriptDirectories;
this._resourceDirectories = options?.ResourceDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
: s_defaultResourceDirectories;
this._scriptRunner = scriptRunner;
}
@@ -165,7 +174,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
results.Add(Path.GetFullPath(directory));
}
if (currentDepth >= MaxSkillDirectorySearchDepth)
if (currentDepth >= MaxSearchDepth)
{
return;
}
@@ -296,248 +305,218 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans the skill directory recursively (up to the configured search depth) for resource files
/// matching the configured extensions.
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicate is configured, files
/// that do not satisfy it are excluded.
/// </remarks>
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
var resources = new List<AgentFileSkillResource>();
this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
}
return resources;
}
private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillResource> resources, int currentDepth)
{
if (currentDepth > this._searchDepth)
{
return;
}
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
}
return;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), string.IsNullOrEmpty(extension) ? "(none)" : extension);
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the skill directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
// Apply user-provided filter predicate
if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
{
continue;
}
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
}
}
}
/// <summary>
/// Scans the skill directory recursively (up to the configured search depth) for script files
/// matching the configured extensions.
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> predicate is configured, files
/// that do not satisfy it are excluded.
/// </remarks>
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
var scripts = new List<AgentFileSkillScript>();
this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1);
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
}
return scripts;
}
private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillScript> scripts, int currentDepth)
{
if (currentDepth > this._searchDepth)
{
return;
}
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
}
return;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the skill directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
// Apply user-provided filter predicate
if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
{
continue;
}
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
}
}
}
/// <summary>
/// Checks whether any segment in the path (relative to the directory) is a symlink.
/// </summary>
@@ -563,31 +542,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
return false;
}
#if !NET
/// <summary>
/// Best-effort directory enumeration for target frameworks without
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
/// array when the caller lacks permission to read the directory contents,
/// so a single inaccessible child does not abort the entire skill scan.
/// </summary>
private string[] SafeEnumerateDirectories(string path)
{
try
{
return Directory.GetDirectories(path);
}
catch (UnauthorizedAccessException)
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
}
return Array.Empty<string>();
}
}
#endif
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
string value = kvMatch.Groups[3].Value;
@@ -710,6 +664,46 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
}
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
{
foreach (string directory in directories)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
}
// "." is valid — it means the skill root directory.
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
{
yield return directory;
continue;
}
// Reject absolute paths and any path segments that escape upward.
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
{
LogDirectoryNameSkippedInvalid(logger, directory);
continue;
}
yield return NormalizePath(directory);
}
}
private static bool ContainsParentTraversalSegment(string directory)
{
foreach (string segment in directory.Split('/', '\\'))
{
if (segment == "..")
{
return true;
}
}
return false;
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -749,6 +743,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -33,31 +32,28 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
/// <summary>
/// Gets or sets the maximum depth to search for script and resource files within each skill directory.
/// A value of <c>1</c> searches only the skill root directory. A value of <c>2</c> searches the root
/// and one level of subdirectories.
/// When <see langword="null"/>, the source uses the default depth of <c>2</c>.
/// Gets or sets relative directory paths to scan for script files within each skill directory.
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>scripts</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
/// <remarks>
/// Must be greater than or equal to <c>1</c>; lower values are rejected by the constructor.
/// </remarks>
public int? SearchDepth { get; set; }
public IEnumerable<string>? ScriptDirectories { get; set; }
/// <summary>
/// Gets or sets a predicate that filters discovered script files.
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
/// and the file's path relative to the skill directory.
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
/// When <see langword="null"/>, all scripts matching the allowed extensions are included.
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>references</c> and <c>assets</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public Func<AgentFileSkillFilterContext, bool>? ScriptFilter { get; set; }
/// <summary>
/// Gets or sets a predicate that filters discovered resource files.
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
/// and the file's path relative to the skill directory.
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
/// When <see langword="null"/>, all resources matching the allowed extensions are included.
/// </summary>
public Func<AgentFileSkillFilterContext, bool>? ResourceFilter { get; set; }
public IEnumerable<string>? ResourceDirectories { get; set; }
}
@@ -222,42 +222,6 @@ public class AgentResponseTests
Assert.Equal(100, usageContent.Details.TotalTokenCount);
}
[Fact]
public void ToAgentResponseUpdatesPropagatesCreatedAt()
{
// Sets different CreatedAt values on the AgentResponse and the ChatMessage to verify that the ChatMessage.CreatedAt is the one that gets propagated to the AgentResponseUpdate
AgentResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage", CreatedAt = new DateTimeOffset(2024, 11, 11, 9, 20, 0, TimeSpan.Zero) })
{
AgentId = "agentId",
ResponseId = "12345",
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
Usage = new UsageDetails
{
TotalTokenCount = 100
},
};
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
Assert.NotNull(updates);
Assert.Equal(2, updates.Length);
AgentResponseUpdate update0 = updates[0];
Assert.Equal("agentId", update0.AgentId);
Assert.Equal("12345", update0.ResponseId);
Assert.Equal("someMessage", update0.MessageId);
Assert.Equal(new DateTimeOffset(2024, 11, 11, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
Assert.Equal("customRole", update0.Role?.Value);
Assert.Equal("Text", update0.Text);
AgentResponseUpdate update1 = updates[1];
Assert.Equal("value1", update1.AdditionalProperties?["key1"]);
Assert.Equal(42, update1.AdditionalProperties?["key2"]);
Assert.IsType<UsageContent>(update1.Contents[0]);
UsageContent usageContent = (UsageContent)update1.Contents[0];
Assert.Equal(100, usageContent.Details.TotalTokenCount);
}
[Fact]
public void ParseAsStructuredOutputWithJSOSuccess()
{
@@ -172,7 +172,7 @@ public class DevUIIntegrationTests
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow");
}
[Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")]
[Fact]
public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync()
{
// Arrange
@@ -8,8 +8,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -102,7 +102,7 @@ public sealed class SessionPersistenceTests : IAsyncDisposable
// Register agent using hosting DI pattern with InMemorySessionStore
builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
.WithInMemorySessionStore(withIsolation: false);
.WithInMemorySessionStore();
this._app = builder.Build();
@@ -1,251 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderTests
{
private const string TestUserId = "test-user-id";
private const string CustomClaimType = "custom-claim-type";
private const string CustomClaimValue = "custom-claim-value";
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
/// </summary>
public ClaimsIdentitySessionIsolationKeyProviderTests()
{
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
}
#region Constructor Tests
/// <summary>
/// Verify that constructor uses default options when options is null.
/// </summary>
[Fact]
public void UsesDefaultOptionsWhenNull()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
Assert.NotNull(provider);
}
/// <summary>
/// Verify that constructor accepts null IHttpContextAccessor.
/// </summary>
[Fact]
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
Assert.NotNull(provider);
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is null.
/// </summary>
[Fact]
public void RequiresClaimType_NotNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is empty.
/// </summary>
[Fact]
public void RequiresClaimType_NotEmpty()
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is whitespace.
/// </summary>
[Fact]
public void RequiresClaimType_NotWhitespace()
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
}
#endregion
#region GetSessionIsolationKeyAsync Tests
/// <summary>
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(TestUserId, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(CustomClaimValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
{
// Arrange
this.SetupHttpContextWithClaim("other-claim", "value");
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify behavior when HttpContextAccessor returns null HttpContext.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
{
// Arrange
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify behavior when HttpContextAccessor itself is null.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
{
// Arrange
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
{
// Arrange
const string FirstValue = "first-value";
const string SecondValue = "second-value";
var claims = new[]
{
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
{
User = principal
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(FirstValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(string.Empty, result);
}
#endregion
#region Helper Methods
private void SetupHttpContextWithClaim(string claimType, string claimValue)
{
var claims = new[] { new Claim(claimType, claimValue) };
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
{
User = principal
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
}
#endregion
}
@@ -1,400 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
/// </summary>
public class DelegatingAgentSessionStoreTests
{
private readonly Mock<AgentSessionStore> _innerStoreMock;
private readonly Mock<AIAgent> _agentMock;
private readonly TestDelegatingAgentSessionStore _delegatingStore;
private readonly AgentSession _testSession;
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
/// </summary>
public DelegatingAgentSessionStoreTests()
{
this._innerStoreMock = new Mock<AgentSessionStore>();
this._agentMock = new Mock<AIAgent>();
this._testSession = new TestAgentSession();
// Setup inner store mock
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(this._testSession);
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
}
#region Constructor Tests
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerStore is null.
/// </summary>
[Fact]
public void RequiresInnerStore() =>
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
/// <summary>
/// Verify that constructor sets the inner store correctly.
/// </summary>
[Fact]
public void Constructor_WithValidInnerStore_SetsInnerStore()
{
// Act
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
// Assert
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
}
#endregion
#region Method Delegation Tests
/// <summary>
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
/// </summary>
[Fact]
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedCancellationToken = new CancellationToken();
this._innerStoreMock
.Setup(x => x.GetSessionAsync(
It.Is<AIAgent>(a => a == this._agentMock.Object),
It.Is<string>(c => c == ExpectedConversationId),
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
.ReturnsAsync(this._testSession);
// Act
var session = await this._delegatingStore.GetSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedCancellationToken);
// Assert
Assert.Same(this._testSession, session);
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedCancellationToken),
Times.Once);
}
/// <summary>
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
/// </summary>
[Fact]
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedCancellationToken = new CancellationToken();
var expectedSession = new TestAgentSession();
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(
It.Is<AIAgent>(a => a == this._agentMock.Object),
It.Is<string>(c => c == ExpectedConversationId),
It.Is<AgentSession>(s => s == expectedSession),
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
.Returns(ValueTask.CompletedTask);
// Act
await this._delegatingStore.SaveSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedSession,
expectedCancellationToken);
// Assert
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedSession,
expectedCancellationToken),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync awaits the inner store's result before returning.
/// </summary>
[Fact]
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
var innerStoreMock = new Mock<AgentSessionStore>();
innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
// Act
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
// Assert
Assert.False(resultTask.IsCompleted);
taskCompletionSource.SetResult(this._testSession);
Assert.True(resultTask.IsCompleted);
Assert.Same(this._testSession, await resultTask);
}
/// <summary>
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
/// </summary>
[Fact]
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedSession = new TestAgentSession();
var taskCompletionSource = new TaskCompletionSource();
var innerStoreMock = new Mock<AgentSessionStore>();
innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask(taskCompletionSource.Task));
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
// Act
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
// Assert
Assert.False(resultTask.IsCompleted);
taskCompletionSource.SetResult();
Assert.True(resultTask.IsCompleted);
await resultTask;
}
#endregion
#region GetService Tests
/// <summary>
/// Verify that GetService returns itself when requesting the exact type.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForExactType()
{
// Act
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService returns itself when requesting a base type.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForBaseType()
{
// Act
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService returns itself when requesting AgentSessionStore.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForAgentSessionStoreType()
{
// Act
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
/// </summary>
[Fact]
public void GetServiceChainsToInnerStore()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService chains through multiple delegation layers.
/// </summary>
[Fact]
public void GetServiceChainsThoughMultipleDelegationLayers()
{
// Arrange - create a three-layer chain: outer -> middle -> inner
var innerStore = new ConcreteAgentSessionStore();
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
// Act - request the innermost store type
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService can find a store in the middle of the delegation chain.
/// </summary>
[Fact]
public void GetServiceFindsMiddleStoreInChain()
{
// Arrange - create a three-layer chain: outer -> middle -> inner
var innerStore = new ConcreteAgentSessionStore();
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
// Act - request the middle store type
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
// Assert
Assert.Same(middleStore, result);
}
/// <summary>
/// Verify that GetService returns null when the requested type is not found in the chain.
/// </summary>
[Fact]
public void GetServiceReturnsNullWhenTypeNotFound()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService returns null when a service key is provided but not matched.
/// </summary>
[Fact]
public void GetServiceReturnsNullWhenServiceKeyProvided()
{
// Act
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService throws ArgumentNullException when serviceType is null.
/// </summary>
[Fact]
public void GetServiceThrowsWhenServiceTypeIsNull() =>
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
/// <summary>
/// Verify that GetService generic method works correctly.
/// </summary>
[Fact]
public void GetServiceGenericReturnsItself()
{
// Act
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService generic method chains to inner store.
/// </summary>
[Fact]
public void GetServiceGenericChainsToInnerStore()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService generic method returns null when type not found.
/// </summary>
[Fact]
public void GetServiceGenericReturnsNullWhenTypeNotFound()
{
// Act
var result = this._delegatingStore.GetService<string>();
// Assert
Assert.Null(result);
}
#endregion
#region Test Implementation
/// <summary>
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
/// </summary>
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
{
public new AgentSessionStore InnerStore => base.InnerStore;
}
/// <summary>
/// Another delegating store implementation for testing multi-layer chains.
/// </summary>
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
/// <summary>
/// Concrete (non-delegating) session store for testing GetService chaining.
/// </summary>
private sealed class ConcreteAgentSessionStore : AgentSessionStore
{
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class TestAgentSession : AgentSession;
#endregion
}
@@ -1,430 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
/// </summary>
public class IsolationKeyScopedAgentSessionStoreTests
{
private const string TestIsolationKey = "test-key";
private const string TestConversationId = "test-conversation-id";
private readonly Mock<AgentSessionStore> _innerStoreMock;
private readonly Mock<AIAgent> _agentMock;
private readonly AgentSession _testSession;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
/// </summary>
public IsolationKeyScopedAgentSessionStoreTests()
{
this._innerStoreMock = new Mock<AgentSessionStore>();
this._agentMock = new Mock<AIAgent>();
this._testSession = new TestAgentSession();
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(this._testSession);
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
}
#region Constructor Tests
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerStore is null.
/// </summary>
[Fact]
public void RequiresInnerStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () =>
new IsolationKeyScopedAgentSessionStore(null!, provider));
}
/// <summary>
/// Verify that constructor uses default options when options is null.
/// </summary>
[Fact]
public void UsesDefaultOptionsWhenNull()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert - should not throw
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
Assert.NotNull(store);
}
#endregion
#region GetSessionAsync Tests
/// <summary>
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
/// </summary>
[Fact]
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"{TestIsolationKey}::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
/// </summary>
[Fact]
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
/// </summary>
[Fact]
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
// Act - should not throw
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - conversation ID should be passed through unmodified
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
TestConversationId,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync returns the session from the inner store.
/// </summary>
[Fact]
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
Assert.Same(this._testSession, result);
}
#endregion
#region SaveSessionAsync Tests
/// <summary>
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
/// </summary>
[Fact]
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
var sessionToSave = new TestAgentSession();
// Act
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
// Assert
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
$"{TestIsolationKey}::{TestConversationId}",
sessionToSave,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
/// </summary>
[Fact]
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
var sessionToSave = new TestAgentSession();
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
/// </summary>
[Fact]
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
var sessionToSave = new TestAgentSession();
// Act - should not throw
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
// Assert - conversation ID should be passed through unmodified
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
TestConversationId,
sessionToSave,
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Escaping Tests
/// <summary>
/// Verify that colons in the isolation key are escaped.
/// </summary>
[Fact]
public async Task EscapesColonsInIsolationKeyAsync()
{
// Arrange
const string KeyWithColon = "key:with:colons";
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - colons should be escaped as \:
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"key\\:with\\:colons::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that backslashes in the isolation key are escaped.
/// </summary>
[Fact]
public async Task EscapesBackslashesInIsolationKeyAsync()
{
// Arrange
const string KeyWithBackslash = @"domain\key";
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - backslashes should be escaped as \\
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"domain\\\\key::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
/// </summary>
[Fact]
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
{
// Arrange
const string KeyWithBoth = @"domain\key:role";
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - backslashes escaped first, then colons
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"domain\\\\key\\:role::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Isolation Tests
/// <summary>
/// Verify that different isolation keys result in different scoped conversation IDs.
/// </summary>
[Fact]
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
{
// Arrange
const string Key1 = "key-1";
const string Key2 = "key-2";
string? capturedConversationId1 = null;
string? capturedConversationId2 = null;
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
{
if (capturedConversationId1 == null)
{
capturedConversationId1 = conversationId;
}
else
{
capturedConversationId2 = conversationId;
}
})
.ReturnsAsync(this._testSession);
// Act - Key 1
var provider1 = new TestSessionIsolationKeyProvider(Key1);
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Act - Key 2
var provider2 = new TestSessionIsolationKeyProvider(Key2);
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
}
#endregion
#region GetService Tests
/// <summary>
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
/// </summary>
[Fact]
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
// Assert
Assert.Same(store, result);
}
/// <summary>
/// Verify that GetService chains through to find inner store types.
/// </summary>
[Fact]
public void GetServiceChainsToInnerStore()
{
// Arrange
var concreteInnerStore = new ConcreteAgentSessionStore();
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
// Act
var result = store.GetService<ConcreteAgentSessionStore>();
// Assert
Assert.Same(concreteInnerStore, result);
}
#endregion
#region Helper Classes
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
}
private sealed class TestAgentSession : AgentSession;
/// <summary>
/// Concrete (non-delegating) session store for testing GetService chaining.
/// </summary>
private sealed class ConcreteAgentSessionStore : AgentSessionStore
{
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
#endregion
}
@@ -6,7 +6,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
</ItemGroup>
</Project>
@@ -1,95 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
/// </summary>
public class SessionIsolationKeyProviderTests
{
/// <summary>
/// Verify that a concrete provider can return a non-null isolation key.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
{
// Arrange
const string ExpectedKey = "test-key";
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(ExpectedKey, result);
}
/// <summary>
/// Verify that a concrete provider can return null when no key is available.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that cancellation token is passed through to the provider implementation.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
{
// Arrange
var provider = new TestCancellableSessionIsolationKeyProvider();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
}
#region Test Implementations
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
}
/// <summary>
/// Test implementation that respects cancellation tokens.
/// </summary>
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
await Task.Delay(1000, cancellationToken);
return "key";
}
}
#endregion
}
@@ -111,9 +111,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptsInRootAndSubdirectories_AreDiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
{
// Arrange — with default depth=2, scripts in root and immediate subdirectories are discovered
// Arrange — scripts outside configured directories are not discovered; only files directly
// inside the configured directory are picked up (no subdirectory recursion)
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -122,10 +123,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — both root and subdirectory scripts are discovered
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
Assert.Single(skills);
Assert.NotNull(await skills[0].GetScriptAsync("convert.py"));
Assert.NotNull(await skills[0].GetScriptAsync("tools/helper.sh"));
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
}
[Fact]
@@ -225,13 +225,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_DeepScript_DiscoveredWithHigherDepthAsync()
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
{
// Arrange — script at depth 4 (f1/f2/f3/run.py) discovered with SearchDepth=5
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
@@ -243,25 +243,36 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
}
[Fact]
public async Task GetSkillsAsync_ScriptFilter_ExcludesFilteredScriptsAsync()
[Theory]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("./scripts/f1", "./f2")]
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
{
// Arrange — ScriptFilter excludes scripts in the "f2" subdirectory
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Filter test", "Body.");
CreateFile(skillDir, "scripts/run.py", "print('scripts')");
CreateFile(skillDir, "f2/run.py", "print('f2')");
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
foreach (string directory in directories)
{
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
}
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFilter = ctx => !ctx.RelativeFilePath.StartsWith("f2/", StringComparison.OrdinalIgnoreCase) });
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — only scripts/ script is included; f2/ is excluded by filter
// Assert — scripts are discovered with names identical to using directories without "./"
Assert.Single(skills);
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
Assert.Null(await skills[0].GetScriptAsync("f2/run.py"));
foreach (string directory in directories)
{
string expectedName = $"{directory.Substring(2)}/run.py";
var script = await skills[0].GetScriptAsync(expectedName);
Assert.NotNull(script);
Assert.Equal(expectedName, script!.Name);
}
}
private static string CreateSkillDir(string root, string name, string description, string body)
@@ -425,9 +425,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
{
// Arrange — resource files directly in the skill directory are discovered with default depth=2
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -440,7 +440,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — root-level files are discovered by default (depth=2 includes root)
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
Assert.Single(skills);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.GetTestResources()!.Count);
@@ -449,22 +471,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public void Constructor_SearchDepthBelowOne_Throws()
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
{
// Arrange / Act / Assert — SearchDepth must be >= 1
Assert.Throws<ArgumentOutOfRangeException>(() =>
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 0 }));
Assert.Throws<ArgumentOutOfRangeException>(() =>
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = -1 }));
}
[Fact]
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredByDefaultAsync()
{
// Arrange — resource in any subdirectory is discovered with default depth=2
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
string customDir = Path.Combine(skillDir, "docs");
Directory.CreateDirectory(customDir);
@@ -477,16 +486,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — subdirectory files are discovered by default
// Assert — non-spec directories are not scanned by default
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("docs/readme.md", skills[0].GetTestResources()![0].Name);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
public async Task GetSkillsAsync_ResourceFilter_ExcludesFilteredFilesAsync()
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
{
// Arrange — ResourceFilter excludes files in the "docs" subdirectory
// Arrange — custom ResourceDirectories replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
string customDir = Path.Combine(skillDir, "docs");
string refsDir = Path.Combine(skillDir, "references");
@@ -498,16 +506,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => !ctx.RelativeFilePath.StartsWith("docs/", StringComparison.OrdinalIgnoreCase) });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only references/ resource is included; docs/ is excluded by filter
// Assert — only docs/ is scanned; references/ is NOT scanned
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.GetTestResources()!);
Assert.Equal("references/ref.md", skill.GetTestResources()![0].Name);
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -747,9 +755,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsSymlinkedDirectoryAsync()
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
{
// Arrange — "sub" directory is a symlink pointing outside the skill directory.
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
Directory.CreateDirectory(skillDir);
@@ -775,7 +783,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 4 });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -949,32 +957,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Null(fm.Metadata);
}
[Fact]
public async Task GetSkillsAsync_SearchDepthOne_OnlyRootFilesDiscoveredAsync()
[Theory]
[InlineData("..")]
[InlineData("../escape")]
[InlineData("sub/../escape")]
[InlineData("/absolute")]
[InlineData("\\absolute")]
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
{
// Arrange — with SearchDepth = 1, only root-level files are discovered
string skillDir = Path.Combine(this._testRoot, "depth-one-skill");
string scriptsDir = Path.Combine(skillDir, "scripts");
Directory.CreateDirectory(scriptsDir);
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: depth-one-skill\ndescription: Depth one\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 1 });
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
// Act
var skills = await source.GetSkillsAsync();
// Assert
Assert.NotNull(source1);
Assert.NotNull(source2);
}
// Assert — scripts in subdirectories are NOT discovered at depth 1
Assert.Single(skills);
Assert.Null(await skills[0].GetScriptAsync("scripts/run.py"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
{
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
}
[Theory]
[InlineData("scripts")]
[InlineData("my-scripts")]
[InlineData("sub/directory")]
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("my..scripts")]
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
{
// Arrange & Act & Assert
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
Assert.NotNull(source);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredWithDefaultDepthAsync()
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
{
// Arrange — resources in a subdirectory are discovered by default (depth=2)
// Arrange — "references" and "./references" refer to the same directory;
// after normalization they should be deduplicated so resources appear only once.
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -982,21 +1012,45 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource is discovered once
// Assert — only one copy of the resource despite two equivalent directory entries
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_ScriptInSubdirectory_DiscoveredWithDefaultDepthAsync()
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
{
// Arrange — scripts in a subdirectory are discovered by default (depth=2)
// Arrange — "references/" should be normalized to "references"
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — trailing slash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
{
// Arrange — ".\\scripts" should be normalized to "scripts"
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
string scriptsDir = Path.Combine(skillDir, "scripts");
Directory.CreateDirectory(scriptsDir);
@@ -1004,48 +1058,50 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — script is discovered
// Assert — backslash variant deduplicated
Assert.Single(skills);
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
}
[Fact]
public async Task GetSkillsAsync_ResourceFilterWhitelist_OnlyMatchingFilesDiscoveredAsync()
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
{
// Arrange — ResourceFilter acts as whitelist: only references/ paths included
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
string refsDir = Path.Combine(skillDir, "references");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(refsDir);
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(Path.Combine(assetsDir, "image.txt"), "data");
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(targetDir);
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => ctx.RelativeFilePath.StartsWith("references/", StringComparison.OrdinalIgnoreCase) });
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only the references/ resource is included
// Assert — the resource is discovered with a name identical to using the directory without "./"
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_DeepResource_NotDiscoveredWithDefaultDepthAsync()
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
{
// Arrange — resource at depth 3 (f1/f2/f3/data.json) exceeds default depth=2
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
@@ -1053,29 +1109,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource at depth 4 is NOT discovered with default depth=2
Assert.Single(skills);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
public async Task GetSkillsAsync_DeepResource_DiscoveredWithHigherDepthAsync()
{
// Arrange — resource at depth 4 (f1/f2/f3/data.json) discovered with SearchDepth=5
string skillDir = Path.Combine(this._testRoot, "deep-res-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: deep-res-skill\ndescription: Deep resource\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -1136,21 +1171,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — script file directly in the skill directory is discovered with default depth=2
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — script at the skill root is discovered by default
// Assert — script at the skill root should be discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
Assert.NotNull(skill);
var script = await skill.GetScriptAsync("run.py");
@@ -51,7 +51,7 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
// Act
await addTodos.InvokeAsync(new AIFunctionArguments()
@@ -75,7 +75,7 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
// Act
await addTodos.InvokeAsync(new AIFunctionArguments()
@@ -111,8 +111,8 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
@@ -131,8 +131,8 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
@@ -156,7 +156,7 @@ public class TodoProviderTests
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
@@ -173,8 +173,8 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
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
@@ -200,8 +200,8 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction removeTodos = GetTool(tools, "todos_remove");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
@@ -220,8 +220,8 @@ public class TodoProviderTests
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction removeTodos = GetTool(tools, "todos_remove");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
@@ -244,7 +244,7 @@ public class TodoProviderTests
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction removeTodos = GetTool(tools, "todos_remove");
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
// Act
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
@@ -265,9 +265,9 @@ public class TodoProviderTests
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
@@ -295,9 +295,9 @@ public class TodoProviderTests
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
@@ -332,12 +332,12 @@ public class TodoProviderTests
// Act — first invocation adds a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
// Second invocation should see the same state
AIContext result2 = await provider.InvokingAsync(context);
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll");
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
// Assert
@@ -364,7 +364,7 @@ public class TodoProviderTests
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
@@ -393,8 +393,8 @@ public class TodoProviderTests
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
@@ -556,8 +556,8 @@ public class TodoProviderTests
// First invocation — add some todos (one with a description to cover that branch)
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
@@ -622,7 +622,7 @@ public class TodoProviderTests
// First invocation — add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
@@ -687,7 +687,7 @@ public class TodoProviderTests
// Add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
@@ -725,8 +725,8 @@ public class TodoProviderTests
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
// Act — launch multiple concurrent adds
var tasks = Enumerable.Range(0, 10).Select(i =>
@@ -760,9 +760,9 @@ public class TodoProviderTests
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
// Add initial items
await addTodos.InvokeAsync(new AIFunctionArguments()
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
@@ -292,151 +290,6 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolApprovalRequestExcludesTransportHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolApprovalRequestExcludesTransportHeadersAsync),
serverUrl: TestServerUrl,
serverLabel: TestServerLabel,
toolName: TestToolName,
requireApproval: true,
headerKey: "Authorization",
headerValue: "Bearer super-secret-token");
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
ExternalInputRequest? capturedRequest = null;
// Act
await this.ExecuteAsync(
[
action,
new DelegateActionExecutor<ExternalInputRequest>(
InvokeMcpToolExecutor.Steps.ExternalInput(action.Id),
this.State,
CaptureRequestAsync)
],
isDiscrete: false);
// Assert - the approval event must not carry any transport headers (e.g. Authorization).
Assert.NotNull(capturedRequest);
ToolApprovalRequestContent approvalRequest =
capturedRequest!.AgentResponse.Messages
.SelectMany(message => message.Contents)
.OfType<ToolApprovalRequestContent>()
.Single();
AdditionalPropertiesDictionary? additionalProperties = approvalRequest.ToolCall.AdditionalProperties;
Assert.True(additionalProperties is null || additionalProperties.Count == 0);
// Defense in depth: the credential value must not appear anywhere in the serialized approval content.
string serializedApproval = System.Text.Json.JsonSerializer.Serialize(capturedRequest.AgentResponse);
Assert.DoesNotContain("super-secret-token", serializedApproval);
ValueTask CaptureRequestAsync(IWorkflowContext context, ExternalInputRequest request, CancellationToken cancellationToken)
{
capturedRequest = request;
return default;
}
}
[Fact]
public async Task InvokeMcpToolInvocationForwardsHeadersToTransportAsync()
{
// Arrange
this.State.InitializeSystem();
const string HeaderKey = "Authorization";
const string HeaderValue = "Bearer super-secret-token";
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolInvocationForwardsHeadersToTransportAsync),
serverUrl: TestServerUrl,
serverLabel: TestServerLabel,
toolName: TestToolName,
requireApproval: false,
headerKey: HeaderKey,
headerValue: HeaderValue);
IDictionary<string, string>? capturedHeaders = null;
Mock<IMcpToolHandler> mockProvider = new();
mockProvider
.Setup(provider => provider.InvokeToolAsync(
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<IDictionary<string, string>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
(_, _, _, _, headers, _, _) => capturedHeaders = headers)
.ReturnsAsync(new McpServerToolResultContent("mock-call-id") { Outputs = [new TextContent("ok")] });
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action, isDiscrete: false);
// Assert - headers remain available to the actual transport invocation.
Assert.NotNull(capturedHeaders);
Assert.True(capturedHeaders!.TryGetValue(HeaderKey, out string? forwardedValue));
Assert.Equal(HeaderValue, forwardedValue);
}
[Fact]
public async Task InvokeMcpToolApprovedCaptureResponseForwardsHeadersToTransportAsync()
{
// Arrange - exercises the post-approval CaptureResponseAsync resume path to prove the
// fix did not regress header forwarding on the path that the vulnerability actually targets.
this.State.InitializeSystem();
const string HeaderKey = "Authorization";
const string HeaderValue = "Bearer super-secret-token";
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolApprovedCaptureResponseForwardsHeadersToTransportAsync),
serverUrl: TestServerUrl,
serverLabel: TestServerLabel,
toolName: TestToolName,
requireApproval: true,
headerKey: HeaderKey,
headerValue: HeaderValue);
IDictionary<string, string>? capturedHeaders = null;
Mock<IMcpToolHandler> mockProvider = new();
mockProvider
.Setup(provider => provider.InvokeToolAsync(
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<IDictionary<string, string>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
(_, _, _, _, headers, _, _) => capturedHeaders = headers)
.ReturnsAsync(new McpServerToolResultContent("mock-call-id") { Outputs = [new TextContent("ok")] });
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
Mock<IWorkflowContext> mockContext = new(MockBehavior.Loose);
// Build an approved response matching this action's request id.
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel);
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act - call CaptureResponseAsync directly so the post-approval branch actually executes.
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - headers reach the transport invocation on the approved path.
Assert.NotNull(capturedHeaders);
Assert.True(capturedHeaders!.TryGetValue(HeaderKey, out string? forwardedValue));
Assert.Equal(HeaderValue, forwardedValue);
}
[Fact]
public async Task InvokeMcpToolExecuteWithEmptyHeaderValueAsync()
{
@@ -4,9 +4,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
@@ -14,164 +17,325 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Tests targeting the static <see cref="AgentWorkflowBuilder"/> helper surface —
/// <see cref="AgentWorkflowBuilder.BuildSequential(IEnumerable{AIAgent})"/>,
/// <see cref="AgentWorkflowBuilder.BuildConcurrent(IEnumerable{AIAgent}, Func{IList{List{ChatMessage}}, List{ChatMessage}})"/>,
/// and the various <c>Create*BuilderWith</c> factories. Per-builder unit tests live in their own
/// files (<see cref="SequentialWorkflowBuilderTests"/>, <see cref="ConcurrentWorkflowBuilderTests"/>, etc.).
/// </summary>
public class AgentWorkflowBuilderTests
{
[Fact]
public void Test_AgentWorkflowBuilder_BuildSequential_InvalidArguments_Throws()
public void BuildSequential_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}
[Fact]
public void BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
[Fact]
public void BuildGroupChat_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
Assert.NotNull(groupChat);
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
manager.MaximumIterationCount = 30;
Assert.Equal(30, manager.MaximumIterationCount);
manager.MaximumIterationCount = 1;
Assert.Equal(1, manager.MaximumIterationCount);
manager.MaximumIterationCount = int.MaxValue;
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
}
[Fact]
public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription()
{
const string WorkflowName = "Test Group Chat";
const string WorkflowDescription = "A test group chat workflow";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Equal(WorkflowDescription, workflow.Description);
}
[Fact]
public void BuildGroupChat_WithNameOnly_SetsWorkflowName()
{
const string WorkflowName = "Named Group Chat";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"))
.WithName(WorkflowName)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Null(workflow.Description);
}
[Fact]
public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull()
{
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"))
.Build();
Assert.Null(workflow.Name);
Assert.Null(workflow.Description);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public async Task Test_AgentWorkflowBuilder_BuildSequential_DelegatesToBuilderAsync(int numAgents)
[InlineData(4)]
[InlineData(5)]
public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents)
{
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
var workflow = AgentWorkflowBuilder.BuildSequential(
from i in Enumerable.Range(1, numAgents)
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"));
select new DoubleEchoAgent($"agent{i}"));
// Smoke: end-to-end run produces a non-empty result. Detailed pipeline-ordering
// assertions live in SequentialWorkflowBuilderTests.
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[numAgents + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < numAgents + 1; i++)
{
string id = $"agent{((i - 1) % numAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
[Fact]
public void Test_AgentWorkflowBuilder_BuildSequential_WithWorkflowNameSetsNameOnWorkflow()
private class DoubleEchoAgent(string name) : AIAgent
{
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
"static-sequential",
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"));
public override string Name => name;
workflow.Name.Should().Be("static-sequential");
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var contents = messages.SelectMany(m => m.Contents).ToList();
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
}
}
[Fact]
public void Test_AgentWorkflowBuilder_BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
private sealed class DoubleEchoAgentSession() : AgentSession();
[Fact]
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_DelegatesToBuilderAsync()
public async Task BuildConcurrent_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
var workflow = AgentWorkflowBuilder.BuildConcurrent(
[
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
]);
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
// TODO: https://github.com/microsoft/agent-framework/issues/784
// These asserts are flaky until we guarantee message delivery order.
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
Assert.Equal(2, result.Count);
}
}
[Fact]
public void Test_AgentWorkflowBuilder_BuildConcurrent_WithWorkflowNameSetsNameOnWorkflow()
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
{
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
"static-concurrent",
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")]);
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.AddParticipants(new DoubleEchoAgent("agent3"))
.Build();
workflow.Name.Should().Be("static-concurrent");
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[maxIterations + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < maxIterations + 1; i++)
{
string id = $"agent{((i - 1) % NumAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
[Fact]
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_AggregatorIsHonoredAsync()
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
// Replace the default ("last message from each agent") with a custom aggregator,
// and confirm the workflow yields its result.
List<ChatMessage> sentinel = [new(ChatRole.Assistant, "custom-aggregator-result")];
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")],
aggregator: _ => sentinel);
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
(_, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
result.Should().NotBeNull().And.ContainSingle();
result![0].Text.Should().Be("custom-aggregator-result");
return await ProcessWorkflowRunAsync(run);
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_RejectsNull()
private static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
output = e;
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
break;
case SuperStepCompletedEvent stepCompleted:
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
break;
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_ReturnsConfigurableBuilder()
private static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
{
barrier.Value!.SetResult(true);
}
SequentialWorkflowBuilder builder = AgentWorkflowBuilder.CreateSequentialBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
await barrier.Value!.Task.ConfigureAwait(false);
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_ReturnsConfigurableBuilder()
{
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
ConcurrentWorkflowBuilder builder = AgentWorkflowBuilder.CreateConcurrentBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateHandoffBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
#pragma warning restore MAAIW001
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateGroupChatBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateMagenticBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("managerAgent", () => AgentWorkflowBuilder.CreateMagenticBuilderWith(null!));
#pragma warning restore MAAIW001
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
{
await Task.Yield();
yield return update;
}
}
}
}
@@ -1,121 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows.UnitTests.BackwardsCompatibility;
/// <summary>
/// Tests pinning the JSON shape of checkpoint-adjacent types so older payloads keep
/// deserializing correctly after the Outputs overhaul (see implementation-plan §5.7).
/// </summary>
public class JsonCheckpointSerializationTests
{
private static readonly JsonSerializerOptions s_options = WorkflowsJsonUtilities.DefaultOptions;
private static WorkflowInfo BuildInfoWithOutputExecutors(Dictionary<string, HashSet<OutputTag>> outputs)
=> new(
executors: new Dictionary<string, ExecutorInfo>(),
edges: new Dictionary<string, List<EdgeInfo>>(),
requestPorts: [],
startExecutorId: "start",
outputExecutorIds: outputs);
// ---------- WorkflowOutputEvent.Tags in-process round-trip (no JSON) ----------
[Fact]
public void Test_WorkflowOutputEvent_SingleTagCtorPopulatesTags()
{
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tag: OutputTag.Intermediate);
evt.ExecutorId.Should().Be("e1");
evt.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
evt.IsIntermediate().Should().BeTrue();
}
[Fact]
public void Test_WorkflowOutputEvent_NoTagsCtorIsUntagged()
{
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1");
evt.Tags.Should().BeEmpty();
evt.IsIntermediate().Should().BeFalse("an event with no tags is a terminal/regular output");
}
[Fact]
public void Test_WorkflowOutputEvent_MultiTagCtorPreservesAllTags()
{
OutputTag customTag = JsonSerializer.Deserialize<OutputTag>("\"custom\"", s_options);
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tags: new[] { OutputTag.Intermediate, customTag });
evt.Tags.Should().HaveCount(2);
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
evt.HasTag(customTag).Should().BeTrue();
evt.IsIntermediate().Should().BeTrue();
}
// ---------- WorkflowInfo.OutputExecutorIds shape ----------
//
// Note: per the comment in WorkflowsJsonUtilities, WorkflowEvent / WorkflowOutputEvent
// is *not* currently a serialized checkpoint shape (events are not persisted into
// checkpoints today), so we do not pin a JSON round-trip for Tags on the event itself
// here. The tag JSON round-trip is exercised by OutputTagTests; the
// OutputExecutorIds map shape is the actually-load-bearing back-compat surface.
[Fact]
public void Test_JsonCheckpoint_WorkflowOutputExecutorsReadsLegacyArrayShape()
{
const string LegacyJson = """
{
"executors": {},
"edges": {},
"requestPorts": [],
"startExecutorId": "start",
"outputExecutorIds": ["a", "b"]
}
""";
WorkflowInfo? info = JsonSerializer.Deserialize<WorkflowInfo>(LegacyJson, s_options);
info.Should().NotBeNull();
info!.OutputExecutorIds.Should().HaveCount(2);
info.OutputExecutorIds["a"].Should().BeEmpty("legacy ids are untagged regular outputs");
info.OutputExecutorIds["b"].Should().BeEmpty();
}
[Fact]
public void Test_JsonCheckpoint_WorkflowOutputExecutorsWritesMapShape()
{
Dictionary<string, HashSet<OutputTag>> outputs = new()
{
["a"] = [],
["b"] = [OutputTag.Intermediate],
};
WorkflowInfo info = BuildInfoWithOutputExecutors(outputs);
string json = JsonSerializer.Serialize(info, s_options);
WorkflowInfo? back = JsonSerializer.Deserialize<WorkflowInfo>(json, s_options);
back.Should().NotBeNull();
back!.OutputExecutorIds.Should().HaveCount(2);
back.OutputExecutorIds["a"].Should().BeEmpty();
back.OutputExecutorIds["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
// The map shape is detectable in the serialized JSON: the property value starts with `{`, not `[`.
int idx = json.IndexOf("\"outputExecutorIds\"", System.StringComparison.Ordinal);
idx.Should().BeGreaterThan(-1);
int colon = json.IndexOf(':', idx);
int firstNonSpace = colon + 1;
while (firstNonSpace < json.Length && char.IsWhiteSpace(json[firstNonSpace]))
{
firstNonSpace++;
}
json[firstNonSpace].Should().Be('{', "OutputExecutorIds is written in the new map shape");
}
}
@@ -1,164 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.UnitTests.Futures;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class ConcurrentWorkflowBuilderTests
{
[Fact]
public void Test_ConcurrentWorkflowBuilder_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => new ConcurrentWorkflowBuilder(null!));
Assert.Throws<ArgumentException>("agents", () => new ConcurrentWorkflowBuilder().Build());
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
}
[Fact]
public async Task Test_ConcurrentWorkflowBuilder_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
var workflow = new ConcurrentWorkflowBuilder(
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining))
.Build();
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
// TODO: https://github.com/microsoft/agent-framework/issues/784
// These asserts are flaky until we guarantee message delivery order.
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
Assert.Equal(2, result.Count);
}
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_DefaultDesignationsMatchSpec()
{
Workflow workflow = new ConcurrentWorkflowBuilder(
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent2"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Where(kvp => kvp.Value.Count == 0)
.Should().ContainSingle("ConcurrentEndExecutor is the sole terminal output by default");
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
.Should().HaveCount(6, "every agent (3) and per-agent accumulator (3) is designated intermediate by default");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
{
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
Workflow workflow = new ConcurrentWorkflowBuilder(a1, a2, a3)
.WithOutputFrom(a1)
.WithIntermediateOutputFrom([a2])
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Should().HaveCount(2,
"only the two explicitly-designated agents land on the inner builder; the end + accumulator defaults are suppressed");
designations.Values.Where(tags => tags.Count == 0)
.Should().ContainSingle("agent1 is the only terminal designation");
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
.Should().ContainSingle("agent2 is the only intermediate designation");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_DesignationForNonParticipantThrows()
{
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
ConcurrentWorkflowBuilder builder = new ConcurrentWorkflowBuilder(participant)
.WithIntermediateOutputFrom([stranger]);
Action build = () => builder.Build();
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_WithNamePropagatesToWorkflow()
{
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithName("named-concurrent")
.Build();
workflow.Name.Should().Be("named-concurrent");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_WithDescriptionPropagatesToWorkflow()
{
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithDescription("describes the concurrent fan-out/fan-in")
.Build();
workflow.Description.Should().Be("describes the concurrent fan-out/fan-in");
}
[Collection(FuturesSerialCollection.Name)]
public class AsAgentForwarding
{
[Fact]
public async Task Test_ConcurrentWorkflowBuilder_AsAgent_OnlyTerminalDesignationSurfacesAsync()
{
using FuturesScope _ = new(enabled: true);
OrchestrationTestHelpers.DoubleEchoAgent agent1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent agent2 = new("agent2");
// Designate only agent1 as a terminal output source — agent2 and the fan-in
// aggregator default-intermediate designations are suppressed.
Workflow workflow = new ConcurrentWorkflowBuilder(agent1, agent2)
.WithOutputFrom(agent1)
.Build();
List<AgentResponseUpdate> updates = await workflow
.AsAIAgent("WorkflowAgent")
.RunStreamingAsync(new ChatMessage(ChatRole.User, "abc"))
.ToListAsync();
HashSet<string> authoredBy = updates
.Select(u => u.AuthorName)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToHashSet();
authoredBy.Should().Contain("agent1", "the designated agent must surface");
authoredBy.Should().NotContain("agent2",
"the undesignated agent must not surface when only one is designated under Futures-on");
}
}
}
@@ -1,289 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// Runner-level coverage for <see cref="Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering"/>.
/// Exercises every combination of (flag on/off) × (designation kind) × (payload shape) to pin the
/// runner's behavior in both the legacy bypass path and the unified filter-and-tag path.
/// </summary>
public static partial class FuturesTests
{
[Collection(FuturesSerialCollection.Name)]
public class AgentResponseOutputFilteringAndTaggingTests
{
private const string SourceId = "yielder";
private static AgentResponse SampleResponse(string text = "hi")
=> new(new ChatMessage(ChatRole.Assistant, text));
private static AgentResponseUpdate SampleUpdate(string text = "tick")
=> new(ChatRole.Assistant, text);
private static async Task<List<WorkflowEvent>> RunAsync<T>(Workflow workflow, T input) where T : notnull
{
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
events.Add(evt);
}
return events;
}
private static Workflow BuildAgentResponseWorkflow(Action<WorkflowBuilder, YieldAgentResponseExecutor>? designate = null)
{
YieldAgentResponseExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
private static Workflow BuildAgentResponseUpdateWorkflow(Action<WorkflowBuilder, YieldAgentResponseUpdateExecutor>? designate = null)
{
YieldAgentResponseUpdateExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
private static Workflow BuildPocoWorkflow(Action<WorkflowBuilder, YieldPocoExecutor>? designate = null)
{
YieldPocoExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
// F1
[Fact]
public async Task Test_Runner_LegacyAgentResponseBypass_RaisesUntaggedEventAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.ExecutorId.Should().Be(SourceId);
emitted.Tags.Should().BeEmpty("legacy bypass attaches no tags");
emitted.IsIntermediate().Should().BeFalse();
}
// F2
[Fact]
public async Task Test_Runner_LegacyAgentResponseUpdateBypass_RaisesUntaggedEventAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseUpdateWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseUpdateEvent emitted = events.OfType<AgentResponseUpdateEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty();
}
// F3
[Fact]
public async Task Test_Runner_LegacyBypassIgnoresDesignationAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("legacy bypass ignores the designation entirely");
emitted.IsIntermediate().Should().BeFalse("legacy bypass does not propagate tags");
}
// F4
[Fact]
public async Task Test_Runner_LegacyPocoIsFilteredAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildPocoWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
events.OfType<WorkflowOutputEvent>().Should().BeEmpty("POCO outputs always go through the filter; undesignated source is dropped");
}
// F5
[Fact]
public async Task Test_Runner_UndesignatedAgentResponseIsFilteredWhenFuturesOnAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
events.OfType<WorkflowOutputEvent>().Should().BeEmpty(
"with the future on, AgentResponse must be designated to surface");
}
// F6
[Fact]
public async Task Test_Runner_DesignatedTerminalAgentResponseHasEmptyTagsAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithOutputFrom(e));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("terminal designation carries no tag");
emitted.IsIntermediate().Should().BeFalse();
}
// F7
[Fact]
public async Task Test_Runner_DesignatedIntermediateAgentResponseHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F8
[Fact]
public async Task Test_Runner_DesignatedIntermediateAgentResponseUpdateHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseUpdateWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseUpdateEvent emitted = events.OfType<AgentResponseUpdateEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F9
[Fact]
public async Task Test_Runner_TagsAccumulateOutputThenIntermediateAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithOutputFrom(e);
b.WithIntermediateOutputFrom([e]);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate },
"terminal+intermediate union is {{ Intermediate }} (terminal contributes the entry but no tag)");
emitted.IsIntermediate().Should().BeTrue();
}
// F10
[Fact]
public async Task Test_Runner_TagsAccumulateIntermediateThenOutputAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithIntermediateOutputFrom([e]);
b.WithOutputFrom(e);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate }, "designation order is irrelevant");
emitted.IsIntermediate().Should().BeTrue();
}
// F11
[Fact]
public async Task Test_Runner_DesignatedIntermediatePocoHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildPocoWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
WorkflowOutputEvent emitted = events.OfType<WorkflowOutputEvent>().Should().ContainSingle().Subject;
emitted.Should().NotBeOfType<AgentResponseEvent>();
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F12
[Fact]
public async Task Test_Runner_DesignatedTerminalPocoHasEmptyTagsAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildPocoWorkflow(static (b, e) => b.WithOutputFrom(e));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
WorkflowOutputEvent emitted = events.OfType<WorkflowOutputEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty();
emitted.IsIntermediate().Should().BeFalse();
}
// F13
[Fact]
public async Task Test_Runner_RepeatedTerminalDesignationDedupesAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithOutputFrom(e);
b.WithOutputFrom(e);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("repeated terminal designation contributes no tag");
}
// ---- Executors -----------------------------------------------------------
internal sealed class YieldAgentResponseExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, AgentResponse>(this.HandleAsync));
private ValueTask<AgentResponse> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(SampleResponse(input));
}
internal sealed class YieldAgentResponseUpdateExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, AgentResponseUpdate>(this.HandleAsync));
private ValueTask<AgentResponseUpdate> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(SampleUpdate(input));
}
public sealed record Poco(string Value);
internal sealed class YieldPocoExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, Poco>(this.HandleAsync));
private ValueTask<Poco> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(new Poco(input));
}
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// Sets <see cref="Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering"/> for
/// the lifetime of the scope, restoring the prior value on dispose. Pair every use with
/// <c>using</c> and run inside the <c>FuturesSerial</c> xUnit collection to avoid leaking
/// state across parallel tests.
/// </summary>
internal sealed class FuturesScope : IDisposable
{
private readonly bool _previous;
public FuturesScope(bool enabled)
{
this._previous = Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering;
Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering = enabled;
}
public void Dispose()
{
Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering = this._previous;
}
}
@@ -1,19 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// xUnit collection marker for tests that mutate the process-global
/// <see cref="Workflows.Futures"/> switches. Membership in this collection serializes
/// the tests against each other so that <see cref="FuturesScope"/> cannot leak state
/// into a concurrently running test.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix",
Justification = "xUnit's [CollectionDefinition] pattern names the marker type after the collection's purpose; the 'Collection' suffix is idiomatic.")]
public sealed class FuturesSerialCollection
{
public const string Name = "FuturesSerial";
}
@@ -1,479 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Orchestration-level tests for <see cref="AgentWorkflowBuilder.CreateGroupChatBuilderWith"/> covering
/// <see cref="FunctionCallContent"/> and <see cref="ToolApprovalRequestContent"/> behavior across
/// real <see cref="ChatClientAgent"/> participants. These tests parallel the equivalents in
/// <see cref="HandoffOrchestrationTests"/> to ensure that the broadcast-based group chat host
/// (each participant maintains its own per-agent session via <see cref="Specialized.AIAgentHostExecutor"/>;
/// only the speaker receives a <see cref="TurnToken"/>; messages are broadcast to every other
/// participant) preserves the same HITL semantics as the handoff path.
/// </summary>
public class GroupChatOrchestrationTests
{
/// <summary>
/// End-to-end tool-approval checkpoint/resume scenario through a <see cref="RoundRobinGroupChatManager"/>
/// with a single participant. Mirrors the maximal repro added in PR #5952 (Track A2 in
/// <c>docs/working/issue-5350-root-cause-validation-plan.md</c>): a <see cref="ChatClientAgent"/>
/// over a mock chat client emits a <see cref="FunctionCallContent"/> for an
/// <see cref="ApprovalRequiredAIFunction"/>, the runtime surfaces a
/// <see cref="ToolApprovalRequestContent"/> as an external <see cref="RequestInfoEvent"/>, the test
/// checkpoints while the request is pending, resumes from a fresh handle, asserts that the
/// resumed <c>TARC.ToolCall</c> is still a <see cref="FunctionCallContent"/>, sends an
/// approval response, and verifies that the wrapped <see cref="AIFunction"/> is invoked
/// exactly once and the workflow completes without errors.
/// </summary>
[Fact]
public async Task GroupChat_ToolApproval_JsonCheckpointResume_PreservesFunctionCallContentAndInvokesToolAsync()
{
ApprovalHarness harness = new();
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
.AddParticipants(harness.Agent)
.Build();
await RunCheckpointedApprovalRoundTripAsync(
workflow,
harness,
CheckpointManager.CreateJson(new InMemoryJsonStore()),
scenarioName: "GroupChat (round-robin, single participant)");
}
/// <summary>
/// Round-robin group chat with two participants. The first participant exposes an
/// <see cref="ApprovalRequiredAIFunction"/> and emits a <see cref="FunctionCallContent"/> for it on
/// its first turn. The test denies the approval and asserts that the conversation continues:
/// the first agent runs once more (the FICC denial branch produces a final assistant message),
/// then the host broadcasts that message and selects the second agent, which produces its own
/// reply. This mirrors <c>Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync</c>
/// but on the group-chat path.
/// </summary>
[Fact]
public async Task GroupChat_ToolApproval_DeniedResponse_ConversationContinuesAsync()
{
int approvalToolCallCount = 0;
const string ApprovalCallId = "approve_call_1";
const string ApprovalToolName = "DoSomethingPrivileged";
AIFunction approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
() =>
{
Interlocked.Increment(ref approvalToolCallCount);
return "tool result";
},
name: ApprovalToolName,
description: "Performs a privileged action"));
int agent1CallCount = 0;
var agent1 = new ChatClientAgent(
new MockChatClient((messages, options) =>
{
int call = Interlocked.Increment(ref agent1CallCount);
return call switch
{
1 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent(ApprovalCallId, ApprovalToolName)])),
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent1 final response")),
};
}),
instructions: "You are agent1.",
name: "agent1",
tools: [approvalTool]);
int agent2CallCount = 0;
var agent2 = new ChatClientAgent(
new MockChatClient((messages, options) =>
{
Interlocked.Increment(ref agent2CallCount);
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent2 reply"));
}),
instructions: "You are agent2.",
name: "agent2");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(agent1, agent2)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = InProcessExecution.OffThread.WithCheckpointing(checkpointManager);
ExternalRequest? pendingRequest = null;
CheckpointInfo? lastCheckpoint = null;
List<WorkflowEvent> firstRunEvents = [];
await using (StreamingRun firstRun = await env.RunStreamingAsync(workflow, new List<ChatMessage> { new(ChatRole.User, "hello") }))
{
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
.Should().BeTrue();
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
firstRunEvents.Add(evt);
if (evt is RequestInfoEvent requestInfo)
{
pendingRequest ??= requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
lastCheckpoint = cp;
}
}
}
pendingRequest.Should().NotBeNull("agent1 should have surfaced an approval request for the privileged tool");
firstRunEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty();
firstRunEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty();
approvalToolCallCount.Should().Be(0, "the tool must not be invoked before approval is granted");
ToolApprovalRequestContent approvalRequest =
pendingRequest!.Data.As<ToolApprovalRequestContent>().Should().NotBeNull()
.And.Subject.As<ToolApprovalRequestContent>();
approvalRequest.ToolCall.Should().BeOfType<FunctionCallContent>();
((FunctionCallContent)approvalRequest.ToolCall).Name.Should().Be(ApprovalToolName);
// Deny the request and continue the conversation.
ExternalResponse denial = pendingRequest.CreateResponse(approvalRequest.CreateResponse(approved: false, reason: "Denied"));
List<WorkflowEvent> secondRunEvents = [];
List<ChatMessage>? finalOutput = null;
await using (StreamingRun resumed = await env.ResumeStreamingAsync(workflow, lastCheckpoint!))
{
await resumed.SendResponseAsync(denial);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
secondRunEvents.Add(evt);
if (evt is WorkflowOutputEvent outputEvt)
{
finalOutput = outputEvt.As<List<ChatMessage>>();
}
}
}
secondRunEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
"denying the approval should not surface any workflow errors");
secondRunEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty(
"denying the approval should not raise executor failures (regression guard for the GroupChat duplicate-key bug pinned in PR #5952's A2 test before the broadcast refactor)");
approvalToolCallCount.Should().Be(0, "the tool must not be invoked after denial");
agent1CallCount.Should().BeGreaterThanOrEqualTo(2, "agent1 should be re-invoked by FICC after the denial to produce a final assistant message");
agent2CallCount.Should().Be(1, "agent2 should be the next round-robin speaker and produce its own reply");
finalOutput.Should().NotBeNull();
finalOutput!.Should().Contain(m => m.AuthorName == "agent1");
finalOutput.Should().Contain(m => m.AuthorName == "agent2" && m.Text == "agent2 reply");
}
/// <summary>
/// Round-robin group chat with two participants. The first participant declares a
/// non-invokable function via <c>AIFunctionFactory.CreateDeclaration</c>,
/// causing the function call to be surfaced as an external <see cref="FunctionCallContent"/>
/// (<see cref="RequestInfoEvent"/>). The test responds with a <see cref="FunctionResultContent"/>
/// and asserts that the conversation continues: the first agent's second invocation produces a
/// final assistant message, then the group chat advances to the second agent which produces
/// its own reply. This mirrors <c>Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync</c>
/// but on the group-chat path.
/// </summary>
[Fact]
public async Task GroupChat_FunctionCall_ExternallyResolved_ConversationContinuesAsync()
{
const string FunctionCallId = "fcc_call_1";
const string FunctionName = "FetchExternalData";
JsonElement schema = AIFunctionFactory.Create(() => true).JsonSchema;
AIFunctionDeclaration declaration = AIFunctionFactory.CreateDeclaration(FunctionName, "Fetches external data", schema);
int agent1CallCount = 0;
var agent1 = new ChatClientAgent(
new MockChatClient((messages, options) =>
{
int call = Interlocked.Increment(ref agent1CallCount);
return call switch
{
1 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent(FunctionCallId, FunctionName)])),
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent1 final response")),
};
}),
instructions: "You are agent1.",
name: "agent1",
tools: [declaration]);
int agent2CallCount = 0;
var agent2 = new ChatClientAgent(
new MockChatClient((messages, options) =>
{
Interlocked.Increment(ref agent2CallCount);
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent2 reply"));
}),
instructions: "You are agent2.",
name: "agent2");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(agent1, agent2)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = InProcessExecution.OffThread.WithCheckpointing(checkpointManager);
ExternalRequest? pendingRequest = null;
CheckpointInfo? lastCheckpoint = null;
await using (StreamingRun firstRun = await env.RunStreamingAsync(workflow, new List<ChatMessage> { new(ChatRole.User, "hello") }))
{
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
.Should().BeTrue();
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
pendingRequest ??= requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
lastCheckpoint = cp;
}
}
}
pendingRequest.Should().NotBeNull("agent1 should have surfaced a FunctionCallContent for the declaration-only tool");
FunctionCallContent functionCall =
pendingRequest!.Data.As<FunctionCallContent>().Should().NotBeNull()
.And.Subject.As<FunctionCallContent>();
functionCall.Name.Should().Be(FunctionName);
functionCall.CallId.Should().EndWith(FunctionCallId,
"the workflow rewrites the CallId with an executor-scoped prefix, but should preserve the original tail");
// Respond with a function result and let the conversation continue.
ExternalResponse response = pendingRequest.CreateResponse(new FunctionResultContent(functionCall.CallId, "external-data-payload"));
List<WorkflowEvent> resumeEvents = [];
List<ChatMessage>? finalOutput = null;
await using (StreamingRun resumed = await env.ResumeStreamingAsync(workflow, lastCheckpoint!))
{
await resumed.SendResponseAsync(response);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
resumeEvents.Add(evt);
if (evt is WorkflowOutputEvent outputEvt)
{
finalOutput = outputEvt.As<List<ChatMessage>>();
}
}
}
resumeEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty();
resumeEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty();
agent1CallCount.Should().BeGreaterThanOrEqualTo(2, "agent1 should be re-invoked once the externally-resolved function result is delivered");
agent2CallCount.Should().Be(1, "agent2 should be the next round-robin speaker after agent1 finishes");
finalOutput.Should().NotBeNull();
finalOutput!.Should().Contain(m => m.AuthorName == "agent1");
finalOutput.Should().Contain(m => m.AuthorName == "agent2" && m.Text == "agent2 reply");
}
/// <summary>
/// Shared end-to-end driver for the approval checkpoint/resume scenario; modelled on the
/// <c>RunReproAsync</c> helper from PR #5952. Runs the workflow until an approval request is
/// pending, captures the latest checkpoint, disposes the run, resumes from a fresh handle,
/// asserts the resumed payload still carries a <see cref="FunctionCallContent"/>, sends an
/// approval response, and asserts the wrapped tool is invoked exactly once and the workflow
/// finishes without errors.
/// </summary>
private static async Task RunCheckpointedApprovalRoundTripAsync(
Workflow workflow,
ApprovalHarness harness,
CheckpointManager checkpointManager,
string scenarioName)
{
InProcessExecutionEnvironment env = InProcessExecution.OffThread;
List<ChatMessage> inputMessages = [new(ChatRole.User, "What's the weather in Amsterdam?")];
ExternalRequest? firstRunRequest = null;
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, inputMessages))
{
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
.Should().BeTrue($"[{scenarioName}] the workflow should accept a TurnToken");
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
firstRunRequest ??= requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
}
firstRunRequest.Should().NotBeNull(
$"[{scenarioName}] the ChatClientAgent + FICC pipeline should surface the approval request as a workflow RequestInfoEvent");
checkpoint.Should().NotBeNull(
$"[{scenarioName}] a checkpoint should have been produced while the approval request was pending");
harness.ChatCallCount.Should().Be(1, $"[{scenarioName}] the mock chat client should have been called exactly once before approval was requested");
harness.InvocationCount.Should().Be(0, $"[{scenarioName}] the underlying tool must NOT have been invoked before approval was granted");
ToolApprovalRequestContent? preCheckpoint = firstRunRequest!.Data.As<ToolApprovalRequestContent>();
preCheckpoint.Should().NotBeNull($"[{scenarioName}] the pending external request should carry a ToolApprovalRequestContent payload");
preCheckpoint!.ToolCall.Should().BeOfType<FunctionCallContent>(
$"[{scenarioName}] the pre-checkpoint pending request payload must already be a FunctionCallContent");
// Resume on a fresh handle and capture the re-emitted approval request.
ExternalRequest? resumedRequest = null;
List<WorkflowEvent> postResumeEvents = [];
await using (StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!))
{
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
resumedRequest ??= requestInfo.Request;
}
}
resumedRequest.Should().NotBeNull($"[{scenarioName}] the resumed workflow should re-emit the pending approval RequestInfoEvent");
ToolApprovalRequestContent? postResume = resumedRequest!.Data.As<ToolApprovalRequestContent>();
postResume.Should().NotBeNull(
$"[{scenarioName}] ExternalRequest.Data.As<ToolApprovalRequestContent>() should materialize the payload after JSON-checkpoint resume");
postResume!.ToolCall.Should().NotBeNull($"[{scenarioName}] the resumed TARC must carry its ToolCall");
postResume.ToolCall.Should().BeOfType<FunctionCallContent>(
$"[{scenarioName}] after CheckpointManager.CreateJson round-trip via ResumeStreamingAsync, " +
"ToolApprovalRequestContent.ToolCall must still be a FunctionCallContent so that " +
"FunctionInvokingChatClient's pattern match (`tarc.ToolCall is FunctionCallContent`) continues to fire.");
ToolApprovalResponseContent approvalResponse = postResume.CreateResponse(approved: true);
await resumed.SendResponseAsync(resumedRequest.CreateResponse(approvalResponse));
using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(30));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token))
{
postResumeEvents.Add(evt);
}
}
harness.InvocationCount.Should().Be(1,
$"[{scenarioName}] approving the request should cause FunctionInvokingChatClient to invoke the wrapped AIFunction exactly once");
postResumeEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
$"[{scenarioName}] no workflow errors should be raised when responding to the resumed approval request");
postResumeEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty(
$"[{scenarioName}] no executor failures should be raised when responding to the resumed approval request " +
"(regression guard: pre-broadcast-refactor this test was the `Track A2` repro in PR #5952 which surfaced a " +
"duplicate-key ArgumentException out of FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses).");
}
/// <summary>
/// Bundles a <see cref="ChatClientAgent"/> with a counting <see cref="ApprovalRequiredAIFunction"/>
/// tool and a <see cref="MockChatClient"/> that emits a function call on the first chat turn
/// and a final assistant text on subsequent turns (after FICC has processed the approval
/// and appended a <see cref="FunctionResultContent"/>).
/// </summary>
private sealed class ApprovalHarness
{
public const string ToolName = "GetWeather";
public const string ToolResultText = "Sunny, 22°C";
public const string ToolCallId = "call-1";
public const string FinalAssistantText = "The weather in Amsterdam is sunny and 22°C.";
private int _invocationCount;
private int _chatCallIndex;
public int InvocationCount => Volatile.Read(ref this._invocationCount);
public int ChatCallCount => Volatile.Read(ref this._chatCallIndex);
public ChatClientAgent Agent { get; }
public ApprovalHarness()
{
AIFunction underlyingTool = AIFunctionFactory.Create(
([Description("City to look up")] string city) =>
{
Interlocked.Increment(ref this._invocationCount);
return ToolResultText;
},
name: ToolName,
description: "Gets the weather for the given city");
ApprovalRequiredAIFunction approvalTool = new(underlyingTool);
MockChatClient mockChatClient = new((messages, options) =>
{
int index = Interlocked.Increment(ref this._chatCallIndex) - 1;
return index switch
{
0 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent(
callId: ToolCallId,
name: ToolName,
arguments: new Dictionary<string, object?> { ["city"] = "Amsterdam" })])),
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, FinalAssistantText)),
};
});
this.Agent = new ChatClientAgent(
mockChatClient,
instructions: "You are a weather agent.",
name: "WeatherAgent",
tools: [approvalTool]);
}
}
/// <summary>
/// Minimal <see cref="IChatClient"/> stub for orchestration tests; delegates each call to a
/// caller-supplied factory.
/// </summary>
private sealed class MockChatClient(Func<IEnumerable<ChatMessage>, ChatOptions?, ChatResponse> responseFactory) : IChatClient
{
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(responseFactory(messages, options));
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatResponse response = await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}
}

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