mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5cbbc17b | ||
|
|
f38c17a406 | ||
|
|
b298113d15 | ||
|
|
8091d052d8 | ||
|
|
52a8045bb6 | ||
|
|
78d175a1e2 | ||
|
|
b59a854fcd | ||
|
|
8b0db48d33 | ||
|
|
5affc9c333 | ||
|
|
edcc786651 | ||
|
|
07a1e83492 | ||
|
|
fa2a6af443 | ||
|
|
11c8d89ab2 | ||
|
|
6510d6e3c8 | ||
|
|
dd9a4b6321 | ||
|
|
e8ff541ebf | ||
|
|
d2d5384f28 | ||
|
|
1fccf16f11 | ||
|
|
8ed2159c4b | ||
|
|
b000a2cf51 | ||
|
|
0578f4c910 | ||
|
|
e9a606344a | ||
|
|
d2f79930d5 |
@@ -0,0 +1,64 @@
|
||||
name: Free runner disk space
|
||||
description: |
|
||||
Reclaims disk space on GitHub-hosted Ubuntu runners by removing
|
||||
pre-installed toolchains we do not use (Android SDK, GHC/Haskell,
|
||||
CodeQL bundle), Docker images, and swap. Also relocates the
|
||||
NuGet package cache to /mnt (which has ~75 GB free vs ~14 GB
|
||||
on /). No-op on non-Linux runners.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Free disk space (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Disk usage before cleanup"
|
||||
df -h /
|
||||
echo "::endgroup::"
|
||||
|
||||
# Remove pre-installed toolchains we never use on this repo's
|
||||
# dotnet/python jobs. These reclaim ~25-30 GB on ubuntu-latest.
|
||||
sudo rm -rf \
|
||||
/usr/local/lib/android \
|
||||
/usr/share/dotnet/sdk/NuGetFallbackFolder \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL \
|
||||
/opt/hostedtoolcache/PyPy \
|
||||
/opt/hostedtoolcache/Ruby \
|
||||
/opt/hostedtoolcache/go \
|
||||
/usr/local/share/boost \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/vcpkg \
|
||||
/usr/local/lib/heroku \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/PyPy" \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/Ruby" \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/go" || true
|
||||
|
||||
# Drop docker images shipped on the runner; jobs that need
|
||||
# docker pull what they need fresh.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
sudo docker image prune --all --force >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Disable swap to free its backing file.
|
||||
sudo swapoff -a || true
|
||||
sudo rm -f /mnt/swapfile /swapfile || true
|
||||
|
||||
echo "::group::Disk usage after cleanup"
|
||||
df -h /
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Relocate NuGet package cache to /mnt (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /mnt/nuget
|
||||
sudo chown -R "$USER":"$USER" /mnt/nuget
|
||||
echo "NUGET_PACKAGES=/mnt/nuget" >> "$GITHUB_ENV"
|
||||
echo "Relocated NuGet package cache to /mnt/nuget"
|
||||
df -h /mnt || true
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
@@ -191,6 +194,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
@@ -365,6 +371,9 @@ jobs:
|
||||
dotnet
|
||||
python
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
@@ -452,6 +461,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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,6 +23,14 @@ 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
|
||||
|
||||
@@ -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.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.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.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.55.0" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.56.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.12.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
@@ -13,8 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+2
@@ -13,8 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -297,7 +297,7 @@ public class AgentResponse
|
||||
AgentId = this.AgentId,
|
||||
ResponseId = this.ResponseId,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = this.CreatedAt,
|
||||
CreatedAt = message.CreatedAt ?? this.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -281,14 +281,19 @@ internal static class OutputConverter
|
||||
|
||||
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
|
||||
|
||||
var itemId = GenerateItemId("fc");
|
||||
var outputItem = new OutputItemFunctionToolCallOutput(
|
||||
// Use the SDK's convenience method so the OutputItemFunctionToolCallOutput
|
||||
// is constructed with a populated Id. The public OutputItemFunctionToolCallOutput
|
||||
// ctor only sets CallId/Output (Id is read-only), and AddOutputItem<T>+EmitAdded
|
||||
// does not auto-stamp Id — only ResponseId/AgentReference. Without this, the
|
||||
// serialized item arrives at the Foundry storage layer with id=null and is
|
||||
// rejected with "ID cannot be null or empty (Parameter 'id')".
|
||||
foreach (var evt in stream.OutputItemFunctionCallOutput(
|
||||
functionResult.CallId,
|
||||
BinaryData.FromString(outputText));
|
||||
BinaryData.FromString(outputText)))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
|
||||
yield return outputBuilder.EmitAdded(outputItem);
|
||||
yield return outputBuilder.EmitDone(outputItem);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
+3
-7
@@ -75,18 +75,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Create tool call content for approval request
|
||||
// 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.
|
||||
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,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
@@ -19,6 +20,28 @@ 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,6 +20,28 @@ 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,10 +2,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;
|
||||
|
||||
@@ -37,31 +33,10 @@ public static partial class AgentWorkflowBuilder
|
||||
{
|
||||
Throw.IfNullOrEmpty(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);
|
||||
SequentialWorkflowBuilder builder = new(agents);
|
||||
if (workflowName is not null)
|
||||
{
|
||||
builder = builder.WithName(workflowName);
|
||||
builder.WithName(workflowName);
|
||||
}
|
||||
return builder.Build();
|
||||
}
|
||||
@@ -107,41 +82,14 @@ public static partial class AgentWorkflowBuilder
|
||||
{
|
||||
Throw.IfNull(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);
|
||||
ConcurrentWorkflowBuilder builder = new(agents);
|
||||
if (workflowName is not null)
|
||||
{
|
||||
builder = builder.WithName(workflowName);
|
||||
builder.WithName(workflowName);
|
||||
}
|
||||
if (aggregator is not null)
|
||||
{
|
||||
builder.WithAggregator(aggregator);
|
||||
}
|
||||
return builder.Build();
|
||||
}
|
||||
@@ -155,7 +103,6 @@ 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);
|
||||
@@ -179,4 +126,31 @@ 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,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -15,14 +16,14 @@ internal sealed class WorkflowInfo
|
||||
Dictionary<string, List<EdgeInfo>> edges,
|
||||
HashSet<RequestPortInfo> requestPorts,
|
||||
string startExecutorId,
|
||||
HashSet<string>? outputExecutorIds)
|
||||
Dictionary<string, HashSet<OutputTag>>? outputExecutorIds)
|
||||
{
|
||||
this.Executors = Throw.IfNull(executors);
|
||||
this.Edges = Throw.IfNull(edges);
|
||||
this.RequestPorts = Throw.IfNull(requestPorts);
|
||||
|
||||
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
|
||||
this.OutputExecutorIds = outputExecutorIds ?? [];
|
||||
this.OutputExecutorIds = outputExecutorIds ?? new Dictionary<string, HashSet<OutputTag>>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Dictionary<string, ExecutorInfo> Executors { get; }
|
||||
@@ -32,7 +33,15 @@ internal sealed class WorkflowInfo
|
||||
public TypeId? InputType { get; }
|
||||
public string StartExecutorId { get; }
|
||||
|
||||
public HashSet<string> OutputExecutorIds { 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 bool IsMatch(Workflow workflow)
|
||||
{
|
||||
@@ -80,9 +89,12 @@ internal sealed class WorkflowInfo
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate the outputs
|
||||
// Validate the outputs (key set + tag set per id must match)
|
||||
if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count ||
|
||||
this.OutputExecutorIds.Any(id => !workflow.OutputExecutors.Contains(id)))
|
||||
this.OutputExecutorIds.Any(kvp =>
|
||||
!workflow.OutputExecutors.TryGetValue(kvp.Key, out HashSet<OutputTag>? tags) ||
|
||||
tags.Count != kvp.Value.Count ||
|
||||
!tags.SetEquals(kvp.Value)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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,11 +1,17 @@
|
||||
// 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.Contains(sourceExecutorId);
|
||||
return workflow.OutputExecutors.ContainsKey(sourceExecutorId);
|
||||
}
|
||||
|
||||
public bool TryGetTags(string sourceExecutorId, [NotNullWhen(true)] out HashSet<OutputTag>? tags)
|
||||
=> workflow.OutputExecutors.TryGetValue(sourceExecutorId, out tags);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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,6 +1,8 @@
|
||||
// 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;
|
||||
@@ -13,6 +15,16 @@ 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>
|
||||
@@ -48,12 +60,22 @@ public abstract class GroupChatManager
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Filters the chat history before it's passed to the next agent.
|
||||
/// Filters the messages broadcast to participants for the current turn.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to filter.</param>
|
||||
/// <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="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The filtered chat history.</returns>
|
||||
/// <returns>The filtered message list to broadcast.</returns>
|
||||
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -78,4 +100,125 @@ 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,12 +12,10 @@ 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
|
||||
public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<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;
|
||||
@@ -44,28 +42,6 @@ public sealed class GroupChatWorkflowBuilder
|
||||
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.
|
||||
@@ -75,10 +51,14 @@ public sealed class GroupChatWorkflowBuilder
|
||||
{
|
||||
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 = true
|
||||
ForwardIncomingMessages = false
|
||||
};
|
||||
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
|
||||
@@ -89,15 +69,7 @@ public sealed class GroupChatWorkflowBuilder
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._name))
|
||||
{
|
||||
builder = builder.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._description))
|
||||
{
|
||||
builder = builder.WithDescription(this._description);
|
||||
}
|
||||
this.ApplyMetadata(builder);
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
{
|
||||
@@ -106,6 +78,15 @@ public sealed class GroupChatWorkflowBuilder
|
||||
.AddEdge(participant, host);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(host).Build();
|
||||
this.ApplyOutputDesignations(builder, agentMap, "group chat", () =>
|
||||
{
|
||||
builder.WithOutputFrom(host);
|
||||
if (agentMap.Count > 0)
|
||||
{
|
||||
builder.WithIntermediateOutputFrom([.. agentMap.Values]);
|
||||
}
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ 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.")]
|
||||
@@ -30,7 +25,6 @@ public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkf
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
|
||||
{
|
||||
}
|
||||
@@ -38,8 +32,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>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
|
||||
public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBuilder>
|
||||
where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
|
||||
@@ -55,8 +49,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
|
||||
// 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
|
||||
@@ -116,20 +108,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
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"/>
|
||||
@@ -631,16 +609,31 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(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)
|
||||
{
|
||||
builder.WithName(this._name);
|
||||
agentMap[agent] = executors[agent.Id];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
this.ApplyMetadata(builder);
|
||||
this.ApplyOutputDesignations(builder, agentMap, "handoff", () =>
|
||||
{
|
||||
builder.WithDescription(this._description);
|
||||
}
|
||||
// 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.WithOutputFrom(end).Build();
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,30 +241,47 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
this.CheckEnded();
|
||||
Throw.IfNull(output);
|
||||
|
||||
// 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)
|
||||
bool isAgentResponseShaped = output is AgentResponse or AgentResponseUpdate;
|
||||
|
||||
if (isAgentResponseShaped && !Futures.EnableAgentResponseOutputTaggingAndFiltering)
|
||||
{
|
||||
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);
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
|
||||
if (!sourceExecutor.CanOutput(output.GetType()))
|
||||
if (!isAgentResponseShaped && !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.CanOutput(sourceId, output))
|
||||
if (!this._outputFilter.TryGetTags(sourceId, out HashSet<OutputTag>? tags))
|
||||
{
|
||||
await this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken).ConfigureAwait(false);
|
||||
// Not designated as an output source — drop silently.
|
||||
return;
|
||||
}
|
||||
|
||||
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,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -16,7 +15,6 @@ 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,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
@@ -13,7 +12,6 @@ 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,7 +14,6 @@ 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",
|
||||
@@ -76,7 +75,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,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
@@ -27,12 +26,9 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// not supported on the ManagerAgent.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase<MagenticWorkflowBuilder>
|
||||
{
|
||||
private readonly List<AIAgent> _team = new();
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
|
||||
private int? _maxRounds;
|
||||
private int? _maxResets;
|
||||
@@ -45,20 +41,6 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
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>
|
||||
@@ -115,28 +97,29 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
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)
|
||||
.WithOutputFrom(orchestrator);
|
||||
result.AddFanOutEdge(orchestrator, teamBindings);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
this.ApplyOutputDesignations(result, teamMap, "Magentic", () =>
|
||||
{
|
||||
result.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
{
|
||||
result.WithDescription(this._description);
|
||||
}
|
||||
result.WithOutputFrom(orchestrator);
|
||||
if (teamMap.Count > 0)
|
||||
{
|
||||
result.WithIntermediateOutputFrom([.. teamMap.Values]);
|
||||
}
|
||||
});
|
||||
|
||||
this.ApplyMetadata(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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 — 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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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,4 +69,23 @@ 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);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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,12 +20,25 @@ 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>>();
|
||||
|
||||
@@ -33,30 +46,105 @@ internal sealed class GroupChatHost(
|
||||
{
|
||||
this._manager ??= this._managerFactory(this._agents);
|
||||
|
||||
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
|
||||
// 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)
|
||||
{
|
||||
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
|
||||
this._history.AddRange(messages);
|
||||
}
|
||||
|
||||
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
|
||||
this._agentMap.TryGetValue(nextAgent, out var executor))
|
||||
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)
|
||||
{
|
||||
this._manager.IterationCount++;
|
||||
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
await this.BroadcastAsync(broadcastMessages, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
this._manager = null;
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ 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>
|
||||
{
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
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;
|
||||
@@ -17,7 +15,6 @@ internal sealed class HandoffMessagesFilter
|
||||
this._filteringBehavior = filteringBehavior;
|
||||
}
|
||||
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
internal static bool IsHandoffFunctionName(string name)
|
||||
{
|
||||
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
|
||||
|
||||
+6
-7
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
@@ -18,7 +17,6 @@ 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)
|
||||
{
|
||||
}
|
||||
@@ -27,7 +25,6 @@ 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>
|
||||
@@ -40,7 +37,6 @@ 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>
|
||||
@@ -53,7 +49,6 @@ 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>
|
||||
@@ -138,7 +133,6 @@ 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)
|
||||
{
|
||||
@@ -201,7 +195,12 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
|
||||
// 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);
|
||||
}
|
||||
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 HashSet<string> OutputExecutors { get; init; } = [];
|
||||
internal Dictionary<string, HashSet<OutputTag>> OutputExecutors { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <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.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
|
||||
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Keys.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 HashSet<string> _outputExecutors = [];
|
||||
private readonly Dictionary<string, HashSet<OutputTag>> _outputExecutors = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly string _startExecutorId;
|
||||
private string? _name;
|
||||
@@ -97,22 +97,89 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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"/>.
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
/// <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>
|
||||
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
|
||||
{
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
this._outputExecutors.Add(this.Track(executor).Id);
|
||||
this.EnsureOutputExecutor(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,4 +211,28 @@ 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,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -13,14 +14,39 @@ 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.
|
||||
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class with no tags.
|
||||
/// </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) : base(data)
|
||||
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)
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
this._tags = tags is null ? new HashSet<OutputTag>() : new HashSet<OutputTag>(tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -32,8 +58,21 @@ 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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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,11 +520,20 @@ internal sealed class WorkflowSession : AgentSession
|
||||
goto default;
|
||||
|
||||
case AgentResponseEvent agentResponse:
|
||||
if (!this._includeWorkflowOutputsInResponse)
|
||||
// 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)
|
||||
{
|
||||
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);
|
||||
@@ -539,7 +548,11 @@ internal sealed class WorkflowSession : AgentSession
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (!this._includeWorkflowOutputsInResponse || updateMessages == 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)
|
||||
{
|
||||
goto default;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,8 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(ExecutorIdentity))]
|
||||
[JsonSerializable(typeof(RunnerStateData))]
|
||||
|
||||
// Workflow Representation Types
|
||||
[JsonSerializable(typeof(WorkflowInfo))]
|
||||
[JsonSerializable(typeof(EdgeConnection))]
|
||||
// Workflow Output Types
|
||||
[JsonSerializable(typeof(OutputTag))]
|
||||
|
||||
// Workflow-as-Agent
|
||||
[JsonSerializable(typeof(WorkflowChatHistoryProvider.StoreState))]
|
||||
@@ -101,6 +100,8 @@ 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
|
||||
|
||||
@@ -222,6 +222,42 @@ 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]
|
||||
[Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")]
|
||||
public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -704,6 +704,35 @@ public class OutputConverterTests
|
||||
Assert.Equal("[{\"id\":1}]", inner);
|
||||
}
|
||||
|
||||
// K-06e: Regression — the OutputItemFunctionToolCallOutput must have a populated Id
|
||||
// and a matching wire id on the added/done events. The Foundry storage layer extracts
|
||||
// a partition id from this field and throws "ID cannot be null or empty (Parameter 'id')"
|
||||
// when it is missing.
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_FunctionResult_OutputItemHasIdAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
var done = Assert.Single(events.OfType<ResponseOutputItemDoneEvent>());
|
||||
|
||||
var addedOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
|
||||
var doneOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(done.Item);
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(addedOutput.Id));
|
||||
Assert.False(string.IsNullOrEmpty(doneOutput.Id));
|
||||
Assert.Equal(addedOutput.Id, doneOutput.Id);
|
||||
Assert.Equal("call_1", addedOutput.CallId);
|
||||
Assert.Equal("call_1", doneOutput.CallId);
|
||||
}
|
||||
|
||||
// L-01
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync()
|
||||
|
||||
+2
@@ -8,6 +8,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+147
@@ -1,9 +1,11 @@
|
||||
// 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;
|
||||
@@ -290,6 +292,151 @@ 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()
|
||||
{
|
||||
|
||||
+120
-284
@@ -4,12 +4,9 @@ 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 Microsoft.Agents.AI.Workflows.InProc;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
|
||||
@@ -17,325 +14,164 @@ 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 BuildSequential_InvalidArguments_Throws()
|
||||
public void Test_AgentWorkflowBuilder_BuildSequential_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
|
||||
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public async Task Test_AgentWorkflowBuilder_BuildSequential_DelegatesToBuilderAsync(int numAgents)
|
||||
{
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
from i in Enumerable.Range(1, numAgents)
|
||||
select new OrchestrationTestHelpers.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")]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(numAgents + 1, result.Count);
|
||||
Assert.NotEmpty(updateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildConcurrent_InvalidArguments_Throws()
|
||||
public void Test_AgentWorkflowBuilder_BuildSequential_WithWorkflowNameSetsNameOnWorkflow()
|
||||
{
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
"static-sequential",
|
||||
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"));
|
||||
|
||||
workflow.Name.Should().Be("static-sequential");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_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)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents)
|
||||
{
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
from i in Enumerable.Range(1, numAgents)
|
||||
select new DoubleEchoAgent($"agent{i}"));
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
||||
private class DoubleEchoAgent(string name) : AIAgent
|
||||
{
|
||||
public override string Name => name;
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DoubleEchoAgentSession() : AgentSession();
|
||||
|
||||
[Fact]
|
||||
public async Task BuildConcurrent_AgentsRunInParallelAsync()
|
||||
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_DelegatesToBuilderAsync()
|
||||
{
|
||||
StrongBox<TaskCompletionSource<bool>> barrier = new();
|
||||
StrongBox<int> remaining = new();
|
||||
|
||||
var workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
[
|
||||
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
|
||||
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
|
||||
]);
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
(string updateText, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Single(Regex.Matches(updateText, "agent1"));
|
||||
Assert.Single(Regex.Matches(updateText, "agent2"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_BuildConcurrent_WithWorkflowNameSetsNameOnWorkflow()
|
||||
{
|
||||
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 workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
"static-concurrent",
|
||||
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")]);
|
||||
|
||||
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;
|
||||
}
|
||||
workflow.Name.Should().Be("static-concurrent");
|
||||
}
|
||||
|
||||
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)
|
||||
[Fact]
|
||||
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_AggregatorIsHonoredAsync()
|
||||
{
|
||||
await using StreamingRun run =
|
||||
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
|
||||
: await environment.OpenStreamingAsync(workflow);
|
||||
// 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 run.TrySendMessageAsync(input);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")],
|
||||
aggregator: _ => sentinel);
|
||||
|
||||
return await ProcessWorkflowRunAsync(run);
|
||||
(_, 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");
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_RejectsNull()
|
||||
{
|
||||
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);
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
|
||||
}
|
||||
|
||||
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)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_ReturnsConfigurableBuilder()
|
||||
{
|
||||
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);
|
||||
}
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
|
||||
|
||||
await barrier.Value!.Task.ConfigureAwait(false);
|
||||
SequentialWorkflowBuilder builder = AgentWorkflowBuilder.CreateSequentialBuilderWith(agent);
|
||||
Workflow workflow = builder.WithName("via-factory").Build();
|
||||
|
||||
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// 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() { }
|
||||
}
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
|
||||
public class GroupChatWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildGroupChat_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
|
||||
|
||||
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new OrchestrationTestHelpers.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 OrchestrationTestHelpers.DoubleEchoAgent("a1"), null!));
|
||||
|
||||
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
|
||||
{
|
||||
var manager = new RoundRobinGroupChatManager([new OrchestrationTestHelpers.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 OrchestrationTestHelpers.DoubleEchoAgent("agent1"), new OrchestrationTestHelpers.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 OrchestrationTestHelpers.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 OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
|
||||
.Build();
|
||||
|
||||
Assert.Null(workflow.Name);
|
||||
Assert.Null(workflow.Description);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
|
||||
{
|
||||
const int NumAgents = 3;
|
||||
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
|
||||
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"), new OrchestrationTestHelpers.DoubleEchoAgent("agent2"))
|
||||
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
|
||||
.Build();
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await OrchestrationTestHelpers.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);
|
||||
|
||||
// The group-chat host broadcasts each new message (initial user input + each speaker's
|
||||
// response) to every participant except the speaker that produced it. The selected
|
||||
// speaker therefore sees only what's been broadcast to it since its previous turn.
|
||||
string[] agentIds = ["agent1", "agent2", "agent3"];
|
||||
List<string>[] buffers = new List<string>[NumAgents];
|
||||
for (int a = 0; a < NumAgents; a++)
|
||||
{
|
||||
buffers[a] = [UserInput];
|
||||
}
|
||||
|
||||
string[] texts = new string[maxIterations + 1];
|
||||
texts[0] = UserInput;
|
||||
string expectedTotal = string.Empty;
|
||||
for (int i = 1; i < maxIterations + 1; i++)
|
||||
{
|
||||
int speakerIdx = (i - 1) % NumAgents;
|
||||
string id = agentIds[speakerIdx];
|
||||
string concatReceived = string.Concat(buffers[speakerIdx]);
|
||||
texts[i] = $"{id}{Double(concatReceived)}";
|
||||
buffers[speakerIdx].Clear();
|
||||
for (int a = 0; a < NumAgents; a++)
|
||||
{
|
||||
if (a == speakerIdx)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffers[a].Add(texts[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_GroupChatWorkflowBuilder_DefaultDesignationsMatchSpec()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
|
||||
.AddParticipants(a1, a2, a3)
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Where(kvp => kvp.Value.Count == 0)
|
||||
.Should().ContainSingle("group-chat host is the sole terminal output executor by default");
|
||||
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
|
||||
.Should().HaveCount(3, "every participant is designated intermediate by default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_GroupChatWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
|
||||
.AddParticipants(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 host default is 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_GroupChatWorkflowBuilder_DesignationForNonParticipantThrows()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
|
||||
|
||||
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
|
||||
.AddParticipants(participant)
|
||||
.WithOutputFrom(stranger);
|
||||
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
|
||||
}
|
||||
|
||||
private sealed class RecordingAgent(string name) : AIAgent
|
||||
{
|
||||
public List<List<string>> Invocations { get; } = [];
|
||||
|
||||
public override string Name => name;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new RecordingAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new RecordingAgentSession());
|
||||
|
||||
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();
|
||||
|
||||
this.Invocations.Add(messages.Select(m => m.Text).ToList());
|
||||
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAgentSession() : AgentSession();
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync()
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
var agentC = new RecordingAgent("agentC");
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
|
||||
.AddParticipants(agentA, agentB, agentC)
|
||||
.Build();
|
||||
|
||||
const string UserInput = "hello";
|
||||
(_, List<ChatMessage>? result, _, _) = await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5, result.Count); // initial user input + 4 agent turns
|
||||
Assert.Collection(
|
||||
result,
|
||||
m => Assert.Equal(UserInput, m.Text),
|
||||
m => Assert.Equal("agentA", m.Text),
|
||||
m => Assert.Equal("agentB", m.Text),
|
||||
m => Assert.Equal("agentC", m.Text),
|
||||
m => Assert.Equal("agentA", m.Text));
|
||||
|
||||
// Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts
|
||||
// confirm only the chosen participant receives a TurnToken on each round.
|
||||
Assert.Equal(2, agentA.Invocations.Count);
|
||||
Assert.Single(agentB.Invocations);
|
||||
Assert.Single(agentC.Invocations);
|
||||
|
||||
// Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so
|
||||
// agentA's only buffered message is the user input.
|
||||
Assert.Equal([UserInput], agentA.Invocations[0]);
|
||||
|
||||
// Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of
|
||||
// agentA's response (agentA itself is excluded as the last speaker).
|
||||
Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]);
|
||||
|
||||
// Turn 3: agentC. It also received every broadcast so far (it has never been excluded).
|
||||
Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]);
|
||||
|
||||
// Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but
|
||||
// received turn-3 (agentB's response) and turn-4 (agentC's response) deltas.
|
||||
Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync()
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 })
|
||||
.AddParticipants(agentA, agentB)
|
||||
.Build();
|
||||
|
||||
const string UserInput = "hello";
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
// Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync
|
||||
// prefixed.
|
||||
Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]);
|
||||
|
||||
// Turn 2: agentB received both the initial broadcast and agentA's response — both passed
|
||||
// through UpdateHistoryAsync before being broadcast.
|
||||
Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync()
|
||||
{
|
||||
const string UserInput = "hello";
|
||||
const int MaxIterations = 6;
|
||||
|
||||
// --- Baseline: run the full conversation under checkpointing and capture every checkpoint
|
||||
// plus the final transcript. The same workflow + agents are reused for the resume,
|
||||
// because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. ---
|
||||
BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations);
|
||||
|
||||
// We need at least one mid-conversation checkpoint to resume from. The baseline produces a
|
||||
// checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint
|
||||
// captured roughly midway so the resumed run still has work to do.
|
||||
Assert.True(baseline.Checkpoints.Count >= 5,
|
||||
$"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}");
|
||||
|
||||
int midIndex = baseline.Checkpoints.Count / 2;
|
||||
CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex];
|
||||
|
||||
// Snapshot per-agent invocation counts before the resume so we can isolate the invocations
|
||||
// produced after the checkpoint is restored.
|
||||
int aPreCount = baseline.AgentA.Invocations.Count;
|
||||
int bPreCount = baseline.AgentB.Invocations.Count;
|
||||
int cPreCount = baseline.AgentC.Invocations.Count;
|
||||
|
||||
// --- Resume the same workflow from the mid-conversation checkpoint. ---
|
||||
List<ChatMessage>? resumedResult = null;
|
||||
await using (StreamingRun resumed = await baseline.Environment
|
||||
.ResumeStreamingAsync(baseline.Workflow, midCheckpoint))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is WorkflowOutputEvent o)
|
||||
{
|
||||
resumedResult = o.As<List<ChatMessage>>();
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent err)
|
||||
{
|
||||
Assert.Fail($"Resumed workflow failed: {err.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (1) Iteration-count continuity: the resumed run terminates with exactly the same number
|
||||
// of turns the baseline produced — proves IterationCount was rehydrated and the manager
|
||||
// honored MaximumIterationCount across the boundary.
|
||||
Assert.NotNull(resumedResult);
|
||||
Assert.Equal(baseline.Result.Count, resumedResult!.Count);
|
||||
|
||||
// (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn,
|
||||
// in order) matches the baseline — proves the round-robin cursor was restored.
|
||||
List<string?> baselineTranscript = [.. baseline.Result.Select(m => m.Text)];
|
||||
List<string?> resumedTranscript = [.. resumedResult.Select(m => m.Text)];
|
||||
Assert.Equal(baselineTranscript, resumedTranscript);
|
||||
|
||||
// (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its
|
||||
// own Name. Examine only the invocations recorded after the resume. If the host failed
|
||||
// to exclude the current speaker from its post-resume broadcasts, an agent's next
|
||||
// invocation buffer would contain its own previously produced response. Asserting that
|
||||
// no post-resume invocation input contains the invoking agent's own name proves the
|
||||
// exclusion was preserved through checkpoint+restore.
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount);
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount);
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount);
|
||||
|
||||
// Sanity: at least one agent was actually invoked after the resume; otherwise the test
|
||||
// would trivially pass even if the host stopped scheduling turns after restore.
|
||||
int totalPost = baseline.AgentA.Invocations.Count - aPreCount
|
||||
+ (baseline.AgentB.Invocations.Count - bPreCount)
|
||||
+ (baseline.AgentC.Invocations.Count - cPreCount);
|
||||
Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint");
|
||||
|
||||
static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount)
|
||||
{
|
||||
for (int i = preCount; i < agent.Invocations.Count; i++)
|
||||
{
|
||||
Assert.DoesNotContain(agent.Name, agent.Invocations[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BaselineRunResult(
|
||||
Workflow Workflow,
|
||||
InProcessExecutionEnvironment Environment,
|
||||
RecordingAgent AgentA,
|
||||
RecordingAgent AgentB,
|
||||
RecordingAgent AgentC,
|
||||
List<ChatMessage> Result,
|
||||
List<CheckpointInfo> Checkpoints,
|
||||
CheckpointManager CheckpointManager);
|
||||
|
||||
private static async Task<BaselineRunResult> RunGroupChatBaselineAsync(string userInput, int maxIterations)
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
var agentC = new RecordingAgent("agentC");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
|
||||
.AddParticipants(agentA, agentB, agentC)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep
|
||||
.ToWorkflowExecutionEnvironment()
|
||||
.WithCheckpointing(checkpointMgr);
|
||||
|
||||
List<CheckpointInfo> checkpoints = [];
|
||||
List<ChatMessage>? finalResult = null;
|
||||
|
||||
await using (StreamingRun run = await env.OpenStreamingAsync(workflow))
|
||||
{
|
||||
await run.TrySendMessageAsync(new List<ChatMessage> { new(ChatRole.User, userInput) });
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
|
||||
checkpoints.Add(cp);
|
||||
break;
|
||||
case WorkflowOutputEvent o:
|
||||
finalResult = o.As<List<ChatMessage>>();
|
||||
break;
|
||||
case WorkflowErrorEvent err:
|
||||
Assert.Fail($"Baseline workflow failed: {err.Exception}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(finalResult);
|
||||
return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr);
|
||||
}
|
||||
|
||||
private sealed class PrefixingGroupChatManager(IReadOnlyList<AIAgent> agents, string prefix) : RoundRobinGroupChatManager(agents)
|
||||
{
|
||||
protected internal override ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> prefixed =
|
||||
history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName });
|
||||
|
||||
return new(prefixed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests focused on <see cref="HandoffWorkflowBuilder"/>'s output-designation surface —
|
||||
/// the Python-aligned defaults applied at <see cref="HandoffWorkflowBuilderCore{TBuilder}.Build"/>
|
||||
/// when the user has not made explicit designations, and the memoized
|
||||
/// <c>WithOutputFrom</c> / <c>WithIntermediateOutputFrom</c> replay otherwise.
|
||||
/// </summary>
|
||||
#pragma warning disable MAAIW001 // Experimental: HandoffWorkflowBuilder
|
||||
public class HandoffWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_HandoffWorkflowBuilder_DefaultDesignationsMatchSpec()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Where(kvp => kvp.Value.Count == 0)
|
||||
.Should().ContainSingle("the handoff end executor is the sole terminal output by default");
|
||||
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
|
||||
.Should().HaveCount(2, "both the coordinator and the specialist are designated intermediate by default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_HandoffWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.WithOutputFrom(coordinator)
|
||||
.WithIntermediateOutputFrom([specialist])
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Should().HaveCount(2,
|
||||
"only the user-specified designations land on the inner builder; the handoff-end default is suppressed");
|
||||
designations.Values.Where(tags => tags.Count == 0)
|
||||
.Should().ContainSingle("coordinator is the only terminal designation");
|
||||
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
|
||||
.Should().ContainSingle("specialist is the only intermediate designation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_HandoffWorkflowBuilder_DesignationForNonParticipantThrows()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
|
||||
|
||||
HandoffWorkflowBuilder builder = AgentWorkflowBuilder
|
||||
.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.WithIntermediateOutputFrom([stranger]);
|
||||
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAIW001
|
||||
-47
@@ -122,50 +122,3 @@ public sealed class InputWaiterTests : IDisposable
|
||||
await waitTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class OutputFilterTests
|
||||
{
|
||||
private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId)
|
||||
{
|
||||
NoOpExecutor start = new("start");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("start")
|
||||
.AddEdge(start, end)
|
||||
.WithOutputFrom(outputExecutorId == "end" ? end : start)
|
||||
.Build();
|
||||
|
||||
return new OutputFilter(workflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor");
|
||||
}
|
||||
|
||||
private sealed class NoOpExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
|
||||
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
|
||||
}
|
||||
}
|
||||
@@ -187,8 +187,12 @@ public class JsonSerializationTests
|
||||
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
|
||||
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
|
||||
|
||||
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count)
|
||||
.And.AllSatisfy(id => prototype.OutputExecutorIds.Contains(id));
|
||||
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count);
|
||||
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in prototype.OutputExecutorIds)
|
||||
{
|
||||
actual.OutputExecutorIds.Should().ContainKey(kvp.Key);
|
||||
actual.OutputExecutorIds[kvp.Key].Should().BeEquivalentTo(kvp.Value);
|
||||
}
|
||||
|
||||
void ValidateExecutorDictionary(Dictionary<string, ExecutorInfo> expected,
|
||||
Dictionary<string, List<EdgeInfo>> expectedEdges,
|
||||
@@ -788,6 +792,34 @@ public class JsonSerializationTests
|
||||
result.IsTakingTurn.Should().Be(prototype.IsTakingTurn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_GroupChatManagerState_JsonRoundtrip()
|
||||
{
|
||||
// Arrange
|
||||
GroupChatManagerState prototype = new(IterationCount: 7);
|
||||
|
||||
// Act
|
||||
GroupChatManagerState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(prototype);
|
||||
result.IterationCount.Should().Be(prototype.IterationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_RoundRobinGroupChatManagerState_JsonRoundtrip()
|
||||
{
|
||||
// Arrange
|
||||
RoundRobinGroupChatManagerState prototype = new(NextIndex: 3);
|
||||
|
||||
// Act
|
||||
RoundRobinGroupChatManagerState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(prototype);
|
||||
result.NextIndex.Should().Be(prototype.NextIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
||||
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
||||
|
||||
@@ -361,6 +361,64 @@ public class MagenticOrchestrationTests
|
||||
runResult.Result![0].Text.Should().Contain("Multi-round task completed!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunCoordinationRound_Forwards_Participant_Reply_To_ManagerAsync()
|
||||
{
|
||||
// Regression: MagenticOrchestrator.TakeTurnAsync used to drop the `messages`
|
||||
// parameter on subsequent turns, so participant replies never reached the
|
||||
// manager's ChatHistory. The manager then re-dispatched the same speaker
|
||||
// every round until MaxRounds. Assert that round-2's progress-ledger call
|
||||
// actually sees the worker's reply in its input.
|
||||
|
||||
const string TaskPrompt = "Echo back this exact magentic-regression-marker";
|
||||
|
||||
List<ChatMessage> factsResponse = CreatePlanResponse("Facts");
|
||||
List<ChatMessage> planResponse = CreatePlanResponse("Plan");
|
||||
List<ChatMessage> round1Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: false,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "Worker",
|
||||
instructionOrQuestion: TaskPrompt);
|
||||
List<ChatMessage> round2Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: true,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "Worker",
|
||||
instructionOrQuestion: "Done");
|
||||
List<ChatMessage> finalAnswer = CreateFinalAnswerResponse("All good");
|
||||
|
||||
RecordingReplayAgent manager = new(
|
||||
[factsResponse, planResponse, round1Ledger, round2Ledger, finalAnswer],
|
||||
name: "Manager");
|
||||
TestEchoAgent worker = new(name: "Worker");
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(worker)
|
||||
.RequirePlanSignoff(false)
|
||||
.Build();
|
||||
|
||||
WorkflowRunResult runResult = await RunMagenticWorkflowAsync(
|
||||
workflow,
|
||||
[new ChatMessage(ChatRole.User, TaskPrompt)]);
|
||||
|
||||
runResult.Result.Should().NotBeNull();
|
||||
runResult.Result![0].Text.Should().Contain("All good");
|
||||
|
||||
// Calls in order: facts, plan, ledger1, ledger2, finalAnswer.
|
||||
manager.RecordedInputs.Should().HaveCount(5);
|
||||
|
||||
manager.RecordedInputs[3].Should().Contain(
|
||||
m => m.Role == ChatRole.Assistant
|
||||
&& m.AuthorName == "Worker"
|
||||
&& m.Text.Contains(TaskPrompt),
|
||||
"round-2 progress ledger must see the worker's reply; without it the manager loops to MaxRounds");
|
||||
|
||||
manager.RecordedInputs[4].Should().Contain(
|
||||
m => m.Role == ChatRole.Assistant && m.AuthorName == "Worker",
|
||||
"final-answer synthesis must see what participants actually said");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlanReview_Revised_Triggers_ReplanAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests focused on <see cref="MagenticWorkflowBuilder"/>'s output-designation surface —
|
||||
/// the Python-aligned defaults applied at <see cref="MagenticWorkflowBuilder.Build"/> when
|
||||
/// the user has not made explicit designations, and the memoized
|
||||
/// <c>WithOutputFrom</c> / <c>WithIntermediateOutputFrom</c> replay otherwise.
|
||||
/// </summary>
|
||||
#pragma warning disable MAAIW001 // Experimental: MagenticWorkflowBuilder
|
||||
public class MagenticWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_DefaultDesignationsMatchSpec()
|
||||
{
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent member1 = new(name: "Worker1");
|
||||
TestEchoAgent member2 = new(name: "Worker2");
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(member1, member2)
|
||||
.RequirePlanSignoff(false)
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Where(kvp => kvp.Value.Count == 0)
|
||||
.Should().ContainSingle("the Magentic orchestrator is the sole terminal output by default");
|
||||
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
|
||||
.Should().HaveCount(2, "every team member is designated intermediate by default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
|
||||
{
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent member1 = new(name: "Worker1");
|
||||
TestEchoAgent member2 = new(name: "Worker2");
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(member1, member2)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithOutputFrom(member1)
|
||||
.WithIntermediateOutputFrom([member2])
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Should().HaveCount(2,
|
||||
"only the user-specified designations land on the inner builder; the orchestrator default is suppressed");
|
||||
designations.Values.Where(tags => tags.Count == 0)
|
||||
.Should().ContainSingle("member1 is the only terminal designation");
|
||||
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
|
||||
.Should().ContainSingle("member2 is the only intermediate designation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_DesignationForNonParticipantThrows()
|
||||
{
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent member = new(name: "Worker");
|
||||
TestEchoAgent stranger = new(name: "Stranger");
|
||||
|
||||
MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(member)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithIntermediateOutputFrom([stranger]);
|
||||
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*Stranger*");
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAIW001
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Container for shared test helpers used by every orchestration-builder test class —
|
||||
/// the <c>DoubleEchoAgent</c> family and the <c>RunWorkflow*</c> methods. The actual
|
||||
/// test methods live in per-builder files (<c>SequentialWorkflowBuilderTests</c>,
|
||||
/// <c>ConcurrentWorkflowBuilderTests</c>, <c>GroupChatWorkflowBuilderTests</c>, etc.).
|
||||
/// </summary>
|
||||
public static class OrchestrationTestHelpers
|
||||
{
|
||||
internal class DoubleEchoAgent(string name) : AIAgent
|
||||
{
|
||||
public override string Name => name;
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DoubleEchoAgentSession() : AgentSession();
|
||||
|
||||
internal sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
await barrier.Value!.Task.ConfigureAwait(false);
|
||||
|
||||
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
|
||||
|
||||
internal static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
|
||||
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
|
||||
{
|
||||
await using StreamingRun run =
|
||||
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
|
||||
: await environment.OpenStreamingAsync(workflow);
|
||||
|
||||
await run.TrySendMessageAsync(input);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
return await ProcessWorkflowRunAsync(run);
|
||||
}
|
||||
|
||||
internal static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
internal static Task<WorkflowRunResult> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
|
||||
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class OutputFilterTests
|
||||
{
|
||||
private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId)
|
||||
{
|
||||
NoOpExecutor start = new("start");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("start")
|
||||
.AddEdge(start, end)
|
||||
.WithOutputFrom(outputExecutorId == "end" ? end : start)
|
||||
.Build();
|
||||
|
||||
return new OutputFilter(workflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputFilter_ReturnsEmptyTagSetWhenRegisteredViaWithOutputFrom()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
|
||||
tags.Should().NotBeNull().And.BeEmpty("terminal designation carries no tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputFilter_ReturnsIntermediateTagWhenRegisteredViaWithIntermediateOutputFrom()
|
||||
{
|
||||
NoOpExecutor start = new("start");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("start")
|
||||
.AddEdge(start, end)
|
||||
.WithIntermediateOutputFrom([end])
|
||||
.Build();
|
||||
|
||||
OutputFilter filter = new(workflow);
|
||||
|
||||
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
|
||||
tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputFilter_ReturnsIntermediateTagForAccumulatedDesignation()
|
||||
{
|
||||
NoOpExecutor start = new("start");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("start")
|
||||
.AddEdge(start, end)
|
||||
.WithOutputFrom(end)
|
||||
.WithIntermediateOutputFrom([end])
|
||||
.Build();
|
||||
|
||||
OutputFilter filter = new(workflow);
|
||||
|
||||
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
|
||||
tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate },
|
||||
"terminal designation contributes no tag; the union is the intermediate set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputFilter_TryGetTagsReturnsFalseForUnregisteredExecutor()
|
||||
{
|
||||
OutputFilter filter = CreateFilterWithOutputFrom("end");
|
||||
|
||||
filter.TryGetTags("start", out HashSet<OutputTag>? tags).Should().BeFalse();
|
||||
tags.Should().BeNull();
|
||||
}
|
||||
|
||||
private sealed class NoOpExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
|
||||
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class OutputTagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_OutputTag_KnownValues()
|
||||
{
|
||||
OutputTag.Intermediate.Value.Should().Be("intermediate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputTag_EqualityIsOrdinalOnValue()
|
||||
{
|
||||
OutputTag.Intermediate.Should().Be(OutputTag.Intermediate);
|
||||
(OutputTag.Intermediate == OutputTag.Intermediate).Should().BeTrue();
|
||||
|
||||
// Same Value via independent construction (via JSON round-trip below) is equal.
|
||||
OutputTag rebuilt = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
|
||||
rebuilt.Should().Be(OutputTag.Intermediate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputTag_DefaultStructValueIsDistinct()
|
||||
{
|
||||
OutputTag def = default;
|
||||
def.Value.Should().BeNull();
|
||||
def.Should().NotBe(OutputTag.Intermediate);
|
||||
def.GetHashCode().Should().Be(0);
|
||||
|
||||
HashSet<OutputTag> set = [OutputTag.Intermediate];
|
||||
set.Contains(def).Should().BeFalse("default(OutputTag) must not collide with the well-known singleton in a HashSet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputTag_GetHashCodeMatchesEquals()
|
||||
{
|
||||
OutputTag a = OutputTag.Intermediate;
|
||||
OutputTag b = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
|
||||
|
||||
a.Equals(b).Should().BeTrue();
|
||||
a.GetHashCode().Should().Be(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputTag_JsonConverter_RoundtripsValueAsString()
|
||||
{
|
||||
string intermediateJson = JsonSerializer.Serialize(OutputTag.Intermediate, WorkflowsJsonUtilities.DefaultOptions);
|
||||
intermediateJson.Should().Be("\"intermediate\"");
|
||||
|
||||
OutputTag back = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
|
||||
back.Should().Be(OutputTag.Intermediate);
|
||||
|
||||
OutputTag fromUnknown = JsonSerializer.Deserialize<OutputTag>("\"custom\"", WorkflowsJsonUtilities.DefaultOptions);
|
||||
fromUnknown.Value.Should().Be("custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_OutputTag_ConstructorIsInternal()
|
||||
{
|
||||
ConstructorInfo? ctor = typeof(OutputTag).GetConstructor(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
types: [typeof(string)],
|
||||
modifiers: null);
|
||||
|
||||
ctor.Should().NotBeNull("OutputTag(string) must exist as an internal constructor");
|
||||
ctor!.IsAssembly.Should().BeTrue("OutputTag(string) must be `internal` so external assemblies cannot synthesize tags");
|
||||
ctor.IsPublic.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TestReplayAgent"/> that records the input messages it receives on each call.
|
||||
/// Used by tests that need to assert what context the agent was actually handed.
|
||||
/// </summary>
|
||||
internal sealed class RecordingReplayAgent(List<List<ChatMessage>> messages, string? id = null, string? name = null)
|
||||
: TestReplayAgent(messages, id, name)
|
||||
{
|
||||
public List<List<ChatMessage>> RecordedInputs { get; } = [];
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.RecordedInputs.Add(messages.ToList());
|
||||
await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -136,4 +136,55 @@ public class RoundRobinGroupChatManagerTests
|
||||
FluentActions.Invoking(() => new RoundRobinGroupChatManager([]))
|
||||
.Should().Throw<System.ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundRobinGroupChat_CheckpointRoundTrip_PreservesIterationCountAndCursorAsync()
|
||||
{
|
||||
TestEchoAgent agent1 = new(id: "agent1");
|
||||
TestEchoAgent agent2 = new(id: "agent2");
|
||||
TestEchoAgent agent3 = new(id: "agent3");
|
||||
List<AIAgent> agents = [agent1, agent2, agent3];
|
||||
List<ChatMessage> history = [];
|
||||
|
||||
TestRunState sharedState = new();
|
||||
TestWorkflowContext sourceContext = new("gcm-host", sharedState);
|
||||
TestWorkflowContext sinkContext = new("gcm-host", sharedState);
|
||||
|
||||
RoundRobinGroupChatManager source = new(agents);
|
||||
await source.SelectNextAgentAsync(history); // cursor -> agent2
|
||||
source.IterationCount = 7;
|
||||
|
||||
await source.CheckpointAsync(sourceContext);
|
||||
|
||||
RoundRobinGroupChatManager restored = new(agents);
|
||||
restored.IterationCount.Should().Be(0, "freshly constructed manager has no iteration count");
|
||||
|
||||
await restored.RestoreCheckpointAsync(sinkContext);
|
||||
|
||||
restored.IterationCount.Should().Be(7, "the base hook must rehydrate IterationCount");
|
||||
|
||||
AIAgent next = await restored.SelectNextAgentAsync(history);
|
||||
next.Should().BeSameAs(agent2, "the round-robin cursor should resume where the source left off");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundRobinGroupChat_RestoreWithoutCheckpoint_DefaultsToZeroStateAsync()
|
||||
{
|
||||
TestEchoAgent agent1 = new(id: "agent1");
|
||||
TestEchoAgent agent2 = new(id: "agent2");
|
||||
List<AIAgent> agents = [agent1, agent2];
|
||||
List<ChatMessage> history = [];
|
||||
|
||||
TestWorkflowContext emptyContext = new("gcm-host");
|
||||
|
||||
RoundRobinGroupChatManager manager = new(agents);
|
||||
manager.IterationCount = 3;
|
||||
await manager.SelectNextAgentAsync(history); // cursor advanced
|
||||
|
||||
await manager.RestoreCheckpointAsync(emptyContext);
|
||||
|
||||
manager.IterationCount.Should().Be(0, "restore from an empty checkpoint should clear IterationCount");
|
||||
AIAgent next = await manager.SelectNextAgentAsync(history);
|
||||
next.Should().BeSameAs(agent1, "restore from an empty checkpoint should reset the cursor to the first agent");
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests.Futures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class SequentialWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_SequentialWorkflowBuilder_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => new SequentialWorkflowBuilder(null!));
|
||||
Assert.Throws<ArgumentException>("agents", () => new SequentialWorkflowBuilder().Build());
|
||||
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
|
||||
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task Test_SequentialWorkflowBuilder_AgentsRunInOrderAsync(int numAgents)
|
||||
{
|
||||
var workflow = new SequentialWorkflowBuilder(
|
||||
from i in Enumerable.Range(1, numAgents)
|
||||
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"))
|
||||
.Build();
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
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_SequentialWorkflowBuilder_DefaultDesignationsMatchSpec()
|
||||
{
|
||||
Workflow workflow = new SequentialWorkflowBuilder(
|
||||
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("OutputMessagesExecutor is the sole terminal output by default");
|
||||
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
|
||||
.Should().HaveCount(3, "every pipeline agent is designated intermediate by default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SequentialWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
|
||||
|
||||
Workflow workflow = new SequentialWorkflowBuilder(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 default is 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_SequentialWorkflowBuilder_DesignationForNonParticipantThrows()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
|
||||
|
||||
SequentialWorkflowBuilder builder = new SequentialWorkflowBuilder(participant)
|
||||
.WithIntermediateOutputFrom([stranger]);
|
||||
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SequentialWorkflowBuilder_WithNamePropagatesToWorkflow()
|
||||
{
|
||||
Workflow workflow = new SequentialWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
|
||||
.WithName("named-sequential")
|
||||
.Build();
|
||||
|
||||
workflow.Name.Should().Be("named-sequential");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SequentialWorkflowBuilder_WithDescriptionPropagatesToWorkflow()
|
||||
{
|
||||
Workflow workflow = new SequentialWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
|
||||
.WithDescription("describes the sequential pipeline")
|
||||
.Build();
|
||||
|
||||
workflow.Description.Should().Be("describes the sequential pipeline");
|
||||
}
|
||||
|
||||
[Collection(FuturesSerialCollection.Name)]
|
||||
public class AsAgentForwarding
|
||||
{
|
||||
[Fact]
|
||||
public async Task Test_SequentialWorkflowBuilder_AsAgent_OnlyTerminalDesignationSurfacesAsync()
|
||||
{
|
||||
using FuturesScope _ = new(enabled: true);
|
||||
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent2 = new("agent2");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent3 = new("agent3");
|
||||
|
||||
// Explicitly designate ONLY the last agent — defaults (which would tag every agent
|
||||
// intermediate) are suppressed, so under Futures-on, agent1/agent2 produce no
|
||||
// AgentResponse(Update)Events and nothing of theirs reaches the AsAgent stream.
|
||||
Workflow workflow = new SequentialWorkflowBuilder(agent1, agent2, agent3)
|
||||
.WithOutputFrom(agent3)
|
||||
.Build();
|
||||
|
||||
List<AgentResponseUpdate> updates = await workflow
|
||||
.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "abc"))
|
||||
.ToListAsync();
|
||||
|
||||
// Filter by AuthorName — distinguishes which agent originated each update
|
||||
// (text-content checks are unreliable because agent3 echoes earlier agents' markers
|
||||
// as part of the cumulative pipeline payload).
|
||||
HashSet<string> authoredBy = updates
|
||||
.Select(u => u.AuthorName)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Select(n => n!)
|
||||
.ToHashSet();
|
||||
|
||||
authoredBy.Should().Contain("agent3", "the terminal agent must surface");
|
||||
authoredBy.Should().NotContain("agent1",
|
||||
"the intermediate agent must not surface when only the terminal is designated");
|
||||
authoredBy.Should().NotContain("agent2",
|
||||
"the intermediate agent must not surface when only the terminal is designated");
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-1
@@ -6,7 +6,7 @@ using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public partial class WorkflowBuilderSmokeTests
|
||||
public partial class WorkflowBuilderTests
|
||||
{
|
||||
private sealed class NoOpExecutor(string id) : Executor(id)
|
||||
{
|
||||
@@ -455,4 +455,112 @@ public partial class WorkflowBuilderSmokeTests
|
||||
/// </summary>
|
||||
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
|
||||
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
|
||||
|
||||
// --- Tag-aware WithOutputFrom / WithIntermediateOutputFrom tests ---
|
||||
|
||||
[Fact]
|
||||
public void Test_WithOutputFrom_RegistersWithEmptyTagSet()
|
||||
{
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b)
|
||||
.WithOutputFrom(b)
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors.Should().ContainKey("b");
|
||||
workflow.OutputExecutors["b"].Should().BeEmpty("regular outputs are untagged");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithIntermediateOutputFrom_AddsIntermediateTag()
|
||||
{
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b)
|
||||
.WithIntermediateOutputFrom([b])
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithOutputFrom_MultipleExecutorsAllUntagged()
|
||||
{
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
NoOpExecutor c = new("c");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b).AddEdge(a, c)
|
||||
.WithOutputFrom(b, c)
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors.Should().HaveCount(2);
|
||||
workflow.OutputExecutors["b"].Should().BeEmpty();
|
||||
workflow.OutputExecutors["c"].Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithOutputFrom_ThenIntermediate_AccumulatesTags()
|
||||
{
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b)
|
||||
.WithOutputFrom(b)
|
||||
.WithIntermediateOutputFrom([b])
|
||||
.Build();
|
||||
|
||||
// WithOutputFrom doesn't add a tag; WithIntermediateOutputFrom adds Intermediate.
|
||||
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithIntermediateOutputFrom_RepeatedDedupes()
|
||||
{
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b)
|
||||
.WithIntermediateOutputFrom([b])
|
||||
.WithIntermediateOutputFrom([b])
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithIntermediateOutputFrom_OnlyRegistersWithoutPriorWithOutputFrom()
|
||||
{
|
||||
// WithIntermediateOutputFrom on its own is sufficient to register the executor as an
|
||||
// output source — the call ensures the id is in the dict with the Intermediate tag.
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor b = new("b");
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, b)
|
||||
.WithIntermediateOutputFrom([b])
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors.Should().ContainKey("b");
|
||||
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WithOutputFrom_TracksExecutorBinding()
|
||||
{
|
||||
// A placeholder binding referenced via WithOutputFrom must end up bound by the time we Build.
|
||||
NoOpExecutor a = new("a");
|
||||
NoOpExecutor future = new("future");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder("a")
|
||||
.AddEdge(a, "future")
|
||||
.WithIntermediateOutputFrom(["future"])
|
||||
.BindExecutor(future)
|
||||
.Build();
|
||||
|
||||
workflow.OutputExecutors.Should().ContainKey("future");
|
||||
workflow.OutputExecutors["future"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
}
|
||||
@@ -824,4 +824,130 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
|
||||
Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
|
||||
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
|
||||
}
|
||||
|
||||
// ----- Phase 5: Workflow-as-Agent intermediate forwarding -----------------
|
||||
|
||||
[Collection(Futures.FuturesSerialCollection.Name)]
|
||||
public class IntermediateForwarding
|
||||
{
|
||||
private const string InterText = "progress";
|
||||
private const string FinalText = "final";
|
||||
|
||||
private static async Task<List<AgentResponseUpdate>> RunStreamingAsync(
|
||||
Workflow workflow,
|
||||
bool includeWorkflowOutputsInResponse = false)
|
||||
{
|
||||
return await workflow
|
||||
.AsAIAgent("WorkflowAgent", includeWorkflowOutputsInResponse: includeWorkflowOutputsInResponse)
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "hi"))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_IntermediateAgentResponseForwardedInStreamingAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: true);
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(binding)
|
||||
.WithIntermediateOutputFrom([binding])
|
||||
.Build();
|
||||
|
||||
// Under Futures-on, AgentResponseEvent mirrors AgentResponseUpdateEvent: always
|
||||
// forwarded regardless of the include flag. The intermediate tag is observable on
|
||||
// the surfaced event for consumers that care to distinguish.
|
||||
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: false);
|
||||
|
||||
updates.Any(u => u.RawRepresentation is AgentResponseEvent are && are.IsIntermediate() && u.Text == InterText)
|
||||
.Should().BeTrue("AgentResponseEvent is forwarded under Futures-on regardless of the include flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_TerminalAgentResponseForwardedUnconditionallyWhenFuturesOnAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: true);
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(FinalText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(binding)
|
||||
.WithOutputFrom(binding)
|
||||
.Build();
|
||||
|
||||
// Even a terminal-only designation surfaces without the include flag — the gating
|
||||
// asymmetry between AgentResponse and AgentResponseUpdate is gone under Futures-on.
|
||||
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: false);
|
||||
|
||||
updates.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
|
||||
.Should().BeTrue("terminal AgentResponseEvent is forwarded under Futures-on regardless of the include flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_TerminalAgentResponseGatedWhenFuturesOffAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: false);
|
||||
|
||||
static Workflow Build()
|
||||
{
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(FinalText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
return new WorkflowBuilder(binding).WithOutputFrom(binding).Build();
|
||||
}
|
||||
|
||||
// Legacy semantics: AgentResponseEvent stays behind the include flag when Futures
|
||||
// is off. Two fresh workflows because in-process runs aren't reentrant.
|
||||
List<AgentResponseUpdate> gated = await RunStreamingAsync(Build(), includeWorkflowOutputsInResponse: false);
|
||||
gated.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
|
||||
.Should().BeFalse("terminal AgentResponseEvent stays gated under Futures-off");
|
||||
|
||||
List<AgentResponseUpdate> included = await RunStreamingAsync(Build(), includeWorkflowOutputsInResponse: true);
|
||||
included.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
|
||||
.Should().BeTrue("opting in via includeWorkflowOutputsInResponse surfaces it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_UndesignatedExecutorEmitsNoAgentResponseEventWhenFuturesOnAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: true);
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
// No designation — under Futures-on, the AgentResponse is dropped by the filter.
|
||||
Workflow workflow = new WorkflowBuilder(binding).Build();
|
||||
|
||||
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true);
|
||||
|
||||
updates.Any(u => u.RawRepresentation is AgentResponseEvent)
|
||||
.Should().BeFalse("an undesignated AIAgent executor produces no AgentResponseEvent under Futures-on");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_UndesignatedAgentResponseSurfacesWhenFuturesOffAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: false);
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(binding).Build();
|
||||
|
||||
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true);
|
||||
|
||||
updates.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == InterText)
|
||||
.Should().BeTrue("legacy bypass still emits AgentResponseEvent regardless of designation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_WorkflowHostAgent_IntermediateTagAvailableViaRawRepresentationAsync()
|
||||
{
|
||||
using Futures.FuturesScope _ = new(enabled: true);
|
||||
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
|
||||
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(binding)
|
||||
.WithIntermediateOutputFrom([binding])
|
||||
.Build();
|
||||
|
||||
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow);
|
||||
|
||||
AgentResponseUpdate progress = updates.First(u => u.RawRepresentation is AgentResponseEvent && u.Text == InterText);
|
||||
AgentResponseEvent raw = (AgentResponseEvent)progress.RawRepresentation!;
|
||||
raw.IsIntermediate().Should().BeTrue();
|
||||
raw.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ GEMINI_MODEL=""
|
||||
# Ollama
|
||||
OLLAMA_ENDPOINT=""
|
||||
OLLAMA_MODEL=""
|
||||
# Mistral AI
|
||||
MISTRAL_API_KEY=""
|
||||
MISTRAL_EMBEDDING_MODEL=""
|
||||
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
|
||||
ENABLE_SENSITIVE_DATA=true
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
|
||||
|
||||
@@ -27,7 +27,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-claude` | `python/packages/claude` | `beta` |
|
||||
| `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` |
|
||||
| `agent-framework-core` | `python/packages/core` | `released` |
|
||||
| `agent-framework-declarative` | `python/packages/declarative` | `beta` |
|
||||
| `agent-framework-declarative` | `python/packages/declarative` | `rc` |
|
||||
| `agent-framework-devui` | `python/packages/devui` | `beta` |
|
||||
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
@@ -37,6 +37,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
| `agent-framework-mistral` | `python/packages/mistral` | `alpha` |
|
||||
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
|
||||
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
|
||||
| `agent-framework-openai` | `python/packages/openai` | `released` |
|
||||
@@ -57,6 +58,13 @@ listed below.
|
||||
|
||||
### Experimental features
|
||||
|
||||
#### `DECLARATIVE_AGENTS`
|
||||
|
||||
- `agent-framework-declarative`: declarative agent loading APIs from
|
||||
`agent_framework_declarative`, including `AgentFactory`,
|
||||
`DeclarativeLoaderError`, `ProviderLookupError`, and `ProviderTypeMapping`
|
||||
from `agent_framework_declarative/_loader.py`
|
||||
|
||||
#### `EVALS`
|
||||
|
||||
- `agent-framework-core`: exported evaluation APIs from `agent_framework`, including
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
@@ -181,9 +182,18 @@ class A2AExecutor(AgentExecutor):
|
||||
"""Run the agent in streaming mode and publish updates to the task updater."""
|
||||
response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs)
|
||||
streamed_artifact_ids: set[str] = set()
|
||||
# Generate a stable artifact ID for the entire stream so all chunks share the same ID.
|
||||
# This ensures clients can coalesce streaming tokens into a single artifact/message
|
||||
# per the A2A spec (TaskArtifactUpdateEvent with append=True on same artifactId).
|
||||
default_artifact_id = str(uuid.uuid4())
|
||||
await (
|
||||
response_stream.with_transform_hook(
|
||||
partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids)
|
||||
partial(
|
||||
self.handle_events,
|
||||
updater=updater,
|
||||
streamed_artifact_ids=streamed_artifact_ids,
|
||||
default_artifact_id=default_artifact_id,
|
||||
)
|
||||
)
|
||||
).get_final_response()
|
||||
|
||||
@@ -199,7 +209,11 @@ class A2AExecutor(AgentExecutor):
|
||||
await self.handle_events(message, updater)
|
||||
|
||||
async def handle_events(
|
||||
self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None
|
||||
self,
|
||||
item: Message | AgentResponseUpdate,
|
||||
updater: TaskUpdater,
|
||||
streamed_artifact_ids: set[str] | None = None,
|
||||
default_artifact_id: str | None = None,
|
||||
) -> None:
|
||||
"""Convert agent response items (Messages or Updates) to A2A protocol events.
|
||||
|
||||
@@ -213,7 +227,10 @@ class A2AExecutor(AgentExecutor):
|
||||
item: The agent response item (Message or AgentResponseUpdate) to process.
|
||||
updater: The task updater to publish events to.
|
||||
streamed_artifact_ids: A set of artifact IDs that have already been streamed.
|
||||
Used to prevent duplicate updates for the same artifact.
|
||||
Used to track which artifacts need append=True on subsequent chunks.
|
||||
default_artifact_id: A stable artifact ID to use when the item does not provide one.
|
||||
This ensures all streaming chunks for a single response share the same artifact ID,
|
||||
allowing clients to coalesce them into a single message.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
@@ -224,6 +241,7 @@ class A2AExecutor(AgentExecutor):
|
||||
item: Message | AgentResponseUpdate,
|
||||
updater: TaskUpdater,
|
||||
streamed_artifact_ids: set[str] | None = None,
|
||||
default_artifact_id: str | None = None,
|
||||
) -> None:
|
||||
# Custom logic to transform item contents
|
||||
if item.role == "assistant" and item.contents:
|
||||
@@ -260,19 +278,20 @@ class A2AExecutor(AgentExecutor):
|
||||
|
||||
if parts:
|
||||
if isinstance(item, AgentResponseUpdate):
|
||||
# Resolve artifact ID: use item's message_id if available, otherwise fall back
|
||||
# to the stable default_artifact_id so all streaming chunks share the same ID.
|
||||
artifact_id = item.message_id or default_artifact_id
|
||||
# For streaming updates, we send TaskArtifactUpdateEvent via add_artifact
|
||||
await updater.add_artifact(
|
||||
parts=parts,
|
||||
artifact_id=item.message_id,
|
||||
artifact_id=artifact_id,
|
||||
metadata=metadata,
|
||||
append=(
|
||||
True
|
||||
if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set())
|
||||
else None
|
||||
True if streamed_artifact_ids is not None and artifact_id in streamed_artifact_ids else None
|
||||
),
|
||||
)
|
||||
if item.message_id and streamed_artifact_ids is not None:
|
||||
streamed_artifact_ids.add(item.message_id)
|
||||
if artifact_id and streamed_artifact_ids is not None:
|
||||
streamed_artifact_ids.add(artifact_id)
|
||||
else:
|
||||
# For final messages, we send TaskStatusUpdateEvent with 'working' state
|
||||
await updater.update_status(
|
||||
|
||||
@@ -176,6 +176,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
auth_interceptor: AuthInterceptor | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
supported_protocol_bindings: list[Literal["JSONRPC", "GRPC", "HTTP+JSON"] | str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the A2AAgent.
|
||||
@@ -193,6 +194,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
timeout: Request timeout configuration. Can be a float (applied to all timeout components),
|
||||
httpx.Timeout object (for full control), or None (uses 10.0s connect, 60.0s read,
|
||||
10.0s write, 5.0s pool - optimized for A2A operations).
|
||||
supported_protocol_bindings: List of protocol bindings to use for transport negotiation.
|
||||
Known values: "JSONRPC", "GRPC", "HTTP+JSON". Defaults to ["JSONRPC"].
|
||||
The A2A spec treats this as an open-form string, so custom bindings are also accepted.
|
||||
kwargs: any additional properties, passed to BaseAgent.
|
||||
"""
|
||||
# Default name/description from agent_card when not explicitly provided
|
||||
@@ -205,6 +209,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
super().__init__(id=id, name=name, description=description, **kwargs)
|
||||
self._http_client: httpx.AsyncClient | None = http_client
|
||||
self._timeout_config = self._create_timeout_config(timeout)
|
||||
bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"]
|
||||
if client is not None:
|
||||
self.client = client
|
||||
self._non_streaming_client: Client | None = None
|
||||
@@ -214,7 +219,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if url is None:
|
||||
raise ValueError("Either agent_card or url must be provided")
|
||||
# Create minimal agent card from URL
|
||||
agent_card = minimal_agent_card(url, ["JSONRPC"])
|
||||
agent_card = minimal_agent_card(url, bindings)
|
||||
|
||||
# Create or use provided httpx client
|
||||
if http_client is None:
|
||||
@@ -229,13 +234,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=True,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
supported_protocol_bindings=bindings,
|
||||
)
|
||||
# Create non-streaming client (single request/response for stream=False)
|
||||
non_streaming_config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
streaming=False,
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
supported_protocol_bindings=bindings,
|
||||
)
|
||||
streaming_factory = ClientFactory(streaming_config)
|
||||
non_streaming_factory = ClientFactory(non_streaming_config)
|
||||
@@ -256,7 +261,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
"Provide a 'url' argument or ensure 'agent_card.supported_interfaces' "
|
||||
"contains at least one interface with a URL."
|
||||
) from transport_error
|
||||
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
|
||||
fallback_card = minimal_agent_card(fallback_url, bindings)
|
||||
try:
|
||||
self.client = streaming_factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
self._non_streaming_client = non_streaming_factory.create(
|
||||
@@ -487,6 +492,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
contents=contents,
|
||||
role="assistant" if msg.role == A2ARole.ROLE_AGENT else "user",
|
||||
response_id=msg.message_id or str(uuid.uuid4()),
|
||||
message_id=msg.message_id,
|
||||
additional_properties={"a2a_metadata": metadata} if metadata else None,
|
||||
raw_representation=msg,
|
||||
)
|
||||
@@ -727,6 +733,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
contents=contents,
|
||||
role="assistant" if message.role == A2ARole.ROLE_AGENT else "user",
|
||||
response_id=update_event.task_id,
|
||||
message_id=message.message_id,
|
||||
additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None,
|
||||
raw_representation=update_event,
|
||||
)
|
||||
|
||||
@@ -420,6 +420,7 @@ async def test_run_streaming_with_message_response(a2a_agent: A2AAgent, mock_a2a
|
||||
assert content.text == "Streaming response from agent!"
|
||||
|
||||
assert updates[0].response_id == "msg-stream-123"
|
||||
assert updates[0].message_id == "msg-stream-123"
|
||||
assert mock_a2a_client.call_count == 1
|
||||
|
||||
|
||||
@@ -703,7 +704,94 @@ def test_a2a_agent_initialization_with_timeout_parameter() -> None:
|
||||
assert isinstance(timeout_arg, httpx.Timeout)
|
||||
|
||||
|
||||
# region Continuation Token Tests
|
||||
def test_a2a_agent_initialization_with_supported_protocol_bindings() -> None:
|
||||
"""Test A2AAgent initialization with custom supported_protocol_bindings."""
|
||||
with (
|
||||
patch("agent_framework_a2a._agent.httpx.AsyncClient") as mock_async_client,
|
||||
patch("agent_framework_a2a._agent.ClientConfig") as mock_config,
|
||||
patch("agent_framework_a2a._agent.ClientFactory") as mock_factory,
|
||||
):
|
||||
mock_async_client.return_value = MagicMock()
|
||||
mock_client_instance = MagicMock()
|
||||
mock_factory.return_value.create.return_value = mock_client_instance
|
||||
|
||||
A2AAgent(
|
||||
name="Test Agent",
|
||||
url="https://test-agent.example.com",
|
||||
supported_protocol_bindings=["GRPC", "JSONRPC"],
|
||||
)
|
||||
|
||||
# Verify ClientConfig was called with our custom bindings for both streaming and non-streaming
|
||||
assert mock_config.call_count == 2
|
||||
for call in mock_config.call_args_list:
|
||||
assert call.kwargs["supported_protocol_bindings"] == ["GRPC", "JSONRPC"]
|
||||
|
||||
|
||||
def test_a2a_agent_initialization_defaults_to_jsonrpc() -> None:
|
||||
"""Test A2AAgent defaults to JSONRPC when supported_protocol_bindings is not provided."""
|
||||
with (
|
||||
patch("agent_framework_a2a._agent.httpx.AsyncClient") as mock_async_client,
|
||||
patch("agent_framework_a2a._agent.ClientConfig") as mock_config,
|
||||
patch("agent_framework_a2a._agent.ClientFactory") as mock_factory,
|
||||
):
|
||||
mock_async_client.return_value = MagicMock()
|
||||
mock_client_instance = MagicMock()
|
||||
mock_factory.return_value.create.return_value = mock_client_instance
|
||||
|
||||
A2AAgent(name="Test Agent", url="https://test-agent.example.com")
|
||||
|
||||
# Verify ClientConfig was called with default JSONRPC bindings
|
||||
assert mock_config.call_count == 2
|
||||
for call in mock_config.call_args_list:
|
||||
assert call.kwargs["supported_protocol_bindings"] == ["JSONRPC"]
|
||||
|
||||
|
||||
def test_a2a_agent_initialization_empty_list_preserved() -> None:
|
||||
"""Test that an explicit empty list is preserved and not replaced with defaults."""
|
||||
with (
|
||||
patch("agent_framework_a2a._agent.httpx.AsyncClient") as mock_async_client,
|
||||
patch("agent_framework_a2a._agent.ClientConfig") as mock_config,
|
||||
patch("agent_framework_a2a._agent.ClientFactory") as mock_factory,
|
||||
):
|
||||
mock_async_client.return_value = MagicMock()
|
||||
mock_client_instance = MagicMock()
|
||||
mock_factory.return_value.create.return_value = mock_client_instance
|
||||
|
||||
A2AAgent(
|
||||
name="Test Agent",
|
||||
url="https://test-agent.example.com",
|
||||
supported_protocol_bindings=[],
|
||||
)
|
||||
|
||||
# Verify ClientConfig was called with the explicit empty list, not the default
|
||||
assert mock_config.call_count == 2
|
||||
for call in mock_config.call_args_list:
|
||||
assert call.kwargs["supported_protocol_bindings"] == []
|
||||
|
||||
|
||||
def test_a2a_agent_fallback_uses_custom_bindings() -> None:
|
||||
"""Test that transport fallback path uses custom bindings."""
|
||||
mock_agent_card = MagicMock()
|
||||
mock_agent_card.supported_interfaces = [MagicMock(url="https://fallback.example.com")]
|
||||
|
||||
mock_factory = MagicMock()
|
||||
# First create() call fails (primary streaming), then fallback calls succeed
|
||||
primary_error = Exception("no compatible transports found")
|
||||
mock_factory.create.side_effect = [primary_error, MagicMock(), MagicMock()]
|
||||
|
||||
with (
|
||||
patch("agent_framework_a2a._agent.ClientFactory", return_value=mock_factory),
|
||||
patch("agent_framework_a2a._agent.minimal_agent_card") as mock_minimal_card,
|
||||
patch("agent_framework_a2a._agent.httpx.AsyncClient"),
|
||||
):
|
||||
A2AAgent(
|
||||
name="test-agent",
|
||||
agent_card=mock_agent_card,
|
||||
supported_protocol_bindings=["GRPC", "HTTP+JSON"],
|
||||
)
|
||||
|
||||
# Verify minimal_agent_card was called with the custom bindings
|
||||
mock_minimal_card.assert_called_once_with("https://fallback.example.com", ["GRPC", "HTTP+JSON"])
|
||||
|
||||
|
||||
async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
@@ -1335,7 +1423,7 @@ async def test_streaming_status_update_event_yields_content(
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
message_id="msg-status-done",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Done")],
|
||||
),
|
||||
@@ -1350,6 +1438,7 @@ async def test_streaming_status_update_event_yields_content(
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "Done"
|
||||
assert updates[0].role == "assistant"
|
||||
assert updates[0].message_id == "msg-status-done"
|
||||
assert updates[0].raw_representation == update_event
|
||||
|
||||
|
||||
@@ -1362,7 +1451,7 @@ async def test_streaming_input_required_emits_content(a2a_agent: A2AAgent, mock_
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
message_id="msg-input-req",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="What is your name?")],
|
||||
),
|
||||
@@ -1376,6 +1465,7 @@ async def test_streaming_input_required_emits_content(a2a_agent: A2AAgent, mock_
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "What is your name?"
|
||||
assert updates[0].message_id == "msg-input-req"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
|
||||
@@ -365,6 +365,14 @@ def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]:
|
||||
# use the task hub name to separate orchestration state.
|
||||
env["TASKHUB_NAME"] = f"test{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# The Azure Functions Python worker's dependency isolation mechanism crashes
|
||||
# on Python 3.13 with a SIGSEGV in the protobuf C extension (google._upb).
|
||||
# Disabling isolation lets the worker load dependencies from the app's own
|
||||
# environment, which avoids the crash.
|
||||
# See: https://github.com/Azure/azure-functions-python-worker/issues/1797
|
||||
if sys.version_info >= (3, 13):
|
||||
env.setdefault("PYTHON_ISOLATE_WORKER_DEPENDENCIES", "0")
|
||||
|
||||
# On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination
|
||||
# shell=True only on Windows to handle PATH resolution
|
||||
if sys.platform == "win32":
|
||||
@@ -375,8 +383,15 @@ def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]:
|
||||
shell=True,
|
||||
env=env,
|
||||
)
|
||||
# On Unix, don't use shell=True to avoid shell wrapper issues
|
||||
return subprocess.Popen(["func", "start", "--port", str(port)], cwd=str(sample_path), env=env)
|
||||
# On Unix, use start_new_session=True to isolate the process group from the
|
||||
# pytest-xdist worker. Without this, signals (e.g. from test-timeout) can
|
||||
# propagate to the func host and vice-versa, potentially killing the worker.
|
||||
return subprocess.Popen(
|
||||
["func", "start", "--port", str(port)],
|
||||
cwd=str(sample_path),
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_function_app_ready(func_process: subprocess.Popen[Any], port: int, max_wait: int = 60) -> None:
|
||||
@@ -533,18 +548,33 @@ def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str,
|
||||
_load_and_validate_env(sample_path)
|
||||
|
||||
max_attempts = 3
|
||||
# The overall budget MUST be shorter than the pytest-timeout value
|
||||
# (--timeout=120 by default) so that the fixture finishes cleanly instead
|
||||
# of being killed by os._exit() which crashes the xdist worker.
|
||||
overall_budget = 100 # seconds – leaves headroom below the 120 s test timeout
|
||||
last_error: Exception | None = None
|
||||
func_process: subprocess.Popen[Any] | None = None
|
||||
base_url = ""
|
||||
port = 0
|
||||
overall_start = time.monotonic()
|
||||
attempts_made = 0
|
||||
|
||||
for _ in range(max_attempts):
|
||||
remaining = overall_budget - (time.monotonic() - overall_start)
|
||||
if remaining < 10:
|
||||
# Not enough time for another attempt; bail out.
|
||||
break
|
||||
|
||||
attempts_made += 1
|
||||
port = _find_available_port()
|
||||
base_url = _build_base_url(port)
|
||||
func_process = _start_function_app(sample_path, port)
|
||||
|
||||
try:
|
||||
_wait_for_function_app_ready(func_process, port)
|
||||
# Cap each attempt's wait to the remaining budget minus a small
|
||||
# buffer for cleanup.
|
||||
per_attempt_wait = min(60, int(remaining) - 5)
|
||||
_wait_for_function_app_ready(func_process, port, max_wait=max(per_attempt_wait, 10))
|
||||
last_error = None
|
||||
break
|
||||
except FunctionAppStartupError as exc:
|
||||
@@ -553,7 +583,8 @@ def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str,
|
||||
func_process = None
|
||||
|
||||
if func_process is None:
|
||||
error_message = f"Function app failed to start after {max_attempts} attempt(s)."
|
||||
elapsed = int(time.monotonic() - overall_start)
|
||||
error_message = f"Function app failed to start after {attempts_made} attempt(s) ({elapsed}s elapsed)."
|
||||
if last_error is not None:
|
||||
error_message += f" Last error: {last_error}"
|
||||
pytest.fail(error_message)
|
||||
|
||||
@@ -76,6 +76,14 @@ agent_framework/
|
||||
- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
|
||||
- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts.
|
||||
|
||||
### File Access Harness (`_harness/_file_access.py`)
|
||||
|
||||
- **`AgentFileStore`** - Abstract async store backing the file-access harness. Implementations expose `write_file`, `read_file`, `delete_file`, `list_files`, `file_exists`, `search_files`, and `create_directory` over forward-slash relative paths.
|
||||
- **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios.
|
||||
- **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape.
|
||||
- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers.
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_search_files`) plus default usage instructions to each invocation. Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
|
||||
### Workflows (`_workflows/`)
|
||||
|
||||
- **`Workflow`** - Graph-based workflow definition
|
||||
|
||||
@@ -90,6 +90,16 @@ from ._harness._background_agents import (
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
)
|
||||
from ._harness._file_access import (
|
||||
DEFAULT_FILE_ACCESS_INSTRUCTIONS,
|
||||
DEFAULT_FILE_ACCESS_SOURCE_ID,
|
||||
AgentFileStore,
|
||||
FileAccessProvider,
|
||||
FileSearchMatch,
|
||||
FileSearchResult,
|
||||
FileSystemAgentFileStore,
|
||||
InMemoryAgentFileStore,
|
||||
)
|
||||
from ._harness._memory import (
|
||||
DEFAULT_MEMORY_SOURCE_ID,
|
||||
MemoryContextProvider,
|
||||
@@ -309,6 +319,8 @@ __all__ = [
|
||||
"APP_INFO",
|
||||
"COMPACTION_STATE_KEY",
|
||||
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
|
||||
"DEFAULT_FILE_ACCESS_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_ACCESS_SOURCE_ID",
|
||||
"DEFAULT_HARNESS_INSTRUCTIONS",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
@@ -334,6 +346,7 @@ __all__ = [
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentFileStore",
|
||||
"AgentFrameworkException",
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewareLayer",
|
||||
@@ -393,11 +406,15 @@ __all__ = [
|
||||
"ExperimentalFeature",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileAccessProvider",
|
||||
"FileCheckpointStorage",
|
||||
"FileHistoryProvider",
|
||||
"FileSearchMatch",
|
||||
"FileSearchResult",
|
||||
"FileSkill",
|
||||
"FileSkillScript",
|
||||
"FileSkillsSource",
|
||||
"FileSystemAgentFileStore",
|
||||
"FilteringSkillsSource",
|
||||
"FinalT",
|
||||
"FinishReason",
|
||||
@@ -414,6 +431,7 @@ __all__ = [
|
||||
"GeneratedEmbeddings",
|
||||
"GraphConnectivityError",
|
||||
"HistoryProvider",
|
||||
"InMemoryAgentFileStore",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InMemoryHistoryProvider",
|
||||
"InMemorySkillsSource",
|
||||
|
||||
@@ -50,6 +50,7 @@ class ExperimentalFeature(str, Enum):
|
||||
on enum membership or attribute presence over time.
|
||||
"""
|
||||
|
||||
DECLARATIVE_AGENTS = "DECLARATIVE_AGENTS"
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FIDES = "FIDES"
|
||||
|
||||
@@ -14,12 +14,13 @@ import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .._agents import Agent
|
||||
from .._agents import Agent, SupportsAgentRun
|
||||
from .._clients import SupportsWebSearchTool
|
||||
from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider
|
||||
from .._skills import SkillsProvider
|
||||
from ._background_agents import BackgroundAgentsProvider
|
||||
from ._memory import MemoryContextProvider, MemoryStore
|
||||
from ._mode import AgentModeProvider
|
||||
from ._todo import TodoProvider
|
||||
@@ -103,6 +104,8 @@ def _assemble_context_providers(
|
||||
memory_store: MemoryStore | None,
|
||||
skills_provider: SkillsProvider | None,
|
||||
skills_paths: Sequence[str] | None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None,
|
||||
background_agents_instructions: str | None,
|
||||
extra_context_providers: Sequence[ContextProvider] | None,
|
||||
) -> list[ContextProvider]:
|
||||
"""Assemble the ordered list of context providers."""
|
||||
@@ -130,6 +133,10 @@ def _assemble_context_providers(
|
||||
if skills_paths:
|
||||
providers.append(SkillsProvider.from_paths(*skills_paths))
|
||||
|
||||
# Background agents are opt-in: only added when agents are provided.
|
||||
if background_agents:
|
||||
providers.append(BackgroundAgentsProvider(background_agents, instructions=background_agents_instructions))
|
||||
|
||||
# Append any user-supplied additional providers.
|
||||
if extra_context_providers:
|
||||
providers.extend(extra_context_providers)
|
||||
@@ -165,6 +172,8 @@ def create_harness_agent(
|
||||
memory_store: MemoryStore | None = None,
|
||||
skills_provider: SkillsProvider | None = None,
|
||||
skills_paths: Sequence[str] | None = None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None = None,
|
||||
background_agents_instructions: str | None = None,
|
||||
disable_web_search: bool = False,
|
||||
otel_provider_name: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
@@ -182,6 +191,7 @@ def create_harness_agent(
|
||||
- **AgentModeProvider** — plan/execute mode tracking
|
||||
- **MemoryContextProvider** — file-based durable memory (when ``memory_store`` provided)
|
||||
- **SkillsProvider** — skill discovery and progressive loading
|
||||
- **BackgroundAgentsProvider** — delegate work to background sub-agents
|
||||
- **OpenTelemetry** — observability via ``AgentTelemetryLayer``
|
||||
|
||||
Each feature can be disabled or customized via keyword arguments.
|
||||
@@ -253,6 +263,13 @@ def create_harness_agent(
|
||||
skills_paths: Paths for file-based skill discovery (looks for SKILL.md files).
|
||||
Can be combined with ``skills_provider``. When neither ``skills_provider``
|
||||
nor ``skills_paths`` is provided, no SkillsProvider is added.
|
||||
background_agents: Collection of agents available for background task delegation.
|
||||
When provided, a ``BackgroundAgentsProvider`` is automatically included,
|
||||
enabling the agent to start, monitor, and retrieve results from background tasks.
|
||||
Each agent must have a non-empty, unique name (case-insensitive).
|
||||
background_agents_instructions: Optional instruction override for the
|
||||
``BackgroundAgentsProvider``. May include ``{background_agents}`` placeholder
|
||||
which will be replaced with the agent listing.
|
||||
disable_web_search: When True, skip automatic web search tool inclusion.
|
||||
When False (default), the web search tool is automatically added if the
|
||||
client implements SupportsWebSearchTool. A warning is logged if the client
|
||||
@@ -302,6 +319,8 @@ def create_harness_agent(
|
||||
memory_store=memory_store,
|
||||
skills_provider=skills_provider,
|
||||
skills_paths=skills_paths,
|
||||
background_agents=background_agents,
|
||||
background_agents_instructions=background_agents_instructions,
|
||||
extra_context_providers=context_providers,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import logging
|
||||
import re
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Collection, Coroutine, Sequence
|
||||
from collections.abc import Callable, Collection, Coroutine, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
@@ -142,6 +142,13 @@ def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str,
|
||||
return meta
|
||||
|
||||
|
||||
def _url_origin(url: Any) -> tuple[str, str, int | None]:
|
||||
port = url.port
|
||||
if port is None:
|
||||
port = 443 if url.scheme == "https" else 80 if url.scheme == "http" else None
|
||||
return (url.scheme, url.host or "", port)
|
||||
|
||||
|
||||
def streamable_http_client(*args: Any, **kwargs: Any) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
"""Lazily import the MCP streamable HTTP transport."""
|
||||
try:
|
||||
@@ -255,6 +262,7 @@ class MCPTool:
|
||||
self._exit_stack = AsyncExitStack()
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._lifecycle_request_lock = asyncio.Lock()
|
||||
self._function_load_lock = asyncio.Lock()
|
||||
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None]]] | None = None
|
||||
self._lifecycle_owner_task: asyncio.Task[None] | None = None
|
||||
self.session = session
|
||||
@@ -655,6 +663,11 @@ class MCPTool:
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("Could not cleanly close MCP exit stack because the lifecycle owner task was cancelled.")
|
||||
except Exception as e:
|
||||
if type(e).__name__ == "ExceptionGroup":
|
||||
logger.warning("Could not cleanly close MCP exit stack due to cleanup error group. Error: %s", e)
|
||||
else:
|
||||
raise
|
||||
|
||||
async def _close_and_check_cancelled(self, ex: BaseException) -> bool:
|
||||
"""Close the exit stack and return True if *ex* is a genuine task cancellation.
|
||||
@@ -1018,6 +1031,10 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
async with self._function_load_lock:
|
||||
await self._load_prompts_locked()
|
||||
|
||||
async def _load_prompts_locked(self) -> None:
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
@@ -1100,6 +1117,10 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
async with self._function_load_lock:
|
||||
await self._load_tools_locked()
|
||||
|
||||
async def _load_tools_locked(self) -> None:
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
|
||||
@@ -1109,7 +1130,7 @@ class MCPTool:
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
self._tool_call_meta_by_name.clear()
|
||||
tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
|
||||
|
||||
params: types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
@@ -1145,7 +1166,7 @@ class MCPTool:
|
||||
|
||||
for tool in tool_list.tools:
|
||||
if tool.meta is not None:
|
||||
self._tool_call_meta_by_name[tool.name] = dict(tool.meta)
|
||||
tool_call_meta_by_name[tool.name] = dict(tool.meta)
|
||||
|
||||
normalized_name = _normalize_mcp_name(tool.name)
|
||||
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
|
||||
@@ -1194,6 +1215,8 @@ class MCPTool:
|
||||
break
|
||||
params = types.PaginatedRequestParams(cursor=tool_list.nextCursor)
|
||||
|
||||
self._tool_call_meta_by_name = tool_call_meta_by_name
|
||||
|
||||
async def _close_on_owner(self) -> None:
|
||||
# Cancel any pending reload tasks before tearing down the session.
|
||||
tasks = list(self._pending_reload_tasks)
|
||||
@@ -1276,7 +1299,11 @@ class MCPTool:
|
||||
tool_name: The name of the tool to call.
|
||||
|
||||
Keyword Args:
|
||||
kwargs: Arguments to pass to the tool.
|
||||
_meta: Optional ``dict[str, Any]`` of MCP request metadata. This reserved key is passed as the
|
||||
``meta`` parameter of the underlying ``session.call_tool`` call rather than as a tool argument.
|
||||
User-supplied keys override metadata from ``tools/list``; OpenTelemetry propagation fills in
|
||||
non-conflicting keys.
|
||||
kwargs: Remaining arguments to pass to the tool.
|
||||
|
||||
Returns:
|
||||
A list of Content items representing the tool output. The default
|
||||
@@ -1294,6 +1321,19 @@ class MCPTool:
|
||||
raise ToolExecutionException(
|
||||
"Tools are not loaded for this server, please set load_tools=True in the constructor."
|
||||
)
|
||||
|
||||
raw_user_meta: object | None = kwargs.get("_meta")
|
||||
user_meta: dict[str, Any] | None = None
|
||||
if raw_user_meta is not None and not isinstance(raw_user_meta, dict):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must be a dict.")
|
||||
if isinstance(raw_user_meta, dict):
|
||||
raw_user_meta_dict = cast(Mapping[object, object], raw_user_meta)
|
||||
user_meta = {}
|
||||
for key, value in raw_user_meta_dict.items():
|
||||
if not isinstance(key, str):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
|
||||
user_meta[key] = value
|
||||
|
||||
# Filter out framework kwargs that cannot be serialized by the MCP SDK.
|
||||
# These are internal objects passed through the function invocation pipeline
|
||||
# that should not be forwarded to external MCP servers.
|
||||
@@ -1313,12 +1353,16 @@ class MCPTool:
|
||||
"conversation_id",
|
||||
"options",
|
||||
"response_format",
|
||||
"_meta",
|
||||
}
|
||||
}
|
||||
|
||||
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
|
||||
tool_meta = self._tool_call_meta_by_name.get(tool_name)
|
||||
meta = _inject_otel_into_mcp_meta(dict(tool_meta) if tool_meta is not None else None)
|
||||
request_meta = dict(tool_meta) if tool_meta is not None else None
|
||||
if user_meta is not None:
|
||||
request_meta = {**(request_meta or {}), **user_meta}
|
||||
meta = _inject_otel_into_mcp_meta(request_meta)
|
||||
|
||||
parser = self.parse_tool_results or self._parse_tool_result_from_mcp
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
@@ -1336,28 +1380,33 @@ class MCPTool:
|
||||
return parser(result)
|
||||
except ToolExecutionException:
|
||||
raise
|
||||
except ClosedResourceError as cl_ex:
|
||||
except (ClosedResourceError, McpError) as call_ex:
|
||||
is_session_terminated = (
|
||||
isinstance(call_ex, McpError) and "session terminated" in call_ex.error.message.lower()
|
||||
)
|
||||
is_connection_lost = isinstance(call_ex, ClosedResourceError) or is_session_terminated
|
||||
if not is_connection_lost:
|
||||
error_message = call_ex.error.message if isinstance(call_ex, McpError) else str(call_ex)
|
||||
raise ToolExecutionException(error_message, inner_exception=call_ex) from call_ex
|
||||
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
logger.info("MCP connection closed unexpectedly. Reconnecting...")
|
||||
# First attempt failed, try reconnecting.
|
||||
logger.info("MCP connection closed or terminated unexpectedly. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
continue # Retry the operation
|
||||
continue
|
||||
except Exception as reconn_ex:
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
else:
|
||||
# Second attempt also failed, give up
|
||||
logger.error(f"MCP connection closed unexpectedly after reconnection: {cl_ex}")
|
||||
raise ToolExecutionException(
|
||||
f"Failed to call tool '{tool_name}' - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
error_message = mcp_exc.error.message
|
||||
raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc
|
||||
|
||||
# Second attempt also failed, give up.
|
||||
logger.error("MCP connection closed unexpectedly after reconnection: %s", call_ex)
|
||||
raise ToolExecutionException(
|
||||
f"Failed to call tool '{tool_name}' - connection lost.",
|
||||
inner_exception=call_ex,
|
||||
) from call_ex
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.")
|
||||
@@ -1718,10 +1767,11 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
Returns:
|
||||
An async context manager for the streamable HTTP client transport.
|
||||
"""
|
||||
from httpx import AsyncClient, Request, Timeout
|
||||
from httpx import URL, AsyncClient, Request, Timeout
|
||||
|
||||
http_client = self._httpx_client
|
||||
if self._header_provider is not None:
|
||||
target_origin = _url_origin(URL(self.url))
|
||||
if http_client is None:
|
||||
http_client = AsyncClient(
|
||||
follow_redirects=True,
|
||||
@@ -1732,6 +1782,8 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
if not hasattr(self, "_inject_headers_hook"):
|
||||
|
||||
async def _inject_headers(request: Request) -> None: # noqa: RUF029
|
||||
if _url_origin(request.url) != target_origin:
|
||||
return
|
||||
headers = _mcp_call_headers.get({})
|
||||
for key, value in headers.items():
|
||||
request.headers[key] = value
|
||||
|
||||
@@ -36,11 +36,10 @@ if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._agents import SupportsAgentRun
|
||||
from ._clients import SupportsChatGetResponse
|
||||
from ._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from ._sessions import AgentSession
|
||||
from ._tools import FunctionTool, ToolTypes
|
||||
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
|
||||
from ._types import ChatOptions
|
||||
|
||||
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
|
||||
|
||||
|
||||
@@ -2134,9 +2134,7 @@ class SkillsProvider(ContextProvider):
|
||||
),
|
||||
FunctionTool(
|
||||
name="read_skill_resource",
|
||||
description=(
|
||||
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
|
||||
),
|
||||
description=("Reads a resource associated with a skill, such as references, assets, or dynamic data."),
|
||||
func=_read_resource,
|
||||
input_model={
|
||||
"type": "object",
|
||||
@@ -2173,8 +2171,7 @@ class SkillsProvider(ContextProvider):
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": (
|
||||
"Named arguments as key-value pairs "
|
||||
'(e.g. {"length": 24, "uppercase": true}).'
|
||||
'Named arguments as key-value pairs (e.g. {"length": 24, "uppercase": true}).'
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1973,11 +1973,97 @@ def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "t
|
||||
contents.extend(coalesced_contents)
|
||||
|
||||
|
||||
def _content_items_text(items: Any) -> str | None:
|
||||
"""Return concatenated text when a content item list only contains text."""
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
text_parts: list[str] = []
|
||||
content_items = cast(list[object], items)
|
||||
for item in content_items:
|
||||
if not isinstance(item, Content) or item.type != "text":
|
||||
return None
|
||||
text_parts.append(item.text or "")
|
||||
return "".join(text_parts)
|
||||
|
||||
|
||||
def _merge_content_item_lists(existing: Any, incoming: Any) -> Any:
|
||||
"""Merge streamed nested content lists, replacing deltas with a later full value when present."""
|
||||
if incoming is None:
|
||||
return existing
|
||||
if existing is None:
|
||||
return deepcopy(incoming)
|
||||
|
||||
existing_text = _content_items_text(existing)
|
||||
incoming_text = _content_items_text(incoming)
|
||||
if existing_text is not None and incoming_text is not None:
|
||||
if incoming_text.startswith(existing_text):
|
||||
return deepcopy(incoming)
|
||||
if existing_text.startswith(incoming_text):
|
||||
return existing
|
||||
|
||||
existing_items = cast(list[Content], existing)
|
||||
merged = deepcopy(existing_items[0])
|
||||
merged.text = existing_text + incoming_text
|
||||
return [merged]
|
||||
|
||||
if isinstance(existing, list) and isinstance(incoming, list):
|
||||
existing_list = cast(list[object], existing)
|
||||
incoming_list = cast(list[object], incoming)
|
||||
return [*existing_list, *deepcopy(incoming_list)]
|
||||
return deepcopy(incoming)
|
||||
|
||||
|
||||
def _merge_code_interpreter_content(existing: Content, incoming: Content) -> None:
|
||||
"""Merge two code interpreter content items for the same logical call."""
|
||||
existing.inputs = _merge_content_item_lists(existing.inputs, incoming.inputs)
|
||||
existing.outputs = _merge_content_item_lists(existing.outputs, incoming.outputs)
|
||||
existing.annotations = _combine_annotations(existing.annotations, incoming.annotations)
|
||||
existing.additional_properties = {**existing.additional_properties, **incoming.additional_properties}
|
||||
existing.raw_representation = _combine_raw_representations(existing.raw_representation, incoming.raw_representation)
|
||||
|
||||
|
||||
def _code_interpreter_key(content: Content) -> tuple[str, str] | None:
|
||||
"""Return the aggregation key for code interpreter call/result content."""
|
||||
if content.type not in {"code_interpreter_tool_call", "code_interpreter_tool_result"}:
|
||||
return None
|
||||
call_id = content.call_id or content.additional_properties.get("item_id")
|
||||
if not isinstance(call_id, str) or not call_id:
|
||||
return None
|
||||
return content.type, call_id
|
||||
|
||||
|
||||
def _coalesce_code_interpreter_content(contents: list[Content]) -> None:
|
||||
"""Coalesce streaming code interpreter chunks by call id."""
|
||||
if not contents:
|
||||
return
|
||||
|
||||
coalesced_contents: list[Content] = []
|
||||
seen: dict[tuple[str, str], Content] = {}
|
||||
for content in contents:
|
||||
key = _code_interpreter_key(content)
|
||||
if key is None:
|
||||
coalesced_contents.append(content)
|
||||
continue
|
||||
|
||||
existing = seen.get(key)
|
||||
if existing is None:
|
||||
copied = deepcopy(content)
|
||||
seen[key] = copied
|
||||
coalesced_contents.append(copied)
|
||||
continue
|
||||
|
||||
_merge_code_interpreter_content(existing, content)
|
||||
|
||||
contents.clear()
|
||||
contents.extend(coalesced_contents)
|
||||
|
||||
|
||||
def _finalize_response(response: ChatResponse | AgentResponse) -> None:
|
||||
"""Finalizes the response by performing any necessary post-processing."""
|
||||
for msg in response.messages:
|
||||
_coalesce_text_content(msg.contents, "text")
|
||||
_coalesce_text_content(msg.contents, "text_reasoning")
|
||||
_coalesce_code_interpreter_content(msg.contents)
|
||||
|
||||
|
||||
# region ContinuationToken
|
||||
|
||||
@@ -1332,6 +1332,22 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
self.duration_histogram = _get_duration_histogram()
|
||||
self.otel_provider_name = otel_provider_name or getattr(self, "OTEL_PROVIDER_NAME", "unknown")
|
||||
|
||||
@staticmethod
|
||||
def _backfill_request_model(span: trace.Span, attributes: dict[str, Any]) -> None:
|
||||
"""Backfill REQUEST_MODEL and the span name from RESPONSE_MODEL when unknown.
|
||||
|
||||
Chat-completion spans use REQUEST_MODEL as part of the span name. If the
|
||||
request model was not known at span creation time (e.g. the client could
|
||||
not resolve it before sending the request), update both the attribute and
|
||||
the span name to the actual model returned in the response. Mutates
|
||||
``attributes`` in place.
|
||||
"""
|
||||
response_model = attributes.get(OtelAttr.RESPONSE_MODEL)
|
||||
if response_model and attributes.get(OtelAttr.REQUEST_MODEL, "unknown") == "unknown":
|
||||
attributes[OtelAttr.REQUEST_MODEL] = response_model
|
||||
operation = attributes.get(OtelAttr.OPERATION, "operation")
|
||||
span.update_name(f"{operation} {response_model}")
|
||||
|
||||
@overload
|
||||
def get_response(
|
||||
self,
|
||||
@@ -1480,6 +1496,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
response: ChatResponse[Any] = await result_stream.get_final_response()
|
||||
duration = duration_state.get("duration")
|
||||
response_attributes = _get_response_attributes(attributes, response)
|
||||
self._backfill_request_model(span, response_attributes)
|
||||
_capture_response(
|
||||
span=span,
|
||||
attributes=response_attributes,
|
||||
@@ -1549,6 +1566,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
raise
|
||||
duration = perf_counter() - start_time_stamp
|
||||
response_attributes = _get_response_attributes(attributes, response)
|
||||
self._backfill_request_model(span, response_attributes)
|
||||
_capture_response(
|
||||
span=span,
|
||||
attributes=response_attributes,
|
||||
|
||||
@@ -394,3 +394,94 @@ def test_create_harness_agent_logs_warning_when_no_web_search(caplog: pytest.Log
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
assert any("SupportsWebSearchTool" in msg for msg in caplog.messages)
|
||||
|
||||
|
||||
# --- Background Agents Tests ---
|
||||
|
||||
|
||||
class _FakeBackgroundAgent:
|
||||
"""Minimal agent stub satisfying SupportsAgentRun for background agents tests."""
|
||||
|
||||
def __init__(self, name: str, description: str | None = None):
|
||||
self.id = f"agent-{name}"
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(service_session_id=service_session_id, session_id=session_id)
|
||||
|
||||
async def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
|
||||
from agent_framework import AgentResponse
|
||||
|
||||
return AgentResponse(messages=[], response_id="fake-bg-response")
|
||||
|
||||
|
||||
def test_create_harness_agent_no_background_agents_by_default() -> None:
|
||||
"""No BackgroundAgentsProvider should be included when background_agents is not provided."""
|
||||
from agent_framework._harness._background_agents import BackgroundAgentsProvider
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers)
|
||||
|
||||
|
||||
def test_create_harness_agent_adds_background_agents_provider() -> None:
|
||||
"""BackgroundAgentsProvider should be included when background_agents are provided."""
|
||||
from agent_framework._harness._background_agents import BackgroundAgentsProvider
|
||||
|
||||
bg_agent = _FakeBackgroundAgent("WebSearcher", "Searches the web")
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
background_agents=[bg_agent],
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
bg_providers = [p for p in providers if isinstance(p, BackgroundAgentsProvider)]
|
||||
assert len(bg_providers) == 1
|
||||
|
||||
|
||||
def test_create_harness_agent_background_agents_custom_instructions() -> None:
|
||||
"""Custom instructions should be passed to BackgroundAgentsProvider."""
|
||||
from agent_framework._harness._background_agents import BackgroundAgentsProvider
|
||||
|
||||
custom_instructions = "## Custom\n\nUse agents wisely.\n\n{background_agents}"
|
||||
bg_agent = _FakeBackgroundAgent("Helper", "A helper agent")
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
background_agents=[bg_agent],
|
||||
background_agents_instructions=custom_instructions,
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
bg_providers = [p for p in providers if isinstance(p, BackgroundAgentsProvider)]
|
||||
assert len(bg_providers) == 1
|
||||
# Verify the custom instructions were used (placeholder replaced with agent list).
|
||||
assert "Custom" in bg_providers[0]._instructions
|
||||
assert "Helper" in bg_providers[0]._instructions
|
||||
|
||||
|
||||
def test_create_harness_agent_empty_background_agents_list() -> None:
|
||||
"""An empty background_agents list should NOT add a BackgroundAgentsProvider."""
|
||||
from agent_framework._harness._background_agents import BackgroundAgentsProvider
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
background_agents=[],
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers)
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentFileStore,
|
||||
AgentSession,
|
||||
ExperimentalFeature,
|
||||
FileAccessProvider,
|
||||
FileSearchMatch,
|
||||
FileSearchResult,
|
||||
FileSystemAgentFileStore,
|
||||
InMemoryAgentFileStore,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
)
|
||||
from agent_framework._harness import _file_access as _file_access_module
|
||||
from agent_framework._harness._file_access import (
|
||||
DEFAULT_FILE_ACCESS_INSTRUCTIONS,
|
||||
DEFAULT_FILE_ACCESS_SOURCE_ID,
|
||||
_matches_glob,
|
||||
_normalize_relative_path,
|
||||
_run_search_with_timeout,
|
||||
)
|
||||
|
||||
|
||||
def _tool_by_name(tools: list[object], name: str) -> object:
|
||||
"""Return the tool with the requested name from a prepared tool list."""
|
||||
for tool in tools:
|
||||
if getattr(tool, "name", None) == name:
|
||||
return tool
|
||||
raise AssertionError(f"Tool {name!r} was not found.")
|
||||
|
||||
|
||||
def test_normalize_relative_path_collapses_and_validates() -> None:
|
||||
"""The path normalizer should accept relative forward/backslash paths and reject unsafe ones."""
|
||||
assert _normalize_relative_path("foo/bar.txt") == "foo/bar.txt"
|
||||
assert _normalize_relative_path("foo\\bar.txt") == "foo/bar.txt"
|
||||
assert _normalize_relative_path("foo//bar.txt") == "foo/bar.txt"
|
||||
assert _normalize_relative_path(" foo/bar.txt ") == "foo/bar.txt"
|
||||
assert _normalize_relative_path("", is_directory=True) == ""
|
||||
assert _normalize_relative_path(" ", is_directory=True) == ""
|
||||
assert _normalize_relative_path("sub/", is_directory=True) == "sub"
|
||||
assert _normalize_relative_path("sub\\", is_directory=True) == "sub"
|
||||
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
_normalize_relative_path("")
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
_normalize_relative_path(" ")
|
||||
with pytest.raises(ValueError, match="must not end with a path separator"):
|
||||
_normalize_relative_path("foo/")
|
||||
with pytest.raises(ValueError, match="must not end with a path separator"):
|
||||
_normalize_relative_path("foo\\")
|
||||
with pytest.raises(ValueError, match="'..' segments"):
|
||||
_normalize_relative_path("foo/../bar.txt")
|
||||
with pytest.raises(ValueError, match="'..' segments"):
|
||||
_normalize_relative_path("./bar.txt")
|
||||
with pytest.raises(ValueError, match="must be relative"):
|
||||
_normalize_relative_path("C:/abs/path")
|
||||
with pytest.raises(ValueError, match="must be relative"):
|
||||
_normalize_relative_path("\\rooted")
|
||||
with pytest.raises(ValueError, match="must be relative"):
|
||||
_normalize_relative_path("/foo/bar.txt")
|
||||
|
||||
|
||||
def test_matches_glob_is_case_insensitive_and_optional() -> None:
|
||||
"""The glob matcher should be case-insensitive and treat missing patterns as match-all."""
|
||||
assert _matches_glob("notes.MD", "*.md")
|
||||
assert _matches_glob("research_one.txt", "research*")
|
||||
assert not _matches_glob("plan.txt", "*.md")
|
||||
assert _matches_glob("anything", None)
|
||||
assert _matches_glob("anything", "")
|
||||
assert _matches_glob("anything", " ")
|
||||
|
||||
|
||||
def test_file_search_match_round_trips() -> None:
|
||||
"""File search match values should serialize and validate cleanly."""
|
||||
raw_match = {"line_number": 3, "line": "error: boom"}
|
||||
|
||||
match = FileSearchMatch.from_dict(raw_match)
|
||||
assert match == FileSearchMatch(line_number=3, line="error: boom")
|
||||
assert match.to_dict() == raw_match
|
||||
assert "FileSearchMatch(" in repr(match)
|
||||
|
||||
with pytest.raises(ValueError, match="positive integer"):
|
||||
FileSearchMatch(line_number=0, line="oops")
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
FileSearchMatch.from_dict({"line_number": "1", "line": "oops"})
|
||||
with pytest.raises(ValueError, match="must be a string"):
|
||||
FileSearchMatch.from_dict({"line_number": 1, "line": 42})
|
||||
|
||||
|
||||
def test_file_search_result_round_trips() -> None:
|
||||
"""File search result values should serialize the matching-line list correctly."""
|
||||
raw_result = {
|
||||
"file_name": "notes.md",
|
||||
"snippet": "hello error world",
|
||||
"matching_lines": [{"line_number": 2, "line": "error one"}],
|
||||
}
|
||||
|
||||
result = FileSearchResult.from_dict(raw_result)
|
||||
assert result.file_name == "notes.md"
|
||||
assert result.snippet == "hello error world"
|
||||
assert result.matching_lines == [FileSearchMatch(line_number=2, line="error one")]
|
||||
assert result.to_dict() == raw_result
|
||||
assert json.loads(result.to_json()) == raw_result
|
||||
|
||||
with pytest.raises(ValueError, match="matching_lines must be a list"):
|
||||
FileSearchResult.from_dict({"file_name": "x", "snippet": "", "matching_lines": {}})
|
||||
|
||||
with pytest.raises(ValueError, match="elements must be mappings"):
|
||||
FileSearchResult.from_dict({"file_name": "x", "snippet": "", "matching_lines": ["not-a-dict"]})
|
||||
|
||||
|
||||
async def test_in_memory_store_round_trips_files() -> None:
|
||||
"""The in-memory store should support write/read/exists/delete/list operations."""
|
||||
store = InMemoryAgentFileStore()
|
||||
|
||||
await store.write_file("a.txt", "alpha")
|
||||
await store.write_file("sub/b.txt", "beta")
|
||||
|
||||
assert await store.file_exists("a.txt")
|
||||
assert not await store.file_exists("missing.txt")
|
||||
assert await store.read_file("a.txt") == "alpha"
|
||||
assert await store.read_file("missing.txt") is None
|
||||
|
||||
assert sorted(await store.list_files()) == ["a.txt"] # subdirs are not direct children
|
||||
assert sorted(await store.list_files("sub")) == ["b.txt"]
|
||||
|
||||
assert await store.delete_file("a.txt") is True
|
||||
assert await store.delete_file("a.txt") is False
|
||||
assert sorted(await store.list_files()) == []
|
||||
|
||||
|
||||
async def test_in_memory_store_search_returns_matches_with_snippets() -> None:
|
||||
"""The in-memory store should search file content case-insensitively and respect glob filters."""
|
||||
store = InMemoryAgentFileStore()
|
||||
await store.write_file("a.md", "line one\nThis line has ERROR inside\nline three\r")
|
||||
await store.write_file("b.md", "no match here")
|
||||
await store.write_file("notes.txt", "ERROR but wrong extension")
|
||||
|
||||
results = await store.search_files("", "error", "*.md")
|
||||
assert [result.file_name for result in results] == ["a.md"]
|
||||
matching_lines = results[0].matching_lines
|
||||
assert matching_lines == [FileSearchMatch(line_number=2, line="This line has ERROR inside")]
|
||||
assert "ERROR" in results[0].snippet
|
||||
|
||||
# No glob -> searches every file.
|
||||
results_all = await store.search_files("", "error")
|
||||
assert {result.file_name for result in results_all} == {"a.md", "notes.txt"}
|
||||
|
||||
|
||||
async def test_in_memory_store_search_rejects_invalid_and_oversize_regex() -> None:
|
||||
"""``search_files`` should surface clean errors for bad regex input."""
|
||||
store = InMemoryAgentFileStore()
|
||||
await store.write_file("a.md", "hello")
|
||||
|
||||
with pytest.raises(re.error):
|
||||
await store.search_files("", "[unclosed")
|
||||
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
await store.search_files("", "a" * 257)
|
||||
|
||||
|
||||
async def test_in_memory_store_normalizes_paths() -> None:
|
||||
"""Path normalization should reject traversal in the in-memory store too."""
|
||||
store = InMemoryAgentFileStore()
|
||||
for bad in ("../escape.txt", "/abs/path.txt", "."):
|
||||
with pytest.raises(ValueError):
|
||||
await store.write_file(bad, "boom")
|
||||
|
||||
|
||||
async def test_filesystem_store_round_trips_files(tmp_path: Path) -> None:
|
||||
"""The filesystem store should round-trip files on disk and create parents on write."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
|
||||
await store.write_file("nested/a.txt", "alpha")
|
||||
assert (tmp_path / "nested" / "a.txt").read_text(encoding="utf-8") == "alpha"
|
||||
|
||||
assert await store.read_file("nested/a.txt") == "alpha"
|
||||
assert await store.read_file("missing.txt") is None
|
||||
assert await store.file_exists("nested/a.txt")
|
||||
assert not await store.file_exists("missing.txt")
|
||||
assert sorted(await store.list_files("nested")) == ["a.txt"]
|
||||
assert sorted(await store.list_files()) == [] # root only contains the directory
|
||||
|
||||
assert await store.delete_file("nested/a.txt") is True
|
||||
assert await store.delete_file("nested/a.txt") is False
|
||||
|
||||
|
||||
async def test_filesystem_store_rejects_traversal_and_rooted_paths(tmp_path: Path) -> None:
|
||||
"""The filesystem store should refuse paths that escape the configured root."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
|
||||
for bad in ("../escape.txt", "/etc/passwd", "C:/Windows/System32/notepad.exe", ".", ".."):
|
||||
with pytest.raises(ValueError):
|
||||
await store.write_file(bad, "boom")
|
||||
|
||||
|
||||
async def test_filesystem_store_rejects_symlinks_into_root(tmp_path: Path) -> None:
|
||||
"""The filesystem store should refuse to read through a symlink target."""
|
||||
target = tmp_path / "outside.txt"
|
||||
target.write_text("outside", encoding="utf-8")
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
link = root / "link.txt"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"Symbolic links are not supported in this environment: {exc!r}")
|
||||
|
||||
store = FileSystemAgentFileStore(root)
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.read_file("link.txt")
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.write_file("link.txt", "stomp")
|
||||
|
||||
# List operations should silently skip the symlink entry rather than raise.
|
||||
assert await store.list_files() == []
|
||||
|
||||
|
||||
async def test_filesystem_store_rejects_in_root_symlinks(tmp_path: Path) -> None:
|
||||
"""Symlinks whose target lives under the root must still be rejected.
|
||||
|
||||
``Path.resolve`` collapses the symlink, so a naive resolved-path check
|
||||
would silently follow it. The symlink probe must operate on the
|
||||
unresolved candidate for this case to fail closed.
|
||||
"""
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
real = root / "real.txt"
|
||||
real.write_text("payload", encoding="utf-8")
|
||||
link = root / "alias.txt"
|
||||
try:
|
||||
link.symlink_to(real)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"Symbolic links are not supported in this environment: {exc!r}")
|
||||
|
||||
store = FileSystemAgentFileStore(root)
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.read_file("alias.txt")
|
||||
# The non-symlinked sibling must still be readable.
|
||||
assert await store.read_file("real.txt") == "payload"
|
||||
|
||||
|
||||
async def test_filesystem_store_search_matches_lines_and_filters_globs(tmp_path: Path) -> None:
|
||||
"""The filesystem store should search files on disk and apply glob filters by file name."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("a.md", "hello\nERROR happens\nbye\r")
|
||||
await store.write_file("b.txt", "ERROR happens too")
|
||||
await store.write_file("c.md", "nothing here")
|
||||
|
||||
results = await store.search_files("", "error", "*.md")
|
||||
assert [result.file_name for result in results] == ["a.md"]
|
||||
assert results[0].matching_lines == [FileSearchMatch(line_number=2, line="ERROR happens")]
|
||||
assert "ERROR" in results[0].snippet
|
||||
|
||||
results_all = await store.search_files("", "error")
|
||||
assert {result.file_name for result in results_all} == {"a.md", "b.txt"}
|
||||
|
||||
|
||||
async def test_filesystem_store_search_skips_non_utf8_files(tmp_path: Path) -> None:
|
||||
"""The filesystem store should silently skip non-UTF-8 files instead of aborting the search."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("notes.md", "ERROR happens here")
|
||||
(tmp_path / "blob.bin").write_bytes(b"\x80\x81\x82\x83")
|
||||
|
||||
results = await store.search_files("", "error")
|
||||
assert [result.file_name for result in results] == ["notes.md"]
|
||||
|
||||
|
||||
async def test_filesystem_store_create_directory(tmp_path: Path) -> None:
|
||||
"""The filesystem store should create directories under the configured root."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.create_directory("nested/inner")
|
||||
assert (tmp_path / "nested" / "inner").is_dir()
|
||||
|
||||
|
||||
async def test_filesystem_store_list_files_accepts_blank_directory(tmp_path: Path) -> None:
|
||||
"""Whitespace-only directory inputs should resolve to the root, matching the in-memory store."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("a.txt", "alpha")
|
||||
assert sorted(await store.list_files("")) == ["a.txt"]
|
||||
assert sorted(await store.list_files(" ")) == ["a.txt"]
|
||||
|
||||
|
||||
def test_filesystem_store_requires_non_empty_root() -> None:
|
||||
"""The filesystem store constructor should refuse blank root paths."""
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
FileSystemAgentFileStore("")
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
FileSystemAgentFileStore(" ")
|
||||
|
||||
|
||||
async def test_file_access_provider_registers_tools_and_instructions(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""``FileAccessProvider.before_run`` should add the canonical instructions and five tools."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = InMemoryAgentFileStore()
|
||||
provider = FileAccessProvider(store=store)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
expected_names = {
|
||||
"file_access_save_file",
|
||||
"file_access_read_file",
|
||||
"file_access_delete_file",
|
||||
"file_access_list_files",
|
||||
"file_access_search_files",
|
||||
}
|
||||
assert {getattr(tool, "name", None) for tool in tools} >= expected_names
|
||||
|
||||
instructions = options.get("instructions")
|
||||
if isinstance(instructions, str):
|
||||
assert DEFAULT_FILE_ACCESS_INSTRUCTIONS in instructions
|
||||
else:
|
||||
assert any(DEFAULT_FILE_ACCESS_INSTRUCTIONS in chunk for chunk in (instructions or []))
|
||||
|
||||
|
||||
async def test_file_access_provider_delete_approval_defaults_to_always_require(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""By default ``file_access_delete_file`` should require host approval."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore())
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
assert delete_file.approval_mode == "always_require"
|
||||
# The non-destructive tools should remain autonomous.
|
||||
for name in (
|
||||
"file_access_save_file",
|
||||
"file_access_read_file",
|
||||
"file_access_list_files",
|
||||
"file_access_search_files",
|
||||
):
|
||||
assert _tool_by_name(tools, name).approval_mode == "never_require"
|
||||
|
||||
|
||||
async def test_file_access_provider_delete_approval_opt_out(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""``require_delete_approval=False`` should drop delete to ``never_require``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore(), require_delete_approval=False)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
|
||||
delete_file = _tool_by_name(options["tools"], "file_access_delete_file") # type: ignore[arg-type]
|
||||
assert delete_file.approval_mode == "never_require"
|
||||
|
||||
|
||||
async def test_file_access_provider_tools_round_trip_files(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""The provider's tools should drive save/read/list/search/delete flows on an ``InMemoryAgentFileStore``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = InMemoryAgentFileStore()
|
||||
provider = FileAccessProvider(store=store)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
save_file = _tool_by_name(tools, "file_access_save_file")
|
||||
read_file = _tool_by_name(tools, "file_access_read_file")
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
list_files = _tool_by_name(tools, "file_access_list_files")
|
||||
search_files = _tool_by_name(tools, "file_access_search_files")
|
||||
|
||||
saved = await save_file.invoke(arguments={"file_name": "plan.md", "content": "step 1\nERROR step 2"})
|
||||
assert "plan.md" in saved[0].text and "saved" in saved[0].text
|
||||
|
||||
# Default overwrite=False should refuse the second save.
|
||||
refused = await save_file.invoke(arguments={"file_name": "plan.md", "content": "stomp"})
|
||||
assert "already exists" in refused[0].text
|
||||
|
||||
# overwrite=True should succeed.
|
||||
overwritten = await save_file.invoke(
|
||||
arguments={"file_name": "plan.md", "content": "stomp\nERROR replaced", "overwrite": True}
|
||||
)
|
||||
assert "saved" in overwritten[0].text
|
||||
|
||||
read_back = await read_file.invoke(arguments={"file_name": "plan.md"})
|
||||
assert read_back[0].text == "stomp\nERROR replaced"
|
||||
|
||||
listed = await list_files.invoke()
|
||||
assert json.loads(listed[0].text) == ["plan.md"]
|
||||
|
||||
# The list tool should accept an optional directory argument so agents can
|
||||
# enumerate nested folders (not only the root).
|
||||
await save_file.invoke(arguments={"file_name": "reports/2024.md", "content": "annual"})
|
||||
listed_nested = await list_files.invoke(arguments={"directory": "reports"})
|
||||
assert json.loads(listed_nested[0].text) == ["2024.md"]
|
||||
# Blank / whitespace directory should fall back to the root listing.
|
||||
listed_blank = await list_files.invoke(arguments={"directory": " "})
|
||||
assert sorted(json.loads(listed_blank[0].text)) == ["plan.md"]
|
||||
|
||||
missing = await read_file.invoke(arguments={"file_name": "missing.md"})
|
||||
assert "not found" in missing[0].text
|
||||
|
||||
search_payload = await search_files.invoke(arguments={"regex_pattern": "error", "file_pattern": "*.md"})
|
||||
parsed = json.loads(search_payload[0].text)
|
||||
assert parsed[0]["file_name"] == "plan.md"
|
||||
assert parsed[0]["matching_lines"][0]["line"] == "ERROR replaced"
|
||||
|
||||
# The search tool should likewise accept an optional directory argument so
|
||||
# agents can scope a search to a subfolder.
|
||||
await save_file.invoke(arguments={"file_name": "reports/issues.md", "content": "ERROR nested"})
|
||||
scoped = await search_files.invoke(
|
||||
arguments={"regex_pattern": "error", "file_pattern": "*.md", "directory": "reports"}
|
||||
)
|
||||
scoped_parsed = json.loads(scoped[0].text)
|
||||
assert [entry["file_name"] for entry in scoped_parsed] == ["issues.md"]
|
||||
|
||||
deleted = await delete_file.invoke(arguments={"file_name": "plan.md"})
|
||||
assert "deleted" in deleted[0].text
|
||||
|
||||
missing_delete = await delete_file.invoke(arguments={"file_name": "plan.md"})
|
||||
assert "not found" in missing_delete[0].text
|
||||
|
||||
|
||||
async def test_file_access_provider_accepts_custom_instructions() -> None:
|
||||
"""Custom instructions should override the default banner."""
|
||||
store = InMemoryAgentFileStore()
|
||||
provider = FileAccessProvider(store=store, instructions="custom-banner")
|
||||
assert provider.instructions == "custom-banner"
|
||||
assert provider.source_id == DEFAULT_FILE_ACCESS_SOURCE_ID
|
||||
|
||||
|
||||
async def test_in_memory_store_write_file_raises_when_exists_and_no_overwrite() -> None:
|
||||
"""The atomic exclusive-create path should raise ``FileExistsError`` under the lock."""
|
||||
store = InMemoryAgentFileStore()
|
||||
await store.write_file("plan.md", "v1")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
await store.write_file("plan.md", "v2", overwrite=False)
|
||||
|
||||
# The original content is preserved.
|
||||
assert await store.read_file("plan.md") == "v1"
|
||||
|
||||
# Default ``overwrite=True`` still replaces.
|
||||
await store.write_file("plan.md", "v3")
|
||||
assert await store.read_file("plan.md") == "v3"
|
||||
|
||||
|
||||
async def test_filesystem_store_write_file_raises_when_exists_and_no_overwrite(tmp_path: Path) -> None:
|
||||
"""The filesystem store should use exclusive-create semantics when ``overwrite=False``."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("plan.md", "v1")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
await store.write_file("plan.md", "v2", overwrite=False)
|
||||
|
||||
assert (tmp_path / "plan.md").read_text(encoding="utf-8") == "v1"
|
||||
|
||||
await store.write_file("plan.md", "v3", overwrite=True)
|
||||
assert (tmp_path / "plan.md").read_text(encoding="utf-8") == "v3"
|
||||
|
||||
|
||||
async def test_run_search_with_timeout_raises_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A scan that exceeds the timeout should surface a clean ``ValueError``."""
|
||||
monkeypatch.setattr(_file_access_module, "_SEARCH_TIMEOUT_SECONDS", 0.01)
|
||||
|
||||
def slow() -> list[FileSearchResult]:
|
||||
time.sleep(0.5)
|
||||
return []
|
||||
|
||||
with pytest.raises(ValueError, match="did not complete"):
|
||||
await _run_search_with_timeout(slow)
|
||||
|
||||
|
||||
async def test_filesystem_store_symlink_probe_fails_closed_on_oserror(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If ``Path.is_symlink`` raises during the probe, the operation must be refused."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("ok.txt", "content")
|
||||
|
||||
def boom(self: Path) -> bool:
|
||||
raise PermissionError("access denied")
|
||||
|
||||
monkeypatch.setattr(Path, "is_symlink", boom)
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link or reparse point"):
|
||||
await store.read_file("ok.txt")
|
||||
|
||||
|
||||
def test_file_access_harness_classes_are_marked_experimental() -> None:
|
||||
"""File-access harness public classes should expose HARNESS experimental metadata."""
|
||||
assert AgentFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert InMemoryAgentFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert FileSystemAgentFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert FileSearchMatch.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert FileSearchResult.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert FileAccessProvider.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert ".. warning:: Experimental" in (FileAccessProvider.__doc__ or "")
|
||||
|
||||
|
||||
async def test_in_memory_store_preserves_original_case_on_list_and_search() -> None:
|
||||
"""``list_files`` / ``search_files`` should return original-case names, not lowercased keys.
|
||||
|
||||
Matches :class:`FileSystemAgentFileStore` on case-preserving filesystems so
|
||||
tests written against the in-memory backend cannot encode a contract that
|
||||
will diverge in production.
|
||||
"""
|
||||
store = InMemoryAgentFileStore()
|
||||
await store.write_file("Plan.MD", "ERROR happens here\n")
|
||||
await store.write_file("Reports/Q1.MD", "alpha")
|
||||
|
||||
# list_files keeps the original case
|
||||
assert sorted(await store.list_files()) == ["Plan.MD"]
|
||||
assert sorted(await store.list_files("Reports")) == ["Q1.MD"]
|
||||
|
||||
# case-insensitive directory lookup still works
|
||||
assert sorted(await store.list_files("reports")) == ["Q1.MD"]
|
||||
|
||||
# search_files emits the original-case file name in FileSearchResult
|
||||
results = await store.search_files("", "error", "*.MD")
|
||||
assert [r.file_name for r in results] == ["Plan.MD"]
|
||||
|
||||
# read_file remains case-insensitive
|
||||
assert await store.read_file("plan.md") == "ERROR happens here\n"
|
||||
|
||||
|
||||
async def test_filesystem_store_read_file_raises_value_error_on_non_utf8(tmp_path: Path) -> None:
|
||||
"""Binary / non-UTF-8 files should raise a clean ``ValueError`` rather than ``UnicodeDecodeError``.
|
||||
|
||||
The tool-layer wrapper relies on this contract to convert the failure into
|
||||
a recoverable string response for the agent.
|
||||
"""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
(tmp_path / "blob.bin").write_bytes(b"\x80\x81\x82\x83")
|
||||
|
||||
with pytest.raises(ValueError, match="not UTF-8 text"):
|
||||
await store.read_file("blob.bin")
|
||||
|
||||
|
||||
async def test_filesystem_store_search_logs_skipped_non_utf8_files(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""``search_files`` skips non-UTF-8 files but logs per-file and a summary so operators have signal."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write_file("notes.md", "ERROR happens here")
|
||||
(tmp_path / "blob.bin").write_bytes(b"\x80\x81\x82\x83")
|
||||
|
||||
with caplog.at_level("INFO", logger="agent_framework._harness._file_access"):
|
||||
results = await store.search_files("", "error")
|
||||
|
||||
assert [r.file_name for r in results] == ["notes.md"]
|
||||
assert any("Skipping non-UTF-8 file during search" in rec.message for rec in caplog.records)
|
||||
assert any("skipped 1 non-UTF-8 file" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
async def test_file_access_tool_wrappers_surface_value_error_as_message(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Recoverable failures (bad path, oversized regex, non-UTF-8 read) should be returned as strings.
|
||||
|
||||
Without these wrappers the model sees a raw stack trace for "you used ``..``"
|
||||
but a polite message for "the file already exists", which is the opposite
|
||||
of what is recoverable.
|
||||
"""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = InMemoryAgentFileStore()
|
||||
provider = FileAccessProvider(store=store)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
save_file = _tool_by_name(tools, "file_access_save_file")
|
||||
read_file = _tool_by_name(tools, "file_access_read_file")
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
list_files = _tool_by_name(tools, "file_access_list_files")
|
||||
search_files = _tool_by_name(tools, "file_access_search_files")
|
||||
|
||||
# Path-traversal attempts on each tool should return a clean string, not raise.
|
||||
saved = await save_file.invoke(arguments={"file_name": "../escape.txt", "content": "x"})
|
||||
assert "Could not save" in saved[0].text and "escape" in saved[0].text.lower()
|
||||
read = await read_file.invoke(arguments={"file_name": "../escape.txt"})
|
||||
assert "Could not read" in read[0].text
|
||||
deleted = await delete_file.invoke(arguments={"file_name": "../escape.txt"})
|
||||
assert "Could not delete" in deleted[0].text
|
||||
listed = await list_files.invoke(arguments={"directory": "../escape"})
|
||||
assert "Could not list" in listed[0].text
|
||||
|
||||
# Regex length cap should also be returned to the model as text.
|
||||
too_long = "a" * 1024
|
||||
searched = await search_files.invoke(arguments={"regex_pattern": too_long})
|
||||
assert "Could not search files" in searched[0].text
|
||||
|
||||
|
||||
async def test_file_access_tool_read_file_wrapper_surfaces_non_utf8(
|
||||
tmp_path: Path, chat_client_base: SupportsChatGetResponse
|
||||
) -> None:
|
||||
"""The read-file tool wrapper should convert a non-UTF-8 ``ValueError`` into a readable string."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
(tmp_path / "blob.bin").write_bytes(b"\x80\x81\x82\x83")
|
||||
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=store)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["read it"])],
|
||||
)
|
||||
read_file = _tool_by_name(options["tools"], "file_access_read_file")
|
||||
response = await read_file.invoke(arguments={"file_name": "blob.bin"})
|
||||
assert "Could not read" in response[0].text and "UTF-8" in response[0].text
|
||||
|
||||
|
||||
_NEEDS_SYMLINK = "Symbolic links are not supported in this environment"
|
||||
|
||||
|
||||
async def test_filesystem_store_rejects_symlink_on_delete_search_and_list(tmp_path: Path) -> None:
|
||||
"""The same symlink probe must front delete/search/list, not just read/write."""
|
||||
target = tmp_path / "outside.txt"
|
||||
target.write_text("outside", encoding="utf-8")
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
link = root / "link.txt"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"{_NEEDS_SYMLINK}: {exc!r}")
|
||||
|
||||
store = FileSystemAgentFileStore(root)
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.delete_file("link.txt")
|
||||
|
||||
# search_files of the root never touches the symlink leaf directly, but
|
||||
# search_files of a symlinked *directory* path must be rejected by the
|
||||
# safe-directory resolver.
|
||||
dir_link = root / "alias_dir"
|
||||
other_dir = tmp_path / "outside_dir"
|
||||
other_dir.mkdir()
|
||||
try:
|
||||
dir_link.symlink_to(other_dir)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"{_NEEDS_SYMLINK}: {exc!r}")
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.search_files("alias_dir", "anything")
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
await store.list_files("alias_dir")
|
||||
|
||||
|
||||
async def test_filesystem_store_rejects_symlinked_intermediate_directory(tmp_path: Path) -> None:
|
||||
"""A symlink used as a non-leaf path segment must still be rejected.
|
||||
|
||||
The classic escape vector is ``root/aliased_dir/file.txt`` where
|
||||
``aliased_dir`` is a symlink to somewhere outside the root. The
|
||||
``_throw_if_contains_symlink`` walk must check every segment, not only
|
||||
the leaf.
|
||||
"""
|
||||
outside = tmp_path / "outside_dir"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("payload", encoding="utf-8")
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
link = root / "aliased_dir"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"{_NEEDS_SYMLINK}: {exc!r}")
|
||||
|
||||
store = FileSystemAgentFileStore(root)
|
||||
|
||||
for op in ("read", "write", "delete"):
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
if op == "read":
|
||||
await store.read_file("aliased_dir/secret.txt")
|
||||
elif op == "write":
|
||||
await store.write_file("aliased_dir/secret.txt", "stomp")
|
||||
else:
|
||||
await store.delete_file("aliased_dir/secret.txt")
|
||||
@@ -1161,6 +1161,43 @@ async def test_local_mcp_server_function_execution_error():
|
||||
await func.invoke(param="test_value")
|
||||
|
||||
|
||||
async def test_mcp_tool_reconnects_after_session_terminated_error():
|
||||
"""Session termination errors should reconnect once and retry the tool call."""
|
||||
|
||||
class TestServer(MCPTool):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.connect_count = 0
|
||||
self.sessions: list[Any] = []
|
||||
|
||||
async def connect(self, *, reset: bool = False) -> None:
|
||||
self.connect_count += 1
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.sessions.append(self.session)
|
||||
if self.connect_count == 1:
|
||||
self.session.call_tool = AsyncMock(
|
||||
side_effect=McpError(types.ErrorData(code=-32000, message="Session terminated"))
|
||||
)
|
||||
else:
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="recovered")])
|
||||
)
|
||||
self.is_connected = True
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
await server.connect()
|
||||
|
||||
result = await server.call_tool("test_tool", param="test_value")
|
||||
|
||||
assert _mcp_result_to_text(result) == "recovered"
|
||||
assert server.connect_count == 2
|
||||
assert server.sessions[0].call_tool.await_count == 1
|
||||
assert server.sessions[1].call_tool.await_count == 1
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_raises_on_is_error():
|
||||
"""Test that call_tool raises ToolExecutionException when MCP returns isError=True."""
|
||||
|
||||
@@ -3260,6 +3297,68 @@ async def test_load_prompts_pagination_with_duplicates():
|
||||
assert [f.name for f in tool._functions] == ["prompt_1", "prompt_2"]
|
||||
|
||||
|
||||
async def test_load_tools_concurrent_reload_does_not_duplicate_tools_and_preserves_meta():
|
||||
"""Concurrent tool reloads should not duplicate functions or lose tools/list metadata."""
|
||||
tool = MCPTool(name="test_tool")
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="tool_1",
|
||||
description="First tool",
|
||||
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
|
||||
_meta={"echo": "tool_1"},
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
|
||||
async def mock_list_tools(params: Any = None) -> Any:
|
||||
assert params is None
|
||||
await asyncio.sleep(0)
|
||||
return page
|
||||
|
||||
mock_session.list_tools = AsyncMock(side_effect=mock_list_tools)
|
||||
|
||||
await asyncio.wait_for(asyncio.gather(tool.load_tools(), tool.load_tools()), timeout=1)
|
||||
|
||||
assert mock_session.list_tools.call_count == 2
|
||||
assert [f.name for f in tool._functions] == ["tool_1"]
|
||||
assert tool._tool_call_meta_by_name == {"tool_1": {"echo": "tool_1"}}
|
||||
|
||||
|
||||
async def test_load_prompts_concurrent_reload_does_not_duplicate_prompts():
|
||||
"""Concurrent prompt reloads should not duplicate functions."""
|
||||
tool = MCPTool(name="test_tool")
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_prompts_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.prompts = [
|
||||
types.Prompt(
|
||||
name="prompt_1",
|
||||
description="First prompt",
|
||||
arguments=[types.PromptArgument(name="arg1", description="Arg 1", required=True)],
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
|
||||
async def mock_list_prompts(params: Any = None) -> Any:
|
||||
assert params is None
|
||||
await asyncio.sleep(0)
|
||||
return page
|
||||
|
||||
mock_session.list_prompts = AsyncMock(side_effect=mock_list_prompts)
|
||||
|
||||
await asyncio.wait_for(asyncio.gather(tool.load_prompts(), tool.load_prompts()), timeout=1)
|
||||
|
||||
assert mock_session.list_prompts.call_count == 2
|
||||
assert [f.name for f in tool._functions] == ["prompt_1"]
|
||||
|
||||
|
||||
async def test_load_tools_pagination_exception_handling():
|
||||
"""Test that load_tools handles exceptions during pagination gracefully."""
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -3891,6 +3990,31 @@ async def test_mcp_tool_safe_close_handles_cancelled_error():
|
||||
mock_exit_stack.aclose.assert_called_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_safe_close_handles_cleanup_exception_group():
|
||||
"""Cleanup task groups should not hide the original connect failure."""
|
||||
import builtins
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
exception_group_type = getattr(builtins, "ExceptionGroup", None)
|
||||
if exception_group_type is None:
|
||||
pytest.skip("ExceptionGroup is not available on this Python version")
|
||||
|
||||
tool = MCPStreamableHTTPTool(
|
||||
name="test",
|
||||
url="http://example.com/mcp",
|
||||
load_tools=False,
|
||||
load_prompts=False,
|
||||
)
|
||||
|
||||
mock_exit_stack = AsyncMock(spec=AsyncExitStack)
|
||||
mock_exit_stack.aclose = AsyncMock(side_effect=exception_group_type("cleanup failed", [RuntimeError("reader")]))
|
||||
tool._exit_stack = mock_exit_stack
|
||||
|
||||
await tool._safe_close_exit_stack()
|
||||
|
||||
mock_exit_stack.aclose.assert_called_once()
|
||||
|
||||
|
||||
async def test_connect_sets_logging_level_when_logger_level_is_set():
|
||||
"""Test that connect() sets the MCP server logging level when the logger level is not NOTSET."""
|
||||
|
||||
@@ -4389,6 +4513,52 @@ async def test_mcp_tool_call_tool_forwards_tool_list_meta():
|
||||
assert server.session.call_tool.call_args.kwargs["meta"] == tool_meta
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_user_meta_merges_with_tool_list_meta():
|
||||
"""User-provided _meta should be sent as MCP request metadata, not tool arguments."""
|
||||
from opentelemetry import trace
|
||||
|
||||
tool_meta = {"from_tool": "tool-value", "shared": "tool-value"}
|
||||
user_meta = {"from_user": "user-value", "shared": "user-value"}
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self) -> None:
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
|
||||
_meta=tool_meta,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
|
||||
with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)):
|
||||
await server.call_tool("test_tool", param="test_value", _meta=user_meta)
|
||||
|
||||
call_kwargs = server.session.call_tool.call_args.kwargs
|
||||
assert call_kwargs["arguments"] == {"param": "test_value"}
|
||||
assert call_kwargs["meta"] == {
|
||||
"from_tool": "tool-value",
|
||||
"from_user": "user-value",
|
||||
"shared": "user-value",
|
||||
}
|
||||
assert user_meta == {"from_user": "user-value", "shared": "user-value"}
|
||||
|
||||
|
||||
async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client():
|
||||
"""Test that calling get_mcp_client multiple times does not accumulate duplicate hooks."""
|
||||
tool = MCPStreamableHTTPTool(
|
||||
@@ -4641,6 +4811,42 @@ async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook():
|
||||
await tool._httpx_client.aclose()
|
||||
|
||||
|
||||
async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redirect():
|
||||
"""The request hook must not re-add caller headers after a cross-origin redirect."""
|
||||
import httpx
|
||||
|
||||
from agent_framework._mcp import _mcp_call_headers
|
||||
|
||||
tool = MCPStreamableHTTPTool(
|
||||
name="test",
|
||||
url="http://example.com/mcp",
|
||||
header_provider=lambda kw: {"Authorization": f"Bearer {kw.get('token', '')}"},
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("agent_framework._mcp.streamable_http_client"):
|
||||
tool.get_mcp_client()
|
||||
|
||||
assert tool._httpx_client is not None
|
||||
hooks = tool._httpx_client.event_hooks.get("request", [])
|
||||
assert len(hooks) == 1
|
||||
|
||||
token = _mcp_call_headers.set({"Authorization": "Bearer secret"})
|
||||
try:
|
||||
same_origin = httpx.Request("POST", "http://example.com/redirected")
|
||||
await hooks[0](same_origin)
|
||||
assert same_origin.headers.get("Authorization") == "Bearer secret"
|
||||
|
||||
cross_origin = httpx.Request("POST", "http://attacker.example/capture")
|
||||
await hooks[0](cross_origin)
|
||||
assert "Authorization" not in cross_origin.headers
|
||||
finally:
|
||||
_mcp_call_headers.reset(token)
|
||||
finally:
|
||||
if getattr(tool, "_httpx_client", None) is not None:
|
||||
await tool._httpx_client.aclose()
|
||||
|
||||
|
||||
async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client():
|
||||
"""Test that header_provider works when the user provides their own httpx client."""
|
||||
import httpx
|
||||
|
||||
@@ -3449,6 +3449,140 @@ def test_capture_response_with_error_type(span_exporter: InMemorySpanExporter):
|
||||
assert spans[0].attributes.get(OtelAttr.ERROR_TYPE) == "ValueError"
|
||||
|
||||
|
||||
def test_backfill_request_model_when_unknown(span_exporter: InMemorySpanExporter):
|
||||
"""_backfill_request_model updates the span name and REQUEST_MODEL attribute when unknown."""
|
||||
from agent_framework.observability import OtelAttr, get_tracer
|
||||
|
||||
span_exporter.clear()
|
||||
tracer = get_tracer()
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
OtelAttr.OPERATION: "chat",
|
||||
OtelAttr.REQUEST_MODEL: "unknown",
|
||||
OtelAttr.RESPONSE_MODEL: "gpt-4o-mini",
|
||||
}
|
||||
|
||||
with tracer.start_as_current_span("chat unknown") as span:
|
||||
ChatTelemetryLayer._backfill_request_model(span, attrs)
|
||||
|
||||
assert attrs[OtelAttr.REQUEST_MODEL] == "gpt-4o-mini"
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].name == "chat gpt-4o-mini"
|
||||
|
||||
|
||||
def test_backfill_request_model_noop_when_request_model_known(span_exporter: InMemorySpanExporter):
|
||||
"""_backfill_request_model leaves a known REQUEST_MODEL and span name untouched."""
|
||||
from agent_framework.observability import OtelAttr, get_tracer
|
||||
|
||||
span_exporter.clear()
|
||||
tracer = get_tracer()
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
OtelAttr.OPERATION: "chat",
|
||||
OtelAttr.REQUEST_MODEL: "gpt-4o",
|
||||
OtelAttr.RESPONSE_MODEL: "gpt-4o-mini",
|
||||
}
|
||||
|
||||
with tracer.start_as_current_span("chat gpt-4o") as span:
|
||||
ChatTelemetryLayer._backfill_request_model(span, attrs)
|
||||
|
||||
assert attrs[OtelAttr.REQUEST_MODEL] == "gpt-4o"
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].name == "chat gpt-4o"
|
||||
|
||||
|
||||
def test_backfill_request_model_noop_when_response_model_missing(span_exporter: InMemorySpanExporter):
|
||||
"""_backfill_request_model is a no-op when no RESPONSE_MODEL is available."""
|
||||
from agent_framework.observability import OtelAttr, get_tracer
|
||||
|
||||
span_exporter.clear()
|
||||
tracer = get_tracer()
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
OtelAttr.OPERATION: "chat",
|
||||
OtelAttr.REQUEST_MODEL: "unknown",
|
||||
}
|
||||
|
||||
with tracer.start_as_current_span("chat unknown") as span:
|
||||
ChatTelemetryLayer._backfill_request_model(span, attrs)
|
||||
|
||||
assert attrs[OtelAttr.REQUEST_MODEL] == "unknown"
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].name == "chat unknown"
|
||||
|
||||
|
||||
async def test_chat_client_backfills_request_model_from_response(span_exporter: InMemorySpanExporter):
|
||||
"""Non-streaming chat: when REQUEST_MODEL is unknown, the response model backfills it."""
|
||||
|
||||
class BackfillingChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message("assistant", ["Test response"])],
|
||||
model="resolved-model",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
client = BackfillingChatClient()
|
||||
span_exporter.clear()
|
||||
# Note: no "model" in options, so REQUEST_MODEL starts as "unknown".
|
||||
await client.get_response(messages=[Message(role="user", contents=["Hi"])], options={})
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "chat resolved-model"
|
||||
assert span.attributes[OtelAttr.REQUEST_MODEL] == "resolved-model"
|
||||
assert span.attributes[OtelAttr.RESPONSE_MODEL] == "resolved-model"
|
||||
|
||||
|
||||
async def test_chat_client_streaming_backfills_request_model_from_response(
|
||||
span_exporter: InMemorySpanExporter,
|
||||
):
|
||||
"""Streaming chat: when REQUEST_MODEL is unknown, the response model backfills it."""
|
||||
|
||||
class BackfillingStreamingChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant")
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(" world")], role="assistant", finish_reason="stop")
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
response = ChatResponse.from_updates(updates)
|
||||
response.model = "resolved-stream-model"
|
||||
return response
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
client = BackfillingStreamingChatClient()
|
||||
span_exporter.clear()
|
||||
stream = client.get_response(stream=True, messages=[Message(role="user", contents=["Hi"])], options={})
|
||||
async for _ in stream:
|
||||
pass
|
||||
await stream.get_final_response()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "chat resolved-stream-model"
|
||||
assert span.attributes[OtelAttr.REQUEST_MODEL] == "resolved-stream-model"
|
||||
assert span.attributes[OtelAttr.RESPONSE_MODEL] == "resolved-stream-model"
|
||||
|
||||
|
||||
def test_configure_otel_providers_with_env_file_path(monkeypatch, tmp_path):
|
||||
"""Test configure_otel_providers with env_file_path creates new settings."""
|
||||
import importlib
|
||||
|
||||
@@ -307,6 +307,63 @@ class TestHistoryProviderBase:
|
||||
assert provider.stored[0].text == "hello"
|
||||
assert provider.stored[1].text == "hi"
|
||||
|
||||
async def test_after_run_stores_coalesced_code_interpreter_chunks(self) -> None:
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content
|
||||
|
||||
provider = ConcreteHistoryProvider("mem", store_inputs=False)
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_code_interpreter_tool_result(
|
||||
call_id="ci_123",
|
||||
outputs=[],
|
||||
)
|
||||
],
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_code_interpreter_tool_call(
|
||||
call_id="ci_123",
|
||||
inputs=[Content.from_text(text="import")],
|
||||
additional_properties={"sequence_number": 1},
|
||||
)
|
||||
],
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_code_interpreter_tool_call(
|
||||
call_id="ci_123",
|
||||
inputs=[Content.from_text(text=" pandas")],
|
||||
additional_properties={"sequence_number": 2},
|
||||
)
|
||||
],
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_code_interpreter_tool_call(
|
||||
call_id="ci_123",
|
||||
inputs=[Content.from_text(text="import pandas as pd")],
|
||||
additional_properties={"sequence_number": 3},
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
ctx = SessionContext(session_id="s1", input_messages=[Message(role="user", contents=["make a sheet"])])
|
||||
ctx._response = AgentResponse.from_updates(updates)
|
||||
|
||||
await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type]
|
||||
|
||||
assert len(provider.stored) == 1
|
||||
stored_contents = provider.stored[0].contents
|
||||
calls = [content for content in stored_contents if content.type == "code_interpreter_tool_call"]
|
||||
results = [content for content in stored_contents if content.type == "code_interpreter_tool_result"]
|
||||
assert len(calls) == 1
|
||||
assert len(results) == 1
|
||||
assert calls[0].inputs is not None
|
||||
assert len(calls[0].inputs) == 1
|
||||
assert calls[0].inputs[0].text == "import pandas as pd"
|
||||
|
||||
async def test_after_run_skips_inputs_when_disabled(self) -> None:
|
||||
from agent_framework import AgentResponse
|
||||
|
||||
|
||||
@@ -4086,8 +4086,8 @@ class TestClassSkill:
|
||||
|
||||
async def test_content_is_cached(self) -> None:
|
||||
skill = _MinimalClassSkill()
|
||||
content1 = (await skill.get_content())
|
||||
content2 = (await skill.get_content())
|
||||
content1 = await skill.get_content()
|
||||
content2 = await skill.get_content()
|
||||
assert content1 is content2
|
||||
|
||||
def test_resources_are_lazy_cached(self) -> None:
|
||||
@@ -5587,8 +5587,8 @@ class TestInlineSkillContentCaching:
|
||||
async def test_content_cached_after_first_access(self) -> None:
|
||||
"""InlineSkill.content returns the same object on subsequent accesses."""
|
||||
skill = InlineSkill(frontmatter=SkillFrontmatter(name="test-skill", description="Test"), instructions="Body")
|
||||
first = (await skill.get_content())
|
||||
second = (await skill.get_content())
|
||||
first = await skill.get_content()
|
||||
second = await skill.get_content()
|
||||
assert first is second # Same object (cached)
|
||||
assert "<name>test-skill</name>" in first
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@ Please install this package via pip:
|
||||
pip install agent-framework-declarative --pre
|
||||
```
|
||||
|
||||
## Release stage
|
||||
|
||||
This package ships at two different stability levels:
|
||||
|
||||
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
||||
`_workflows` surface) are at **release-candidate** stability and may receive only
|
||||
minor refinements before GA.
|
||||
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
||||
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
||||
**experimental** and may change or be removed in future versions without notice.
|
||||
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
||||
|
||||
## Declarative features
|
||||
|
||||
The declarative packages provides support for building agents based on a declarative yaml specification.
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative specification support for Microsoft Agent Framework.
|
||||
|
||||
Release stage:
|
||||
|
||||
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
|
||||
etc.) is at release-candidate stability.
|
||||
* The declarative-agents surface (``AgentFactory`` and the YAML agent
|
||||
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
|
||||
``ProviderTypeMapping``) is *experimental* and may change or be removed in
|
||||
future versions without notice. Using these symbols emits an
|
||||
``ExperimentalWarning`` on first use.
|
||||
"""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
|
||||
|
||||
@@ -15,6 +15,10 @@ from agent_framework import (
|
||||
from agent_framework import (
|
||||
FunctionTool as AFFunctionTool,
|
||||
)
|
||||
from agent_framework._feature_stage import ( # type: ignore[reportPrivateUsage]
|
||||
ExperimentalFeature,
|
||||
experimental,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -43,6 +47,7 @@ else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderTypeMapping(TypedDict, total=True):
|
||||
package: str
|
||||
name: str
|
||||
@@ -118,18 +123,21 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
|
||||
}
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class DeclarativeLoaderError(AgentException):
|
||||
"""Exception raised for errors in the declarative loader."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderLookupError(DeclarativeLoaderError):
|
||||
"""Exception raised for errors in provider type lookup."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class AgentFactory:
|
||||
"""Factory for creating Agent instances from declarative YAML definitions.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260528"
|
||||
version = "1.0.0rc1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"types-PyYaml==6.0.12.20250915"
|
||||
"types-PyYaml==6.0.12.20260518"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -49,7 +49,8 @@ addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*",
|
||||
"ignore::agent_framework._feature_stage.ExperimentalWarning",
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
|
||||
@@ -375,13 +375,15 @@ class DevServer:
|
||||
logger.info("Starting Agent Framework Server")
|
||||
await self._ensure_executor()
|
||||
await self._ensure_openai_executor() # Initialize OpenAI executor
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down Agent Framework Server")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down Agent Framework Server")
|
||||
|
||||
# Cleanup entity resources (e.g., close credentials, clients)
|
||||
if self.executor:
|
||||
await self._cleanup_entities()
|
||||
# Cleanup entity resources (e.g., close credentials, clients)
|
||||
if self.executor:
|
||||
await self._cleanup_entities()
|
||||
|
||||
app = FastAPI(
|
||||
title="Agent Framework Server",
|
||||
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"types-python-dateutil==2.9.0.20260402",
|
||||
"types-python-dateutil==2.9.0.20260518",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -57,8 +57,6 @@ if TYPE_CHECKING:
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentRunInputs,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ContextProvider,
|
||||
MiddlewareTypes,
|
||||
ToolTypes,
|
||||
)
|
||||
@@ -507,10 +505,10 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryAgentClient
|
||||
from agent_framework.foundry import FoundryAgent
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
client = FoundryAgentClient(
|
||||
client = FoundryAgent(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
agent_name="my-prompt-agent",
|
||||
agent_version="1",
|
||||
|
||||
@@ -57,19 +57,19 @@ math = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"uv==0.11.6",
|
||||
"ruff==0.15.8",
|
||||
"uv==0.11.17",
|
||||
"ruff==0.15.15",
|
||||
"pytest==9.0.3",
|
||||
"mypy==1.20.0",
|
||||
"pyright==1.1.408",
|
||||
#tasks
|
||||
"poethepoet==0.42.1",
|
||||
"poethepoet==0.46.0",
|
||||
"rich>=13.7.1,<15.0.0",
|
||||
"tomli==2.4.1",
|
||||
"tomli-w==1.2.0",
|
||||
# tau2 from source (not available on PyPI)
|
||||
"tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619",
|
||||
"prek==0.3.9",
|
||||
"prek==0.4.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user