diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3bddc65eab6..824f537f139 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -7,10 +7,13 @@ version: 2
updates:
# Maintain dependencies for nuget
- package-ecosystem: "nuget"
- # Use the Dependabot-only solution in this folder so NuGet discovery does
- # not expand the full developer solution, which includes samples and times
- # out before creating update PRs.
- directory: "dotnet/dependabot/"
+ directory: "dotnet/"
+ # The samples tree contains most of the .NET package manifests and causes
+ # the NuGet updater to exceed Dependabot's fixed execution timeout. Keep the
+ # production source, tests, and central package versions in scope so updates
+ # can be opened again.
+ exclude-paths:
+ - "samples/**"
schedule:
interval: "cron"
cronjob: "0 8 * * 4,0" # Every Thursday(4) and Sunday(0) at 8:00 UTC
diff --git a/.github/scripts/pr_limit_moderation.js b/.github/scripts/pr_limit_moderation.js
deleted file mode 100644
index 6cb23e053ad..00000000000
--- a/.github/scripts/pr_limit_moderation.js
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-function getPullRequest(context) {
- const pullRequest = context.payload.pull_request;
- if (!pullRequest?.number || !pullRequest.user?.login) {
- throw new Error('This script must be run from a pull_request_target event.');
- }
-
- return {
- author: pullRequest.user.login,
- authorType: pullRequest.user.type,
- 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 isDependabotAuthor({ author, authorType }) {
- return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
-}
-
-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 openPullRequests = await github.paginate(github.rest.pulls.list, {
- owner,
- repo,
- state: 'open',
- per_page: 100,
- });
-
- const authorOpenPullRequestNumbers = openPullRequests
- .filter((pullRequest) => pullRequest.user?.login === author)
- .map((pullRequest) => pullRequest.number);
- const currentPrIsOpen = authorOpenPullRequestNumbers.includes(pullRequestNumber);
- const existingOpenPrCount = currentPrIsOpen
- ? authorOpenPullRequestNumbers.length - 1
- : authorOpenPullRequestNumbers.length;
-
- return existingOpenPrCount + 1;
-}
-
-async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
- const { owner, repo } = context.repo;
- const { author, authorType, labels, number } = getPullRequest(context);
-
- if (isDependabotAuthor({ author, authorType })) {
- core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
- return {
- author,
- closed: false,
- dependabotExempt: true,
- openPrCount: null,
- };
- }
-
- 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,
-};
diff --git a/.github/tests/test_devflow_fix_ci_workflow.js b/.github/tests/test_devflow_fix_ci_workflow.js
index e06529069de..0944020fec7 100644
--- a/.github/tests/test_devflow_fix_ci_workflow.js
+++ b/.github/tests/test_devflow_fix_ci_workflow.js
@@ -27,10 +27,57 @@ describe('DevFlow PR repair entrypoint', () => {
const intake = workflow.indexOf('Validate and bind the authorized request');
const reaction = workflow.indexOf('Acknowledge accepted repair command');
assert.ok(reaction > intake);
+ const authStep = workflow.slice(
+ workflow.indexOf('Get source-repository App token'),
+ workflow.indexOf('Authorize the frozen command requester'),
+ );
+ assert.match(authStep, /pull-requests-permission: write/);
+ assert.doesNotMatch(workflow, /id: reaction-auth/);
+ const reactionStep = workflow.slice(reaction, workflow.indexOf('\n repair:'));
+ assert.match(reactionStep, /github-token: \$\{\{ steps\.auth\.outputs\.token \}\}/);
+ assert.match(reactionStep, /continue-on-error: true/);
assert.match(workflow, /steps\.intake\.outputs\.mode == 'repair'/);
assert.match(workflow, /content: 'eyes'/);
});
+ it('uses the review-proven App grant and the job token for Actions reads', () => {
+ assert.doesNotMatch(workflow, /actions-permission: read/);
+ assert.ok([...workflow.matchAll(/GITHUB_TOKEN: \$\{\{ github\.token \}\}/g)].length >= 3);
+ const authorization = workflow.slice(
+ workflow.indexOf('Get source-repository App token'),
+ workflow.indexOf('Authorize the frozen command requester'),
+ );
+ assert.match(authorization, /contents-permission: read/);
+ assert.match(authorization, /issues-permission: write/);
+ assert.match(authorization, /pull-requests-permission: write/);
+
+ const repair = workflow.slice(workflow.indexOf('\n repair:'), workflow.indexOf('\n approve:'));
+ assert.match(repair, /permissions:\n contents: read\n actions: read\n checks: read/);
+ assert.doesNotMatch(repair.slice(0, repair.indexOf(' outputs:')), /issues: write/);
+ const repairRead = repair.slice(
+ repair.indexOf('Get read-only source-repository App token'),
+ repair.indexOf('Generate and verify the bounded repair'),
+ );
+ assert.match(repairRead, /contents-permission: read/);
+ assert.match(repairRead, /issues-permission: read/);
+ assert.match(repairRead, /pull-requests-permission: read/);
+ const report = repair.slice(repair.indexOf('Get issue-reporting token'));
+ assert.match(report, /pull-requests-permission: write/);
+
+ const publish = workflow.slice(workflow.indexOf('\n publish:'));
+ assert.doesNotMatch(publish.slice(0, publish.indexOf(' env:')), /issues: write/);
+ const publishRead = publish.slice(
+ publish.indexOf('Get read-only source-repository App token'),
+ publish.indexOf('Revalidate exact candidate and approval before write authority'),
+ );
+ assert.match(publishRead, /issues-permission: read/);
+ assert.match(publishRead, /pull-requests-permission: read/);
+ const publishWrite = publish.slice(publish.indexOf('Get source-repository write token'));
+ assert.match(publishWrite, /contents-permission: write/);
+ assert.match(publishWrite, /issues-permission: write/);
+ assert.match(publishWrite, /pull-requests-permission: write/);
+ });
+
it('runs private policy code and preserves the exact-diff publication gate', () => {
assert.match(workflow, /scripts\/trigger_fix_ci\.py --phase run/);
assert.match(workflow, /environment: devflow-pr-repair-publish/);
diff --git a/.github/tests/test_pr_limit_moderation.js b/.github/tests/test_pr_limit_moderation.js
deleted file mode 100644
index 5f0c8c865ab..00000000000
--- a/.github/tests/test_pr_limit_moderation.js
+++ /dev/null
@@ -1,341 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-/**
- * Tests for pr_limit_moderation.js.
- *
- * Run with: node --test .github/tests/test_pr_limit_moderation.js
- */
-
-const { describe, it } = require('node:test');
-const assert = require('node:assert/strict');
-
-const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
-
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
- return {
- repo: {
- owner: 'microsoft',
- repo: 'agent-framework',
- },
- payload: {
- pull_request: {
- number,
- labels: labels.map((name) => ({ name })),
- user: {
- login: author,
- type: authorType,
- },
- },
- },
- };
-}
-
-function createCore() {
- const messages = [];
- return {
- messages,
- info(message) {
- messages.push(message);
- },
- };
-}
-
-function createGithub({
- itemNumbers,
- labelExists = true,
- pullRequests = createPullRequestPage({ numbers: itemNumbers }),
-}) {
- const calls = [];
-
- return {
- calls,
- async paginate(method, params) {
- calls.push({ api: 'paginate', method, params });
- return pullRequests;
- },
- rest: {
- 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 list(params) {
- calls.push({ api: 'pulls.list', params });
- return { data: pullRequests };
- },
- async update(params) {
- calls.push({ api: 'pulls.update', params });
- return { data: { state: params.state } };
- },
- },
- },
- };
-}
-
-function createPullRequestPage({ author = 'community-user', numbers }) {
- return numbers.map((number) => ({
- number,
- user: {
- login: author,
- },
- }));
-}
-
-
-// ---------------------------------------------------------------------------
-// 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({
- 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),
- ['paginate'],
- );
- });
-
- it('counts the new PR when the pull list includes it', async () => {
- const github = createGithub({
- itemNumbers: [123, 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),
- [
- 'paginate',
- 'issues.getLabel',
- 'issues.addLabels',
- 'issues.createComment',
- 'pulls.update',
- ],
- );
- });
-
- it('counts the current PR on top of existing open PRs', async () => {
- const github = createGithub({
- itemNumbers: [123, ...Array.from({ length: 24 }, (_, index) => index + 1)],
- pullRequests: createPullRequestPage({
- numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
- }),
- });
-
- 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, 26);
- const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
- assert.match(comment, /This PR would put you at 26 open pull requests/);
- });
-
- it('creates the label when it does not already exist', async () => {
- const github = createGithub({
- 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),
- [
- 'paginate',
- '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({
- 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),
- [
- 'paginate',
- 'issues.getLabel',
- 'issues.createLabel',
- 'issues.addLabels',
- 'issues.createComment',
- 'pulls.update',
- ],
- );
- });
-
- it('uses a diplomatic close message with the configured limit', async () => {
- const github = createGithub({
- itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
- pullRequests: createPullRequestPage({
- author: 'octo-contributor',
- numbers: [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({
- 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 close Dependabot PRs', async () => {
- const github = createGithub({
- itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
- pullRequests: createPullRequestPage({
- author: 'dependabot[bot]',
- numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
- }),
- });
-
- const result = await enforcePrLimit({
- github,
- context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
- core: createCore(),
- exemptLabelName: 'pr-limit-exempt',
- maxOpenPrs: 10,
- labelName: 'too-many-prs',
- });
-
- assert.equal(result.closed, false);
- assert.equal(result.dependabotExempt, true);
- assert.equal(result.openPrCount, null);
- assert.deepEqual(github.calls, []);
- });
-
- it('counts the current PR when the author has more than one page of open PRs', async () => {
- const github = createGithub({
- itemNumbers: [123, ...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);
- });
-});
diff --git a/.github/workflows/devflow-fix-ci.yml b/.github/workflows/devflow-fix-ci.yml
index 4fb132d41ce..a1bddd939e2 100644
--- a/.github/workflows/devflow-fix-ci.yml
+++ b/.github/workflows/devflow-fix-ci.yml
@@ -96,9 +96,8 @@ jobs:
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
contents-permission: read
- actions-permission: read
issues-permission: write
- pull-requests-permission: read
+ pull-requests-permission: write
- name: Authorize the frozen command requester
id: team
@@ -163,6 +162,7 @@ jobs:
GATEWAY_SHA256: ${{ needs.snapshot.outputs.gateway_sha256 }}
DEVFLOW_SHA: ${{ steps.controller.outputs.sha }}
GH_TOKEN: ${{ steps.auth.outputs.token }}
+ GITHUB_TOKEN: ${{ github.token }}
DEVFLOW_PUBLISH_AUTH_SOURCE: ${{ steps.auth.outputs.source }}
DEVFLOW_PUBLISH_AUTHORIZATION: ${{ steps.auth.outputs.authorization }}
PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }}
@@ -182,6 +182,7 @@ jobs:
- name: Acknowledge accepted repair command
if: ${{ steps.intake.outputs.mode == 'repair' }}
+ continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.auth.outputs.token }}
@@ -201,7 +202,7 @@ jobs:
permissions:
contents: read
actions: read
- issues: write
+ checks: read
id-token: write
copilot-requests: write
outputs:
@@ -261,7 +262,6 @@ jobs:
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
contents-permission: read
- actions-permission: read
issues-permission: read
pull-requests-permission: read
- name: Generate and verify the bounded repair
@@ -271,6 +271,7 @@ jobs:
DEVFLOW_SHA: ${{ needs.authorize.outputs.controller_sha }}
PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ steps.read-auth.outputs.token }}
+ GITHUB_TOKEN: ${{ github.token }}
DEVFLOW_MODEL_TOKEN: ${{ github.token }}
DEVFLOW_PUBLISH_AUTH_SOURCE: ${{ steps.read-auth.outputs.source }}
DEVFLOW_PUBLISH_AUTHORIZATION: ${{ steps.read-auth.outputs.authorization }}
@@ -307,7 +308,7 @@ jobs:
repository: ${{ github.repository }}
contents-permission: read
issues-permission: write
- pull-requests-permission: read
+ pull-requests-permission: write
- name: Report exact candidate and approval instructions
if: ${{ steps.run.outputs.status == 'ready' }}
env:
@@ -378,7 +379,6 @@ jobs:
permissions:
contents: read
actions: read
- issues: write
id-token: write
env:
SNAPSHOT_SHA256: ${{ needs.authorize.outputs.snapshot_sha256 }}
@@ -386,6 +386,7 @@ jobs:
PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }}
PR_REPAIR_APPROVAL_ENVIRONMENT: ${{ needs.authorize.outputs.command == '/fix' && 'devflow-pr-repair-publish' || 'github-app-auth' }}
DEVELOPER_TEAM: ${{ secrets.DEVELOPER_TEAM }}
+ GITHUB_TOKEN: ${{ github.token }}
steps:
- name: Checkout frozen public automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -441,7 +442,6 @@ jobs:
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
contents-permission: read
- actions-permission: read
issues-permission: read
pull-requests-permission: read
- name: Revalidate exact candidate and approval before write authority
@@ -469,9 +469,8 @@ jobs:
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
repository: ${{ github.repository }}
contents-permission: write
- actions-permission: read
issues-permission: write
- pull-requests-permission: read
+ pull-requests-permission: write
- name: Publish only the revalidated candidate
env:
GH_TOKEN: ${{ steps.write-auth.outputs.token }}
diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml
deleted file mode 100644
index 12e40018612..00000000000
--- a/.github/workflows/limit-community-prs.yml
+++ /dev/null
@@ -1,122 +0,0 @@
-name: Limit community pull requests
-
-on:
- pull_request_target:
- types: [opened, reopened]
-
-permissions:
- contents: read
- id-token: write
- 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
- environment: github-app-auth
- outputs:
- is_team_member: ${{ steps.check.outputs.is_team_member }}
- steps:
- - name: Checkout scripts
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- sparse-checkout: |
- .github/actions/github-app-token
- .github/scripts
- fetch-depth: 1
- persist-credentials: false
-
- - name: Get GitHub automation token
- id: github-auth
- uses: ./.github/actions/github-app-token
- with:
- mode: ${{ vars.GH_APP_AUTH_MODE }}
- azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
- azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
- azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
- key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
- key-name: ${{ secrets.GH_APP_KEY_NAME }}
- github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
- github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
- repository: ${{ github.repository }}
- fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
-
- - name: Check PR author team membership
- id: check
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- with:
- github-token: ${{ steps.github-auth.outputs.token }}
- 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
- environment: github-app-auth
- needs: team_check
- if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
- steps:
- - name: Checkout scripts
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- sparse-checkout: |
- .github/actions/github-app-token
- .github/scripts
- fetch-depth: 1
- persist-credentials: false
-
- - name: Get GitHub automation token
- id: github-auth
- uses: ./.github/actions/github-app-token
- with:
- mode: ${{ vars.GH_APP_AUTH_MODE }}
- azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
- azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
- azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
- key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
- key-name: ${{ secrets.GH_APP_KEY_NAME }}
- github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
- github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
- repository: ${{ github.repository }}
- fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
-
- - name: Enforce open PR limit
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.github-auth.outputs.token }}
- 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,
- });
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 4ccfe254ae0..56f8b92e5b1 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -17,10 +17,11 @@ jobs:
python-tests:
name: Python Tests
runs-on: ${{ matrix.os }}
+ continue-on-error: ${{ matrix.python-version == '3.15' }}
strategy:
fail-fast: true
matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
# todo: add macos-latest when problems are resolved
os: [ubuntu-latest, windows-latest]
env:
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000000..3f312ba1bbe
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "dotnet.defaultSolution": "dotnet/agent-framework-src-and-tests.slnf"
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000000..69a854bebb3
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,16 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "process",
+ "command": "dotnet",
+ "group": "build",
+ "problemMatcher": [],
+ "label": "dotnet: build source and test solution filter",
+ "args": ["build", "agent-framework-src-and-tests.slnf"],
+ "options": {
+ "cwd": "${workspaceFolder}/dotnet"
+ }
+ }
+ ]
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 554640c23c3..f64cdbf4607 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -49,6 +49,17 @@ report should contain the following information:
Project maintainers will merge accepted code changes from contributors.
+We welcome contributions, but maintainers must prioritize their limited review time
+across issues and pull requests, so we may not be able to get to reviewing every
+contribution immediately.
+
+AI tooling has also made it easier for more people to
+submit contributions, which has increased the overall volume we receive; as a
+result, contributors should expect longer wait times for reviews.
+
+Note that your pull request may receive automated review comments from a bot; addressing or
+at least replying to those comments will help fast track the eventual human review.
+
### DOs and DON'Ts
DO's:
diff --git a/docs/decisions/0043-python-mcp-runtime-context.md b/docs/decisions/0043-python-mcp-runtime-context.md
new file mode 100644
index 00000000000..7bdbb60331a
--- /dev/null
+++ b/docs/decisions/0043-python-mcp-runtime-context.md
@@ -0,0 +1,48 @@
+---
+status: proposed
+date: 2026-09-21
+deciders: eavanvalkenburg, westey-m
+---
+
+# Preserve host runtime context through MCP invocation and approval
+
+## Context and Problem Statement
+
+Workflow client kwargs, tool runtime kwargs, model arguments, and reviewed transport headers have different
+destinations and lifetimes. Flattening them loses provenance; retaining only an operation across a delayed approval
+does not preserve the transport context under which that operation was reviewed.
+
+## Considered Options
+
+- Reverse precedence in the shared argument mapping. This protects collisions but changes tool arguments and still
+ permits model-only values to supply authentication inputs.
+- Persist evaluated headers or an unkeyed digest with the approval. This supports restart-transparent comparison but
+ exposes credentials or permits offline guessing of low-entropy header values.
+- Use executor-local ephemeral binding keys. This keeps keys out of storage, but can cause endless reapproval when
+ each resume reconstructs the executor on a different worker.
+- Separate host runtime inputs and retain keyed approval verification in trusted workflow checkpoint state.
+ This keeps credentials out of checkpoints and keys out of approval payloads, but requires protected host storage.
+
+## Decision Outcome
+
+Choose separate host inputs and workflow-local keyed bindings:
+
+- Declarative agent execution resolves both kwargs buckets per executor, including legacy checkpoint state, and
+ forwards them through their matching Agent parameters. It does not copy the outer bag into tool inputs.
+- Generated MCP functions preserve host runtime kwargs in an owner-scoped context separate from merged tool
+ arguments. HTTP header providers read only host context. The scope is reset on success, error, and cancellation.
+ Direct host calls keep their existing kwargs-based API; model precedence remains unchanged for tool arguments.
+- Declarative MCP approvals contain header names and an HMAC over the request ID and canonical evaluated headers.
+ The random binding key is retained separately in trusted host checkpoint state, never in approval payloads.
+ Header names compare case-insensitively, values exactly; no raw credentials or unkeyed digests enter checkpoints.
+- A changed or unverifiable header set produces a fresh approval for the pinned operation before dispatch. This
+ includes legacy requests, credential rotation, and missing verification state. Unchanged approvals remain valid
+ across executor reconstruction using the checkpointed key. Headerless requests retain their resume behavior.
+
+These are intentional compatibility changes: applications deriving generated-call headers from model arguments must
+move trusted values into host runtime context or a provider closure. Consumers of delayed approvals must handle
+replacement request IDs. Checkpoints already hold approval authority and must be protected against unauthorized reads
+and writes; the binding key inherits that host trust boundary. Custom handlers and client providers remain responsible for
+principal changes in credentials resolved outside the action's evaluated headers.
+
+The proposed deciders are Python code owners; maintainer and engineering-management approval is still required.
diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md
index 96b41d6d38f..ccfc2644614 100644
--- a/docs/specs/004-python-function-calling-loop.md
+++ b/docs/specs/004-python-function-calling-loop.md
@@ -383,24 +383,25 @@ that manually replay messages own the equivalent rule: do not resend an approval
must treat the exception as an explicit instruction to discard those calls and never execute them. Caller
cancellation remains cancellation rather than becoming this provider signal.
- Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses
- for a new user-input request or the run is aborted by `MiddlewareFailure`.
+ for a new user-input request or the run is aborted by a fail-closed error.
- An ordinary exception raised by function middleware or a tool body becomes one terminal error `function_result`
- and the loop continues; `MiddlewareFailure` is the loop's only fail-closed escape: it is never converted into a
+ and the loop continues. `MiddlewareFailure` is the execution-time fail-closed escape: it is never converted into a
tool result, the in-flight parallel batch is cancelled, no further tool call starts, no further model turn is
- consumed, and the exception propagates to the caller (for streaming runs, when the stream is consumed). On a
- service-managed conversation the loop first settles the aborted batch — one error `function_result` per dangling
- call (approval-response wrappers unwrap to their underlying calls; hosted-tool approvals are left to their own
- provider protocol), submitted with `tool_choice="none"` in a single extra request — so the hosted thread is not
- left ending in unresolved function calls that the service would reject on the session's next request; the
- persisted continuation then advances to the settlement response (for response-ID continuations the settled
- endpoint is the new handle; for conversation-object ids the advance is a no-op) and the settlement response is
- otherwise discarded. Settlement covers the approval-resolution phase too: a fatal abort while an approved tool is
- replayed settles the original, already-persisted calls. Without a service-managed conversation no extra request
- is made. Batch
- cancellation is cooperative: an async sibling stops at its next suspension point, while a synchronous tool body
- already executing in a worker thread cannot be interrupted and may complete its side effects — its result is
- discarded either way and never reaches the transcript, the model, or history. Middleware must not catch
- `MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop.
+ consumed, and the exception propagates to the caller (for streaming runs, when the stream is consumed). A
+ configured fatal unknown call likewise raises `KeyError` and propagates, but complete-batch classification detects
+ it before any call executes. On a service-managed conversation the loop first settles either aborted batch — one
+ error `function_result` per dangling call (approval-response wrappers unwrap to their underlying calls; hosted-tool
+ approvals are left to their own provider protocol), submitted with `tool_choice="none"` in a single extra request —
+ so the hosted thread is not left ending in unresolved function calls that the service would reject on the session's
+ next request; the persisted continuation then advances to the settlement response (for response-ID continuations
+ the settled endpoint is the new handle; for conversation-object ids the advance is a no-op) and the settlement
+ response is otherwise discarded. Settlement covers the approval-resolution phase too: a fatal abort while an
+ approved tool is replayed settles the original, already-persisted calls. Without a service-managed conversation no
+ extra request is made. Batch cancellation is cooperative: an async sibling stops at its next suspension point,
+ while a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side
+ effects — its result is discarded either way and never reaches the transcript, the model, or history. Middleware
+ must not catch `MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly
+ unguarded loop.
- `Content.exception` is host-internal diagnostic state. Default `Content.to_dict()` and nested response serialization replace it with a fixed non-sensitive failure marker,
while the original field remains directly available to trusted local code. Remote protocol serializers use the
marker only for status and use the channel-visible `result` or `items` for output text. `include_detailed_errors=False` keeps the channel-visible
@@ -677,7 +678,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Approved middleware repair | Approval binds to the normalized middleware-entry representation, so ordinary Pydantic coercion still completes in one approval round. A changed approval-bound call executes zero times under the old grant, returns a persisted occurrence-bound replacement request in both response modes, and executes once only after the replacement is approved. Recursive type-aware, float-bit-exact comparison treats booleans and numbers, and positive and negative zero, as distinct while keeping unchanged NaNs stable. Opaque mutable normalized values fail closed before approval authority is established. The same replacement rule applies when middleware short-circuits instead of calling the tool. Security expansion preserves approval-visible placeholders. | `test_approved_coercing_arguments_execute_without_replacement`, `test_approved_argument_repair_requires_replacement_approval`, `test_approved_argument_repair_short_circuit_requires_replacement_approval`, `test_approval_snapshot_distinguishes_exact_values`, `test_approval_rejects_opaque_mutable_validator_output`, `packages/core/tests/test_security.py::TestVariableArgumentPolicy::test_hidden_argument_resolution_does_not_require_reapproval` |
| Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` |
| Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` |
-| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents |
+| Unknown call handling | Configured false returns an error result; configured true raises before any call executes. A service-managed continuation settles every call in the aborted batch and advances to the settlement response before the `KeyError` propagates; without one, including local per-service-call history persistence, no extra request is made. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents, `test_fatal_unknown_settles_service_conversation`, `test_fatal_unknown_with_local_history_makes_no_settlement_request` |
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
| Middleware failure (fatal) | `MiddlewareFailure` from function middleware or a tool body propagates to the caller without becoming a tool result; the tool does not execute (pre-invocation) or its result never feeds another model call (post-invocation); the cause chain is preserved; ordinary exceptions still become tool-error results and the loop continues. | `packages/core/tests/core/test_middleware_with_agent.py::TestMiddlewareFailure::test_failure_before_tool_aborts_run`, `test_failure_after_tool_aborts_run_before_next_model_turn`, `test_failure_cause_chain_reaches_caller`, `test_failure_from_tool_escapes_without_middleware`, `test_failure_streaming_reaches_stream_consumer`, `test_ordinary_exception_still_becomes_tool_error` |
| Middleware failure batch cancellation | A fatal signal fails the whole parallel batch: in-flight sibling tool invocations are cancelled and awaited before the failure propagates. Cancellation is cooperative — an async sibling stops at its next suspension point; a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side effects, but its result is discarded and never reaches the transcript, the model, or history, and failure propagation is not delayed behind it. | `TestMiddlewareFailure::test_failure_cancels_concurrent_sibling_tool`, `test_failure_with_sync_sibling_discards_late_result` |
diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf
deleted file mode 100644
index 4588c11d0e6..00000000000
--- a/dotnet/agent-framework-release.slnf
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "solution": {
- "path": "agent-framework-dotnet.slnx",
- "projects": [
- "src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj",
- "src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj",
- "src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj",
- "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
- "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
- "src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
- "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
- "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
- "src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
- "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj",
- "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj",
- "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
- "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
-
- "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
- "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
- "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
- "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
- "src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj",
- "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
- "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
- "src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
- "src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
- "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
- "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
- "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
- "src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
- "src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.csproj",
- "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
- "src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
- "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
- "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
- "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
- "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
- "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
- "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
- ]
- }
-}
diff --git a/dotnet/agent-framework-src-and-tests.slnf b/dotnet/agent-framework-src-and-tests.slnf
new file mode 100644
index 00000000000..b6639c7a09b
--- /dev/null
+++ b/dotnet/agent-framework-src-and-tests.slnf
@@ -0,0 +1,75 @@
+{
+ "solution": {
+ "path": "agent-framework-dotnet.slnx",
+ "projects": [
+ "src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj",
+ "src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj",
+ "src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj",
+ "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
+ "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
+ "src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
+ "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
+ "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
+ "src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
+ "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj",
+ "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj",
+ "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
+ "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
+
+ "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
+ "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
+ "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
+ "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
+ "src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj",
+ "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
+ "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
+ "src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
+ "src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
+ "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
+ "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
+ "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
+ "src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
+ "src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.csproj",
+ "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
+ "src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
+ "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
+ "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
+ "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
+ "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
+ "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
+ "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj",
+ "tests\\Aspire.Hosting.AgentFramework.DevUI.UnitTests\\Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.A2A.UnitTests\\Microsoft.Agents.AI.A2A.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Abstractions.UnitTests\\Microsoft.Agents.AI.Abstractions.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.AgentHooks.UnitTests\\Microsoft.Agents.AI.AgentHooks.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Anthropic.UnitTests\\Microsoft.Agents.AI.Anthropic.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.AzureAI.Persistent.UnitTests\\Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.CosmosNoSql.UnitTests\\Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Declarative.UnitTests\\Microsoft.Agents.AI.Declarative.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.DevUI.UnitTests\\Microsoft.Agents.AI.DevUI.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.FeatureRegistry.UnitTests\\Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Foundry.Hosting.UnitTests\\Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Foundry.UnitTests\\Microsoft.Agents.AI.Foundry.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.GitHub.Copilot.UnitTests\\Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Harness.UnitTests\\Microsoft.Agents.AI.Harness.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hosting.A2A.UnitTests\\Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests\\Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hosting.OpenAI.UnitTests\\Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hosting.UnitTests\\Microsoft.Agents.AI.Hosting.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Hyperlight.UnitTests\\Microsoft.Agents.AI.Hyperlight.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.LocalCodeAct.UnitTests\\Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Mcp.UnitTests\\Microsoft.Agents.AI.Mcp.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Mem0.UnitTests\\Microsoft.Agents.AI.Mem0.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.OpenAI.UnitTests\\Microsoft.Agents.AI.OpenAI.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Purview.UnitTests\\Microsoft.Agents.AI.Purview.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Tools.Shell.UnitTests\\Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.UnitTests\\Microsoft.Agents.AI.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Valkey.UnitTests\\Microsoft.Agents.AI.Valkey.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Workflows.Declarative.UnitTests\\Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Workflows.Generators.UnitTests\\Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj",
+ "tests\\Microsoft.Agents.AI.Workflows.UnitTests\\Microsoft.Agents.AI.Workflows.UnitTests.csproj"
+ ]
+ }
+}
diff --git a/dotnet/dependabot/README.md b/dotnet/dependabot/README.md
deleted file mode 100644
index 9f1c7724588..00000000000
--- a/dotnet/dependabot/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Dependabot NuGet discovery
-
-This folder contains the NuGet entry point used by Dependabot.
-
-Dependabot's NuGet updater expands solution files before resolving package updates. The developer solution in `dotnet/agent-framework-dotnet.slnx` includes the sample projects, and expanding those projects causes the updater to exceed its fixed execution timeout before it can open dependency update pull requests.
-
-`agent-framework-dependabot.slnx` intentionally references only non-sample source and test projects. The referenced projects still live under `dotnet/src` and `dotnet/tests`, so MSBuild imports the central package management configuration from `dotnet/Directory.Packages.props` normally.
-
-Do not add sample projects to this solution. If a new non-sample project should be covered by Dependabot NuGet updates, add it here.
diff --git a/dotnet/dependabot/agent-framework-dependabot.slnx b/dotnet/dependabot/agent-framework-dependabot.slnx
deleted file mode 100644
index 4591ba0f068..00000000000
--- a/dotnet/dependabot/agent-framework-dependabot.slnx
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs
index 7c3ecbc7e96..d6d9396fd4c 100644
--- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs
+++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs
@@ -100,6 +100,7 @@ public static async Task Main(string[] args)
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
{
Configuration = workflowConfiguration,
+ AllowedEnvironmentVariables = [ToolboxMcpServerUrlSetting, DocsServerLabelSetting, WebSearchToolNameSetting],
McpToolHandler = mcpToolHandler
};
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
index 9ac945da40e..32ab0ec821b 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
@@ -137,6 +137,18 @@ public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options
{
Name = "PredictiveStateUpdatesAgent",
Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
+ // write_document runs on the server without approval, while confirm_changes runs on the client.
+ // If the model requests both in one response, the function-invocation loop returns both calls
+ // unexecuted because confirm_changes is only a declaration on the server. Bypassing saves
+ // write_document in the server session and initially exposes only confirm_changes.
+ // The client executes confirm_changes and sends its result with the original call ID in
+ // a continuation request for the same thread. On that next request, the server executes
+ // the saved write_document call. Its call and matching result then reach the client in
+ // the continuation stream as completed history, not as a request for client-side execution.
+ // Program.cs registers a session store keyed by this agent's name so the deferred call
+ // survives across HTTP requests; without it, the call is lost when the client continues.
+ // Execution therefore uses the stored call, not a call reconstructed from client-supplied history.
+ EnableInvocableFunctionBypassing = true,
ChatOptions = new ChatOptions
{
Instructions = """
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
index 49100e42d84..8a0a393ebd4 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUIDojoServer;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Extensions.Options;
@@ -19,9 +21,22 @@
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUIServer();
-// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
-// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
-// deployments, e.g.:
+// A session store is REQUIRED for mixed client/server tool continuation. When write_document and
+// confirm_changes are requested together, invocable function bypassing saves the server's write_document
+// call in the session and sends only confirm_changes to the client. The client's result returns in a
+// separate HTTP request for the same thread, which must reload that session to execute the saved call.
+// Without a session store, the deferred call is lost between requests. Client-replayed tool calls are not
+// a trusted substitute for the server-side record. The registration key must match the agent's name so
+// MapAGUIServer can find its store.
+// In production, use a persistent session store instead of the in-memory one: InMemoryAgentSessionStore
+// loses sessions on restart and keeps every session for the lifetime of the process, with no size limit,
+// expiry or eviction. Thread ids are client-supplied, so multi-user hosts also need the isolation below.
+// This store has no atomic consume: concurrent continuations on one thread can read the same pending call
+// before either saves its updated session, potentially executing that call more than once.
+builder.Services.AddKeyedSingleton("PredictiveStateUpdatesAgent", new InMemoryAgentSessionStore());
+
+// WARNING: When session persistence is enabled, in a multi-user deployment you must also register an
+// AgentIsolationKeyProvider to scope sessions by principal, e.g.:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
index b5c1f946a3c..bb8c8c484bf 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
@@ -4,6 +4,7 @@
using System.ComponentModel;
using AGUIServer;
using Azure.Identity;
+using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
@@ -40,7 +41,26 @@
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
- .AddAIAgent(AgentName, "You are a helpful assistant.", chatClient)
+ .AddAIAgent(AgentName, (services, name) => chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = name,
+ // Client tools are declarations on the server. If the model requests client and server tools
+ // in the same response, the function-invocation loop returns both calls without executing them.
+ // Bypassing keeps the non-approval-required server calls in the server session and initially
+ // exposes only the client calls. The client executes them and sends each result with its
+ // original call ID in a continuation request for the same thread. On that next request,
+ // the server executes the stored calls rather than trusting calls replayed in client-supplied history.
+ // The executed server calls and their matching results then appear in the continuation stream
+ // as completed history, not as requests for the client to execute those server tools.
+ // WithInMemorySessionStore below is required to retain those calls between HTTP requests;
+ // without a session store, the deferred calls are lost when the client continues the run.
+ EnableInvocableFunctionBypassing = true,
+ ChatOptions = new ChatOptions
+ {
+ Instructions = "You are a helpful assistant.",
+ Tools = services.GetKeyedServices(name).ToList(),
+ },
+ }, services: services))
.WithAITools(
new HostedWebSearchTool(),
AIFunctionFactory.Create(
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
index 30adeb1eef2..5085feb0f26 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
@@ -131,7 +131,22 @@ IChatClient chatClient = new OpenAIClient(
.AsIChatClientWithStoredOutputDisabled(model: deploymentName);
builder
- .AddAIAgent("AGUIAssistant", "You are a helpful assistant.", chatClient)
+ .AddAIAgent("AGUIAssistant", (services, name) => chatClient.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = name,
+ // Mixed client/server calls otherwise leave both calls unexecuted on the server.
+ // Retain server calls in the session and expose only client calls. The client executes them
+ // and sends results with matching call IDs on the same thread's continuation request.
+ // The server then executes its saved calls and streams their call/result pairs as completed
+ // history, not requests for client execution. WithInMemorySessionStore preserves deferred calls
+ // between HTTP requests so execution does not rely on client-replayed server calls.
+ EnableInvocableFunctionBypassing = true,
+ ChatOptions = new ChatOptions
+ {
+ Instructions = "You are a helpful assistant.",
+ Tools = services.GetKeyedServices(name).ToList(),
+ },
+ }, services: services))
.WithAITool(new HostedWebSearchTool())
.WithInMemorySessionStore();
@@ -144,6 +159,16 @@ This automatically handles:
- Server-sent events (SSE) formatting
- Thread and run management
+### Mixed Client and Server Tools
+
+`AGUIServer` enables `ChatClientAgentOptions.EnableInvocableFunctionBypassing` and uses an in-memory session store. When a model response requests both a client tool and a non-approval-required server function, the server retains the pending server call in its session and sends only the client call for execution. After the client returns its result, the server resumes the stored call.
+
+The client result carries the original call ID and returns on a continuation request for the same thread. The server executes its saved call, and the server call and its matching result appear in the continuation stream as completed history; the client does not execute that server tool.
+
+Both bypassing and session persistence are required for this flow. `AGUIDojoServer` configures the same behavior for `PredictiveStateUpdatesAgent`, which combines server-side `write_document` with client-side `confirm_changes`. Session stores must be keyed by the agent's name.
+
+In-memory storage is for demonstration: it loses sessions on restart and has no size limit or eviction. Production hosts should use a persistent store and an `AgentIsolationKeyProvider` to isolate sessions by authenticated user.
+
### Client Side
The `AGUIClient` uses the `AGUIChatClient` to connect to the remote server:
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index 921412ba007..d66bc66e337 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
@@ -201,6 +201,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
case StreamResponseCase.Message:
var message = streamResponse.Message!;
contextId = message.ContextId;
+ ValidateContext(typedSession, contextId);
yield return this.ConvertToAgentResponseUpdate(message);
break;
@@ -209,6 +210,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
contextId = task.ContextId;
taskId = task.Id;
taskState = task.Status.State;
+ ValidateContext(typedSession, contextId);
yield return this.ConvertToAgentResponseUpdate(task);
break;
@@ -217,6 +219,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
contextId = statusUpdate.ContextId;
taskId = statusUpdate.TaskId;
taskState = statusUpdate.Status.State;
+ ValidateContext(typedSession, contextId);
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
break;
@@ -224,6 +227,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
var artifactUpdate = streamResponse.ArtifactUpdate!;
contextId = artifactUpdate.ContextId;
taskId = artifactUpdate.TaskId;
+ ValidateContext(typedSession, contextId);
yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
break;
@@ -347,6 +351,16 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s
return;
}
+ ValidateContext(session, contextId);
+
+ // Assign a server-generated context Id to the session if it's not already set.
+ session.ContextId ??= contextId;
+ session.TaskId = taskId;
+ session.TaskState = taskState;
+ }
+
+ private static void ValidateContext(A2AAgentSession session, string? contextId)
+ {
// Surface cases where the A2A agent responds with a response that
// has a different context Id than the session's conversation Id.
if (session.ContextId is not null && contextId is not null && session.ContextId != contextId)
@@ -354,11 +368,6 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s
throw new InvalidOperationException(
$"The {nameof(contextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentSession)}.");
}
-
- // Assign a server-generated context Id to the session if it's not already set.
- session.ContextId ??= contextId;
- session.TaskId = taskId;
- session.TaskState = taskState;
}
private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection messages)
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs
index 89eacdf8fa9..4e62030ccd8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs
@@ -1,11 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.ObjectModel;
using Microsoft.Agents.ObjectModel.Abstractions;
+using Microsoft.Agents.ObjectModel.Analysis;
+using Microsoft.Agents.ObjectModel.PowerFx;
using Microsoft.Agents.ObjectModel.Yaml;
using Microsoft.Extensions.Configuration;
using Microsoft.Shared.Diagnostics;
@@ -22,8 +25,9 @@ internal static class AgentBotElementYaml
///
/// YAML representation of the to use to create the prompt function.
/// Optional instance which provides environment variables to the template.
+ /// Configuration keys that may be exposed when the YAML references them through Env.
[RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")]
- public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null)
+ public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null)
{
Throw.IfNullOrEmpty(text);
@@ -35,7 +39,7 @@ public static GptComponentMetadata FromYaml(string text, IConfiguration? configu
throw new InvalidDataException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(GptComponentMetadata)}.");
}
- var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration);
+ var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration, allowedConfigurationVariables);
return botDefinition.Descendants().OfType().First();
}
@@ -52,7 +56,7 @@ private sealed class AgentFeatureConfiguration : IFeatureConfiguration
public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => defaultValue;
}
- public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null)
+ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null)
{
var botBuilder =
new BotDefinition.Builder
@@ -67,19 +71,26 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele
}
};
- if (configuration is not null)
+ if (configuration is not null && allowedConfigurationVariables is not null)
{
- foreach (var kvp in configuration.AsEnumerable().Where(kvp => kvp.Value is not null))
+ HashSet allowedVariables = new(allowedConfigurationVariables, StringComparer.OrdinalIgnoreCase);
+ foreach (string variableName in GetReferencedEnvironmentVariableNames(element).Where(allowedVariables.Contains))
{
+ string? configurationValue = configuration[variableName];
+ if (configurationValue is null)
+ {
+ continue;
+ }
+
botBuilder.EnvironmentVariables.Add(new EnvironmentVariableDefinition.Builder()
{
- SchemaName = kvp.Key,
+ SchemaName = variableName,
Id = Guid.NewGuid(),
- DisplayName = kvp.Key,
+ DisplayName = variableName,
ValueComponent = new EnvironmentVariableValue.Builder()
{
Id = Guid.NewGuid(),
- Value = kvp.Value!,
+ Value = configurationValue,
},
});
}
@@ -87,5 +98,13 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele
return botBuilder.Build();
}
+
+ internal static ISet GetReferencedEnvironmentVariableNames(GptComponentMetadata element)
+ {
+ var botDefinition = WrapPromptAgentWithBot(element);
+ SemanticModel semanticModel = botDefinition.GetSemanticModel(new PowerFxExpressionChecker(new AgentFeatureConfiguration()), new AgentFeatureConfiguration());
+
+ return semanticModel.GetAllEnvironmentVariablesReferencedInTheBot();
+ }
#endregion
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs
index 28f0c47fbb6..e2af721adee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs
@@ -20,36 +20,70 @@ public sealed class ChatClientPromptAgentFactory : PromptAgentFactory
///
/// Creates a new instance of the class.
///
- public ChatClientPromptAgentFactory(IChatClient chatClient, IList? functions = null, RecalcEngine? engine = null, IConfiguration? configuration = null, ILoggerFactory? loggerFactory = null) : base(engine, configuration)
+ /// The chat client used by created agents.
+ /// Optional functions exposed as tools to created agents.
+ /// Optional Power Fx engine used to evaluate declarative expressions.
+ /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition.
+ /// Optional logger factory used by created agents.
+ public ChatClientPromptAgentFactory(
+ IChatClient chatClient,
+ IList? functions = null,
+ RecalcEngine? engine = null,
+ IConfiguration? configuration = null,
+ ILoggerFactory? loggerFactory = null)
+ : this(chatClient, functions, engine, configuration, loggerFactory, null)
{
- Throw.IfNull(chatClient);
+ // BINARY COMPAT CONSTRUCTOR
+ }
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The chat client used by created agents.
+ /// Optional functions exposed as tools to created agents.
+ /// Optional Power Fx engine used to evaluate declarative expressions.
+ /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition.
+ /// Optional logger factory used by created agents.
+ /// Optional explicitly allowed environment variables referenced by the agent definition.
+ public ChatClientPromptAgentFactory(
+ IChatClient chatClient,
+ IList? functions,
+ RecalcEngine? engine,
+ IConfiguration? configuration,
+ ILoggerFactory? loggerFactory,
+ IEnumerable? allowedConfigurationVariables)
+ : base(engine, configuration, allowedConfigurationVariables)
+ {
+ Throw.IfNull(chatClient);
this._chatClient = chatClient;
this._functions = functions;
this._loggerFactory = loggerFactory;
}
///
- public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default)
+ public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default)
{
Throw.IfNull(promptAgent);
+ this.InitializeConfigurationVariables(promptAgent);
+
var options = new ChatClientAgentOptions()
{
Name = promptAgent.Name,
Description = promptAgent.Description,
- ChatOptions = promptAgent.GetChatOptions(this.Engine, this._functions),
+ ChatOptions = await promptAgent.GetChatOptionsAsync(this.Engine, this._functions, cancellationToken: cancellationToken).ConfigureAwait(false),
};
var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory);
Declarative.FeatureUsageMarker.MarkUsed();
- return Task.FromResult(agent);
+ return agent;
}
#region private
private readonly IChatClient _chatClient;
private readonly IList? _functions;
private readonly ILoggerFactory? _loggerFactory;
+
#endregion
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs
index 9b12ea19fdd..a114f28ddcd 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
@@ -15,8 +17,9 @@ internal static class BoolExpressionExtensions
///
/// Expression to evaluate.
/// Recalc engine to use for evaluation.
+ /// Cancellation token to observe while evaluating the expression.
/// The evaluated boolean value, or null if the expression is null or cannot be evaluated.
- internal static bool? Eval(this BoolExpression? expression, RecalcEngine? engine)
+ internal static async Task EvalAsync(this BoolExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default)
{
if (expression is null)
{
@@ -35,11 +38,11 @@ internal static class BoolExpressionExtensions
if (expression.IsExpression)
{
- return engine.Eval(expression.ExpressionText!).AsBoolean();
+ return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsBoolean();
}
else if (expression.IsVariableReference)
{
- var formulaValue = engine.Eval(expression.VariableReference!.VariableName);
+ var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false);
if (formulaValue is BooleanValue booleanValue)
{
return booleanValue.Value;
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs
index dbc6ff4dda4..3d24cb19fbe 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
@@ -16,8 +18,9 @@ internal static class IntExpressionExtensions
///
/// Expression to evaluate.
/// Recalc engine to use for evaluation.
+ /// Cancellation token to observe while evaluating the expression.
/// The evaluated integer value, or null if the expression is null or cannot be evaluated.
- internal static long? Eval(this IntExpression? expression, RecalcEngine? engine)
+ internal static async Task EvalAsync(this IntExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default)
{
if (expression is null)
{
@@ -36,11 +39,11 @@ internal static class IntExpressionExtensions
if (expression.IsExpression)
{
- return (long)engine.Eval(expression.ExpressionText!).AsDouble();
+ return (long)(await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble();
}
else if (expression.IsVariableReference)
{
- var formulaValue = engine.Eval(expression.VariableReference!.VariableName);
+ var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false);
if (formulaValue is NumberValue numberValue)
{
return (long)numberValue.Value;
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs
index b4f59a015a1..aa6710025b8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
@@ -16,8 +18,9 @@ internal static class NumberExpressionExtensions
///
/// Expression to evaluate.
/// Recalc engine to use for evaluation.
+ /// Cancellation token to observe while evaluating the expression.
/// The evaluated number value, or null if the expression is null or cannot be evaluated.
- internal static double? Eval(this NumberExpression? expression, RecalcEngine? engine)
+ internal static async Task EvalAsync(this NumberExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default)
{
if (expression is null)
{
@@ -36,11 +39,11 @@ internal static class NumberExpressionExtensions
if (expression.IsExpression)
{
- return engine.Eval(expression.ExpressionText!).AsDouble();
+ return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble();
}
else if (expression.IsVariableReference)
{
- var formulaValue = engine.Eval(expression.VariableReference!.VariableName);
+ var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false);
if (formulaValue is NumberValue numberValue)
{
return numberValue.Value;
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs
index 0da3f18f85a..ea02a7f1910 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs
@@ -2,6 +2,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx;
using Microsoft.Shared.Diagnostics;
@@ -19,7 +21,8 @@ public static class PromptAgentExtensions
/// Instance of
/// Instance of
/// Instance of
- public static ChatOptions? GetChatOptions(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions)
+ /// Cancellation token to observe while retrieving chat options.
+ public static async Task GetChatOptionsAsync(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions, CancellationToken cancellationToken = default)
{
Throw.IfNull(promptAgent);
@@ -36,17 +39,17 @@ public static class PromptAgentExtensions
return new ChatOptions()
{
Instructions = promptAgent.Instructions?.ToTemplateString(),
- Temperature = (float?)modelOptions?.Temperature?.Eval(engine),
- MaxOutputTokens = (int?)modelOptions?.MaxOutputTokens?.Eval(engine),
- TopP = (float?)modelOptions?.TopP?.Eval(engine),
- TopK = (int?)modelOptions?.TopK?.Eval(engine),
- FrequencyPenalty = (float?)modelOptions?.FrequencyPenalty?.Eval(engine),
- PresencePenalty = (float?)modelOptions?.PresencePenalty?.Eval(engine),
- Seed = modelOptions?.Seed?.Eval(engine),
+ Temperature = modelOptions?.Temperature is { } temperature ? (float?)await temperature.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ MaxOutputTokens = modelOptions?.MaxOutputTokens is { } maxOutputTokens ? (int?)await maxOutputTokens.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ TopP = modelOptions?.TopP is { } topP ? (float?)await topP.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ TopK = modelOptions?.TopK is { } topK ? (int?)await topK.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ FrequencyPenalty = modelOptions?.FrequencyPenalty is { } frequencyPenalty ? (float?)await frequencyPenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ PresencePenalty = modelOptions?.PresencePenalty is { } presencePenalty ? (float?)await presencePenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
+ Seed = modelOptions?.Seed is { } seed ? (int?)await seed.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
ResponseFormat = outputSchema?.AsChatResponseFormat(),
ModelId = promptAgent.Model?.ModelNameHint,
StopSequences = modelOptions?.StopSequences,
- AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls?.Eval(engine),
+ AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls is { } allowMultipleToolCalls ? await allowMultipleToolCalls.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default,
ToolMode = modelOptions?.AsChatToolMode(),
Tools = tools,
AdditionalProperties = modelOptions?.GetAdditionalProperties(s_chatOptionProperties),
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs
index 2a9b42e0873..fab9c4371de 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
@@ -15,8 +17,9 @@ public static class StringExpressionExtensions
///
/// Expression to evaluate.
/// Recalc engine to use for evaluation.
+ /// Cancellation token to use for the asynchronous operation.
/// The evaluated string value, or null if the expression is null or cannot be evaluated.
- public static string? Eval(this StringExpression? expression, RecalcEngine? engine)
+ public static async Task EvalAsync(this StringExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default)
{
if (expression is null)
{
@@ -35,11 +38,11 @@ public static class StringExpressionExtensions
if (expression.IsExpression)
{
- return engine.Eval(expression.ExpressionText!).ToString();
+ return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken: cancellationToken).ConfigureAwait(false)).ToString();
}
else if (expression.IsVariableReference)
{
- var stringValue = engine.Eval(expression.VariableReference!.VariableName) as StringValue;
+ var stringValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken: cancellationToken).ConfigureAwait(false) as StringValue;
return stringValue?.Value;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs
index 1cc24055d90..b7a0e2da26a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs
@@ -24,7 +24,7 @@ public static Task CreateFromYamlAsync(this PromptAgentFactory agentFac
Throw.IfNull(agentFactory);
Throw.IfNullOrEmpty(agentYaml);
- var agentDefinition = AgentBotElementYaml.FromYaml(agentYaml);
+ var agentDefinition = agentFactory.FromYaml(agentYaml);
return agentFactory.CreateAsync(
agentDefinition,
diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs
index 22d55178ba0..f9ea1f2deda 100644
--- a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.ObjectModel;
@@ -15,30 +18,85 @@ namespace Microsoft.Agents.AI;
///
public abstract class PromptAgentFactory
{
+ private const int DefaultMaximumExpressionLength = 10000;
+
+ private readonly IConfiguration? _configuration;
+ private readonly HashSet _allowedConfigurationVariables;
+
///
/// Initializes a new instance of the class.
///
/// Optional , if none is provided a default instance will be created.
- /// Optional configuration to be added as variables to the .
- protected PromptAgentFactory(RecalcEngine? engine = null, IConfiguration? configuration = null)
+ /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition.
+ protected PromptAgentFactory(RecalcEngine? engine = null,
+ IConfiguration? configuration = null) : this(engine, configuration, null, null, null)
{
- this.Engine = engine ?? new RecalcEngine();
+ // BINARY COMPAT CONSTRUCTOR
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Optional , if none is provided a default instance will be created.
+ /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition.
+ /// Configuration keys that may be exposed to Power Fx when the agent definition references them through Env.
+ /// Optional maximum length for Power Fx expressions evaluated by the factory-created engine.
+ /// Optional maximum nested call depth for Power Fx expressions evaluated by the factory-created engine.
+ protected PromptAgentFactory(RecalcEngine? engine,
+ IConfiguration? configuration,
+ IEnumerable? allowedConfigurationVariables,
+ int? maximumExpressionLength = null,
+ int? maximumCallDepth = null)
+ {
+ this.Engine = engine ?? new RecalcEngine(CreateConfig(maximumExpressionLength, maximumCallDepth));
+ this._configuration = configuration;
+ this._allowedConfigurationVariables = new(
+ allowedConfigurationVariables ?? [],
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static PowerFxConfig CreateConfig(int? maximumExpressionLength, int? maximumCallDepth)
+ {
+ PowerFxConfig config = new(Features.PowerFxV1)
+ {
+ MaximumExpressionLength = maximumExpressionLength ?? DefaultMaximumExpressionLength,
+ };
- if (configuration is not null)
+ if (maximumCallDepth is not null)
{
- foreach (var kvp in configuration.AsEnumerable())
- {
- this.Engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty);
- }
+ config.MaxCallDepth = maximumCallDepth.Value;
}
+
+ return config;
}
///
/// Gets the Power Fx recalculation engine used to evaluate expressions in agent definitions.
- /// This engine is configured with variables from the provided during construction.
+ /// This engine is configured with only explicitly allowed variables from the provided during construction.
///
protected RecalcEngine Engine { get; }
+ ///
+ /// Adds allowed configuration values referenced through Env by the agent definition to the Power Fx engine.
+ ///
+ /// Definition of the agent to inspect.
+ protected void InitializeConfigurationVariables(GptComponentMetadata promptAgent)
+ {
+ if (this._configuration is null || this._allowedConfigurationVariables.Count == 0)
+ {
+ return;
+ }
+
+ foreach (string variableName in AgentBotElementYaml.GetReferencedEnvironmentVariableNames(promptAgent).Where(this._allowedConfigurationVariables.Contains))
+ {
+ this.Engine.UpdateVariable(variableName, this._configuration[variableName] ?? string.Empty);
+ }
+ }
+
+ [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")]
+ internal GptComponentMetadata FromYaml(string text) =>
+ AgentBotElementYaml.FromYaml(text, this._configuration, this._allowedConfigurationVariables);
+
///
/// Create a from the specified .
///
@@ -49,6 +107,7 @@ public async Task CreateAsync(GptComponentMetadata promptAgent, Cancell
{
Throw.IfNull(promptAgent);
+ this.InitializeConfigurationVariables(promptAgent);
var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false) ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported.");
Declarative.FeatureUsageMarker.MarkUsed();
return agent;
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
index ba9931e5d12..8b4643fa419 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
@@ -52,6 +52,42 @@ if (builder.Environment.IsDevelopment())
app.Run();
```
+## Function approvals
+
+Function approval requires an agent session store. The approval request and the user's
+decision arrive in separate HTTP requests. The store preserves the server-recorded
+request so the decision can be matched to the exact function call shown in DevUI,
+rather than trusting function details supplied by the caller.
+
+Configure a session store on every agent that exposes an
+`ApprovalRequiredAIFunction`:
+
+```csharp
+builder.AddAIAgent("assistant", "You are a helpful assistant.")
+ .WithInMemorySessionStore();
+```
+
+`WithInMemorySessionStore()` preserves approvals between requests but loses them
+when the application restarts. Use `WithSessionStore(...)` with persistent storage
+when approvals must survive restarts or move between service instances.
+
+If a client sends `function_approval_response` for an agent without a configured
+session store, the Responses endpoint returns HTTP 400:
+
+```text
+Approval-required function calling is not supported because no AgentSessionStore is configured.
+```
+
+The endpoint also returns HTTP 400 when `request_id` is unknown or belongs to a
+different stored session. This prevents an approval issued in one conversation
+from authorizing a function call in another.
+
+For a streaming response, wait for its terminal event before sending the
+`function_approval_response`. The server persists the approval checkpoint before
+publishing `response.completed`. A decision sent while the originating stream is
+still in progress is rejected with HTTP 400 because that approval is not yet
+available for continuation.
+
## Security
DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs
index 561504c58ba..32a9e502a6b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs
@@ -38,8 +38,21 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name);
- return MapOpenAIResponses(endpoints, agent, path, mapOptions);
+ ValidateAgentName(agentBuilder.Name);
+ path ??= $"/{agentBuilder.Name}/v1/responses";
+
+ // Defer agent and session-store resolution so their registered lifetimes are owned by
+ // each validation or execution operation rather than by the application root.
+ var scopeFactory = endpoints.ServiceProvider.GetRequiredService();
+ var executor = new AIAgentResponseExecutor(
+ agentBuilder.Name,
+ scopeFactory,
+ mapOptions);
+ return MapOpenAIResponses(
+ endpoints,
+ executor,
+ path,
+ agentBuilder.Name);
}
///
@@ -70,9 +83,27 @@ public static IEndpointConventionBuilder MapOpenAIResponses(
responsesPath ??= $"/{agent.Name}/v1/responses";
- // Create an executor for this agent
- var executor = new AIAgentResponseExecutor(agent, mapOptions);
+ // The supplied agent remains fixed, while its optional keyed session store is resolved
+ // inside each operation scope.
+ var scopeFactory = endpoints.ServiceProvider.GetRequiredService();
+ var executor = new AIAgentResponseExecutor(
+ agent,
+ agent.Name,
+ scopeFactory,
+ mapOptions);
+ return MapOpenAIResponses(
+ endpoints,
+ executor,
+ responsesPath,
+ agent.Name);
+ }
+ private static RouteGroupBuilder MapOpenAIResponses(
+ IEndpointRouteBuilder endpoints,
+ IResponseExecutor executor,
+ string responsesPath,
+ string endpointAgentName)
+ {
// Resolve the response storage settings and optional conversation storage.
var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions();
var conversationStorage = endpoints.ServiceProvider.GetService();
@@ -85,11 +116,8 @@ public static IEndpointConventionBuilder MapOpenAIResponses(
// Create the response service so response and conversation operations are scoped by the caller's isolation key.
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver);
-
var handlers = new ResponsesHttpHandler(responsesService);
-
var group = endpoints.MapGroup(responsesPath);
- var endpointAgentName = agent.Name ?? agent.Id;
// Create response endpoint
group.MapPost("/", handlers.CreateResponseAsync)
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
index f77143c5832..60c6c916fd9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
@@ -147,6 +147,7 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(ItemContentOutputText))]
[JsonSerializable(typeof(ItemContentOutputAudio))]
[JsonSerializable(typeof(ItemContentRefusal))]
+[JsonSerializable(typeof(ItemContentFunctionApprovalResponse))]
[JsonSerializable(typeof(TextConfiguration))]
[JsonSerializable(typeof(ResponseTextFormatConfiguration))]
[JsonSerializable(typeof(ResponseTextFormatConfigurationText))]
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
index 5b01cc1394a..84e95d7f0d7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
@@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -16,22 +17,56 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
///
internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
- private readonly AIAgent _agent;
+ private readonly AIAgent? _agent;
+ private readonly string _registrationKey;
+ private readonly IServiceScopeFactory _scopeFactory;
private readonly OpenAIResponsesMapOptions _mapOptions;
public AIAgentResponseExecutor(
AIAgent agent,
+ string registrationKey,
+ IServiceScopeFactory scopeFactory,
OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
+ ArgumentException.ThrowIfNullOrEmpty(registrationKey);
+ ArgumentNullException.ThrowIfNull(scopeFactory);
+
this._agent = agent;
+ this._registrationKey = registrationKey;
+ this._scopeFactory = scopeFactory;
+ this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
+ }
+
+ public AIAgentResponseExecutor(
+ string registrationKey,
+ IServiceScopeFactory scopeFactory,
+ OpenAIResponsesMapOptions? mapOptions = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(registrationKey);
+ ArgumentNullException.ThrowIfNull(scopeFactory);
+
+ this._registrationKey = registrationKey;
+ this._scopeFactory = scopeFactory;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}
- public ValueTask ValidateRequestAsync(
+ public async ValueTask ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
- => ValueTask.FromResult(this.ValidateRunOptions(request));
+ {
+ AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider);
+ ResponseError? sessionError =
+ AgentResponseExecution.ValidateSessionRequirements(request, executionAgent is AIHostAgent);
+ return sessionError
+ ?? this.ValidateRunOptions(request)
+ ?? await AgentResponseExecution.ValidatePendingApprovalResponsesAsync(
+ executionAgent, request, cancellationToken).ConfigureAwait(false);
+ }
+ }
internal ResponseError? ValidateRunOptions(CreateResponse request)
{
@@ -58,29 +93,37 @@ public async IAsyncEnumerable ExecuteAsync(
IReadOnlyList? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
- // request settings are mapped onto the agent run. By default no request setting is mapped.
- AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
-
- // Convert input to chat messages, prepending conversation history if available
- var messages = new List();
-
- if (conversationHistory is not null)
+ // The scope surrounds the full enumeration so background execution retains scoped
+ // agents and stores through session persistence after the HTTP request has returned.
+ AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- messages.AddRange(conversationHistory);
- }
+ AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider);
- foreach (var inputMessage in request.Input.GetInputMessages())
- {
- messages.Add(inputMessage.ToChatMessage());
+ // Agent selection is fixed by the endpoint. The remaining response execution behavior is
+ // shared with request-routed hosted agents.
+ await foreach (StreamingResponseEvent streamingEvent in AgentResponseExecution.ExecuteAsync(
+ executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken).ConfigureAwait(false))
+ {
+ yield return streamingEvent;
+ }
}
+ }
- // Use the extension method to convert streaming updates to streaming response events
- await foreach (var streamingEvent in this._agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
- .ToStreamingResponseAsync(request, context, cancellationToken)
- .ConfigureAwait(false))
+ private AIAgent ResolveExecutionAgent(IServiceProvider services)
+ {
+ AIAgent agent = this._agent ??
+ services.GetRequiredKeyedService(this._registrationKey);
+#pragma warning disable MAAI001
+ AgentSessionStore? sessionStore =
+ services.GetKeyedService(this._registrationKey);
+#pragma warning restore MAAI001
+ if (sessionStore is null)
{
- yield return streamingEvent;
+ return agent;
}
+
+ string? sessionStorageIdentity = this._agent is null ? this._registrationKey : null;
+ return new AIHostAgent(agent, sessionStore, sessionStorageIdentity);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
index f21c2e84e94..1fdd2bfb53f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
@@ -9,7 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
///
/// The ID generator.
/// The JSON serializer options. If not provided, default options will be used.
-internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerializerOptions? jsonSerializerOptions = null)
+///
+/// The trusted caller isolation key captured while the originating request context was available.
+///
+internal sealed class AgentInvocationContext(
+ IdGenerator idGenerator,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ string? isolationKey = null)
{
///
/// Gets the ID generator for this context.
@@ -26,6 +32,11 @@ internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerial
///
public string ConversationId => this.IdGenerator.ConversationId;
+ ///
+ /// Gets the trusted caller isolation key captured before background execution detached from the request.
+ ///
+ public string? IsolationKey { get; } = isolationKey;
+
///
/// Gets the JSON serializer options.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs
new file mode 100644
index 00000000000..c4d0cd352aa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs
@@ -0,0 +1,248 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Executes a response after the endpoint-specific executor has selected an agent.
+///
+internal static class AgentResponseExecution
+{
+ private const string SessionInitializedStateKey = "Microsoft.Agents.AI.Hosting.OpenAI.Responses.Initialized";
+ private const string PendingApprovalRequestIdsStateKey = "Microsoft.Agents.AI.Hosting.OpenAI.Responses.PendingApprovalRequestIds";
+
+ ///
+ /// Validates that requests which resume an approval have server-side session storage available.
+ ///
+ public static ResponseError? ValidateSessionRequirements(CreateResponse request, bool hasSessionStore)
+ {
+ List approvalResponses = GetFunctionApprovalResponses(request);
+ if (approvalResponses.Count == 0)
+ {
+ return null;
+ }
+
+ if (hasSessionStore)
+ {
+ return null;
+ }
+
+ // Approval responses are trusted only when matched to a request recorded by the server.
+ // Without session storage, continuing would silently ignore the decision or trust caller data.
+ return new ResponseError
+ {
+ Code = ResponseErrorCodes.InvalidRequest,
+ Message = "Approval-required function calling is not supported because no AgentSessionStore is configured."
+ };
+ }
+
+ ///
+ /// Validates that every incoming approval response matches a request emitted for the restored session.
+ ///
+ public static async ValueTask ValidatePendingApprovalResponsesAsync(
+ AIAgent agent,
+ CreateResponse request,
+ CancellationToken cancellationToken)
+ {
+ List approvalResponses = GetFunctionApprovalResponses(request);
+ if (approvalResponses.Count == 0)
+ {
+ return null;
+ }
+
+ // ValidateSessionRequirements ensures approval responses reach this point only through an AIHostAgent.
+ var hostAgent = (AIHostAgent)agent;
+ string? sessionId = request.Conversation?.Id ?? request.PreviousResponseId;
+ if (sessionId is null)
+ {
+ return InvalidApprovalRequest(approvalResponses[0].RequestId);
+ }
+
+ AgentSession session = await hostAgent.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false);
+ session.StateBag.TryGetValue>(PendingApprovalRequestIdsStateKey, out var pendingRequestIds);
+
+ foreach (ItemContentFunctionApprovalResponse approvalResponse in approvalResponses)
+ {
+ if (pendingRequestIds?.Exists(
+ requestId => string.Equals(requestId, approvalResponse.RequestId, StringComparison.Ordinal)) != true)
+ {
+ return InvalidApprovalRequest(approvalResponse.RequestId);
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Runs the selected agent and converts its updates to Responses API events.
+ ///
+ public static async IAsyncEnumerable ExecuteAsync(
+ AIAgent agent,
+ OpenAIResponsesMapOptions mapOptions,
+ AgentInvocationContext context,
+ CreateResponse request,
+ IReadOnlyList? conversationHistory = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which request
+ // settings are mapped onto the agent run. By default no request setting is mapped.
+ AgentRunOptions? options = mapOptions.RunOptionsFactory(request.ToRequestInfo());
+
+ // A response ID identifies an immutable continuation snapshot. A conversation ID identifies the
+ // mutable conversation head. A new response without either starts under its generated response ID.
+ AIHostAgent? hostAgent = agent as AIHostAgent;
+ if (hostAgent is not null && context.IsolationKey is not null)
+ {
+ // Background work cannot safely query IHttpContextAccessor after the request has ended. Bind the
+ // session store to the trusted key captured by InMemoryResponsesService before detaching.
+ hostAgent = hostAgent.BindIsolationKey(context.IsolationKey);
+ agent = hostAgent;
+ }
+
+ AgentSession? session = null;
+ bool includeConversationHistory = true;
+ if (hostAgent is not null)
+ {
+ string sessionId = request.Conversation?.Id
+ ?? request.PreviousResponseId
+ ?? context.ResponseId;
+ session = await hostAgent.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false);
+
+ // A new agent session must be seeded from an existing conversation transcript. Once initialized,
+ // the session owns both history and pending approvals, so replaying the transcript would duplicate
+ // messages and could resubmit approval content that was already processed.
+ includeConversationHistory = !session.StateBag.TryGetValue(SessionInitializedStateKey, out _);
+ session.StateBag.SetValue(SessionInitializedStateKey, bool.TrueString);
+ }
+
+ // Convert input to chat messages, prepending conversation history only when it is not already
+ // represented by a restored agent session.
+ var messages = new List();
+ if (includeConversationHistory && conversationHistory is not null)
+ {
+ messages.AddRange(conversationHistory);
+ }
+
+ foreach (InputMessage inputMessage in request.Input.GetInputMessages())
+ {
+ messages.Add(inputMessage.ToChatMessage());
+ }
+
+ // Convert streaming agent updates to Responses API events. For persisted sessions, hold the
+ // terminal event until the state has been saved. Otherwise a streaming client can immediately
+ // continue the response before the approval checkpoint is available.
+ StreamingResponseCompleted? completedEvent = null;
+ List? emittedApprovalRequestIds = null;
+ await foreach (StreamingResponseEvent streamingEvent in agent.RunStreamingAsync(messages, session, options, cancellationToken)
+ .ToStreamingResponseAsync(request, context, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ if (session is not null && streamingEvent is StreamingFunctionApprovalRequested approvalRequested)
+ {
+ (emittedApprovalRequestIds ??= []).Add(approvalRequested.RequestId);
+ }
+
+ if (hostAgent is not null && streamingEvent is StreamingResponseCompleted completed)
+ {
+ completedEvent = completed;
+ continue;
+ }
+
+ yield return streamingEvent;
+ }
+
+ if (hostAgent is not null && session is not null)
+ {
+ UpdatePendingApprovalRequests(session, request, emittedApprovalRequestIds);
+
+ // Response IDs are immutable snapshots and honor store=false. A conversation ID is a mutable
+ // head and always advances after a successful turn.
+ if (request.Store is not false)
+ {
+ await hostAgent.SaveSessionAsync(context.ResponseId, session, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (request.Conversation?.Id is { } conversationId)
+ {
+ await hostAgent.SaveSessionAsync(conversationId, session, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // Publish completion only after persistence so an immediate continuation can restore the session.
+ if (completedEvent is not null)
+ {
+ yield return completedEvent;
+ }
+ }
+
+ private static List GetFunctionApprovalResponses(CreateResponse request)
+ {
+ var approvalResponses = new List();
+ foreach (InputMessage inputMessage in request.Input.GetInputMessages())
+ {
+ if (inputMessage.Content.Contents is not { } contents)
+ {
+ continue;
+ }
+
+ foreach (ItemContent content in contents)
+ {
+ if (content is ItemContentFunctionApprovalResponse approvalResponse)
+ {
+ approvalResponses.Add(approvalResponse);
+ }
+ }
+ }
+
+ return approvalResponses;
+ }
+
+ private static void UpdatePendingApprovalRequests(
+ AgentSession session,
+ CreateResponse request,
+ List? emittedRequestIds)
+ {
+ session.StateBag.TryGetValue>(PendingApprovalRequestIdsStateKey, out var pendingRequestIds);
+ pendingRequestIds ??= [];
+
+ // A successfully processed response consumes its matching request ID. New approval events are then
+ // added so the saved session represents exactly the approvals the client can answer next.
+ foreach (ItemContentFunctionApprovalResponse approvalResponse in GetFunctionApprovalResponses(request))
+ {
+ pendingRequestIds.RemoveAll(id => string.Equals(id, approvalResponse.RequestId, StringComparison.Ordinal));
+ }
+
+ if (emittedRequestIds is not null)
+ {
+ foreach (string requestId in emittedRequestIds)
+ {
+ if (!pendingRequestIds.Exists(id => string.Equals(id, requestId, StringComparison.Ordinal)))
+ {
+ pendingRequestIds.Add(requestId);
+ }
+ }
+ }
+
+ if (pendingRequestIds.Count == 0)
+ {
+ session.StateBag.TryRemoveValue(PendingApprovalRequestIdsStateKey);
+ }
+ else
+ {
+ session.StateBag.SetValue(PendingApprovalRequestIdsStateKey, pendingRequestIds);
+ }
+ }
+
+ private static ResponseError InvalidApprovalRequest(string requestId) => new()
+ {
+ Code = ResponseErrorCodes.InvalidRequest,
+ Message = $"Function approval response '{requestId}' does not match a pending approval request for this session."
+ };
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
index 2476ce2fbd3..f54ade45445 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Generic;
+using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
@@ -70,6 +72,15 @@ private static string MediaTypeToAudioFormat(string mediaType) =>
ItemContentOutputAudio outputAudio =>
new DataContent(outputAudio.Data, "audio/*"),
+ ItemContentFunctionApprovalResponse approvalResponse =>
+ new ToolApprovalResponseContent(
+ approvalResponse.RequestId,
+ approvalResponse.Approved,
+ new FunctionCallContent(
+ approvalResponse.FunctionCall.Id,
+ approvalResponse.FunctionCall.Name,
+ ToFunctionArguments(approvalResponse.FunctionCall.Arguments))),
+
_ => null
};
@@ -159,4 +170,22 @@ DataContent audioData when audioData.HasTopLevelMediaType("audio") =>
return null;
}
+
+ private static Dictionary? ToFunctionArguments(JsonElement arguments)
+ {
+ if (arguments.ValueKind == JsonValueKind.Null)
+ {
+ return null;
+ }
+
+ // Keep each value as a cloned JsonElement so nested objects, arrays, numbers, booleans,
+ // and null values retain their original JSON representation.
+ var result = new Dictionary();
+ foreach (JsonProperty property in arguments.EnumerateObject())
+ {
+ result[property.Name] = property.Value.Clone();
+ }
+
+ return result;
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
index 687c121be25..2115eb7cdde 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
@@ -19,82 +19,102 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
///
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
- private readonly IServiceProvider _serviceProvider;
+ private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
private readonly OpenAIResponsesMapOptions _mapOptions;
///
/// Initializes a new instance of the class.
///
- /// The service provider used to resolve hosted agents.
+ /// The factory used to create a scope for each hosted-agent operation.
/// The logger instance.
/// Options controlling how incoming requests are mapped onto the agent run.
public HostedAgentResponseExecutor(
- IServiceProvider serviceProvider,
+ IServiceScopeFactory scopeFactory,
ILogger logger,
OpenAIResponsesMapOptions? mapOptions = null)
{
- ArgumentNullException.ThrowIfNull(serviceProvider);
+ ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
- this._serviceProvider = serviceProvider;
+ this._scopeFactory = scopeFactory;
this._logger = logger;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}
///
- public ValueTask ValidateRequestAsync(
+ public async ValueTask ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
// Extract agent name from agent.name or model parameter
- string? agentName = GetAgentName(request);
+ string? registrationKey = GetAgentRegistrationKey(request);
- if (string.IsNullOrEmpty(agentName))
+ if (string.IsNullOrEmpty(registrationKey))
{
- return ValueTask.FromResult(new ResponseError
+ return new ResponseError
{
Code = "missing_required_parameter",
Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request."
- });
+ };
}
- // Validate that the agent can be resolved
- AIAgent? agent = this._serviceProvider.GetKeyedService(agentName);
- if (agent is null)
+ AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- if (this._logger.IsEnabled(LogLevel.Warning))
+ IServiceProvider services = scope.ServiceProvider;
+
+ // Validate that the agent can be resolved
+ AIAgent? agent = services.GetKeyedService(registrationKey);
+ if (agent is null)
{
- this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName);
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", registrationKey);
+ }
+
+ return new ResponseError
+ {
+ Code = "agent_not_found",
+ Message = $"""
+ Agent '{registrationKey}' not found.
+ Ensure the agent is registered with '{registrationKey}' name in the dependency injection container.
+ We recommend using 'builder.AddAIAgent()' for simplicity.
+ """
+ };
}
- return ValueTask.FromResult(new ResponseError
+ // An approval response can be validated only against the pending request stored by the server.
+#pragma warning disable MAAI001
+ AgentSessionStore? sessionStore = services.GetKeyedService(registrationKey);
+#pragma warning restore MAAI001
+ ResponseError? sessionError = AgentResponseExecution.ValidateSessionRequirements(request, sessionStore is not null);
+ if (sessionError is not null)
{
- Code = "agent_not_found",
- Message = $"""
- Agent '{agentName}' not found.
- Ensure the agent is registered with '{agentName}' name in the dependency injection container.
- We recommend using 'builder.AddAIAgent()' for simplicity.
- """
- });
- }
+ return sessionError;
+ }
- // Surface unsupported request settings as a clean request error rather than an unhandled
- // exception during execution.
- try
- {
- _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
- }
- catch (NotSupportedException ex)
- {
- return ValueTask.FromResult(new ResponseError
+ // Surface unsupported request settings as a clean request error rather than an unhandled
+ // exception during execution.
+ try
{
- Code = "unsupported_parameter",
- Message = ex.Message
- });
- }
+ _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
+ }
+ catch (NotSupportedException ex)
+ {
+ return new ResponseError
+ {
+ Code = "unsupported_parameter",
+ Message = ex.Message
+ };
+ }
- return ValueTask.FromResult(null);
+ AIAgent executionAgent = sessionStore is null
+ ? agent
+ : new AIHostAgent(agent, sessionStore, sessionStorageIdentity: registrationKey);
+ return await AgentResponseExecution.ValidatePendingApprovalResponsesAsync(
+ executionAgent, request, cancellationToken).ConfigureAwait(false);
+ }
}
///
@@ -104,28 +124,28 @@ public async IAsyncEnumerable ExecuteAsync(
IReadOnlyList? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- string agentName = GetAgentName(request)!;
- AIAgent agent = this._serviceProvider.GetRequiredKeyedService(agentName);
-
- // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
- // request settings are mapped onto the agent run. By default no request setting is mapped.
- AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
- var messages = new List();
-
- if (conversationHistory is not null)
- {
- messages.AddRange(conversationHistory);
- }
-
- foreach (var inputMessage in request.Input.GetInputMessages())
+ // Resolve the agent selected by this request together with its optional persisted session store.
+ string registrationKey = GetAgentRegistrationKey(request)!;
+ // The scope surrounds the full enumeration so background execution retains scoped
+ // agents and stores through session persistence after the HTTP request has returned.
+ AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- messages.Add(inputMessage.ToChatMessage());
- }
-
- await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
- .ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
- {
- yield return streamingEvent;
+ IServiceProvider services = scope.ServiceProvider;
+ AIAgent agent = services.GetRequiredKeyedService(registrationKey);
+#pragma warning disable MAAI001
+ AgentSessionStore? sessionStore = services.GetKeyedService(registrationKey);
+#pragma warning restore MAAI001
+ AIAgent executionAgent = sessionStore is null
+ ? agent
+ : new AIHostAgent(agent, sessionStore, sessionStorageIdentity: registrationKey);
+
+ // Once selected, hosted agents use the same execution behavior as fixed-agent endpoints.
+ await foreach (StreamingResponseEvent streamingEvent in AgentResponseExecution.ExecuteAsync(
+ executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken).ConfigureAwait(false))
+ {
+ yield return streamingEvent;
+ }
}
}
@@ -134,7 +154,7 @@ public async IAsyncEnumerable ExecuteAsync(
///
/// The create response request.
/// The agent name.
- private static string? GetAgentName(CreateResponse request)
+ private static string? GetAgentRegistrationKey(CreateResponse request)
{
string? agentName = request.Agent?.Name;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs
index 9d351e793d1..365851508d0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs
@@ -34,6 +34,7 @@ private sealed class ResponseState
public Response? Response { get; set; }
public CreateResponse? Request { get; set; }
public string? ConversationStorageId { get; set; }
+ public string? IsolationKey { get; set; }
public List StreamingUpdates { get; } = [];
public Task? CompletionTask { get; set; }
public CancellationTokenSource? CancellationTokenSource { get; set; }
@@ -206,10 +207,12 @@ public async Task CreateResponseAsync(
var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id);
var responseId = idGenerator.ResponseId;
- var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false);
+ // Resolve ambient caller identity before a background response detaches from the HTTP request.
+ string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
+ string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey);
var conversationStorageId = request.Conversation?.Id is { } conversationId
- ? await this.GetStorageIdAsync(conversationId, cancellationToken).ConfigureAwait(false)
+ ? IsolationKeyResolver.ScopeId(conversationId, isolationKey)
: null;
var ct = request.Background switch
@@ -218,7 +221,8 @@ public async Task CreateResponseAsync(
_ => cancellationToken,
};
- var state = this.InitializeResponse(responseId, responseStorageId, conversationStorageId, request);
+ var state = this.InitializeResponse(
+ responseId, responseStorageId, conversationStorageId, isolationKey, request);
state.CompletionTask = this.ExecuteResponseAsync(responseId, state, ct);
// For background responses, start execution and return immediately
@@ -244,14 +248,17 @@ public async IAsyncEnumerable CreateResponseStreamingAsy
var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id);
var responseId = idGenerator.ResponseId;
- var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false);
+ // Streaming execution is also detached from the request cancellation token, so capture identity now.
+ string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
+ string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey);
var conversationStorageId = request.Conversation?.Id is { } conversationId
- ? await this.GetStorageIdAsync(conversationId, cancellationToken).ConfigureAwait(false)
+ ? IsolationKeyResolver.ScopeId(conversationId, isolationKey)
: null;
// Start execution
- var state = this.InitializeResponse(responseId, responseStorageId, conversationStorageId, request);
+ var state = this.InitializeResponse(
+ responseId, responseStorageId, conversationStorageId, isolationKey, request);
state.CompletionTask = this.ExecuteResponseAsync(responseId, state, CancellationToken.None);
// Stream updates as they become available
@@ -408,7 +415,17 @@ private ValueTask GetStorageIdAsync(string id, CancellationToken cancell
return this._isolationKeyResolver.ScopeIdAsync(id, cancellationToken);
}
- private ResponseState InitializeResponse(string responseId, string responseStorageId, string? conversationStorageId, CreateResponse request)
+ private ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) =>
+ this._isolationKeyResolver is null
+ ? new ValueTask((string?)null)
+ : this._isolationKeyResolver.GetKeyAsync(cancellationToken);
+
+ private ResponseState InitializeResponse(
+ string responseId,
+ string responseStorageId,
+ string? conversationStorageId,
+ string? isolationKey,
+ CreateResponse request)
{
var metadata = request.Metadata ?? [];
@@ -458,6 +475,7 @@ private ResponseState InitializeResponse(string responseId, string responseStora
Response = response,
Request = request,
ConversationStorageId = conversationStorageId,
+ IsolationKey = isolationKey,
CancellationTokenSource = new CancellationTokenSource()
};
@@ -484,7 +502,9 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state,
try
{
// Create agent invocation context
- var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
+ var context = new AgentInvocationContext(
+ new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id),
+ isolationKey: state.IsolationKey);
// Load conversation history if a conversation ID is provided
IReadOnlyList? conversationHistory = null;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
index 289bafbc437..17a36c1f7b8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
@@ -266,6 +266,7 @@ internal enum FunctionToolCallOutputItemResourceStatus
[JsonDerivedType(typeof(ItemContentOutputText), "output_text")]
[JsonDerivedType(typeof(ItemContentOutputAudio), "output_audio")]
[JsonDerivedType(typeof(ItemContentRefusal), "refusal")]
+[JsonDerivedType(typeof(ItemContentFunctionApprovalResponse), "function_approval_response")]
internal abstract class ItemContent
{
///
@@ -282,6 +283,48 @@ internal abstract class ItemContent
public object? RawRepresentation { get; set; }
}
+///
+/// A response to a function approval request.
+///
+internal sealed class ItemContentFunctionApprovalResponse : ItemContent
+{
+ ///
+ [JsonIgnore]
+ public override string Type => "function_approval_response";
+
+ ///
+ /// The unique identifier of the approval request.
+ ///
+ [JsonPropertyName("request_id")]
+ public required string RequestId { get; init; }
+
+ ///
+ /// Whether the function call was approved.
+ ///
+ [JsonPropertyName("approved")]
+ public required bool Approved { get; init; }
+
+ ///
+ /// The function call associated with the approval request.
+ ///
+ [JsonPropertyName("function_call")]
+ public required FunctionCallInfo FunctionCall
+ {
+ get;
+ init
+ {
+ // DevUI defines function_call.arguments as an object. Reject other JSON shapes while
+ // binding the request so malformed approval responses become HTTP 400 rather than 500.
+ if (value is null || value.Arguments.ValueKind != JsonValueKind.Object)
+ {
+ throw new JsonException("The 'function_call.arguments' property must be a JSON object.");
+ }
+
+ field = value;
+ }
+ }
+}
+
///
/// Text input content.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs
index f68fa12e4d1..16108486d13 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs
@@ -41,8 +41,10 @@ public override IEnumerable ProcessContent(AIContent con
{
Id = functionCall.CallId,
Name = functionCall.Name,
+ // DevUI requires an arguments object. Represent a no-argument call as an empty object
+ // so its response can pass the same strict wire validation as calls with arguments.
Arguments = JsonSerializer.SerializeToElement(
- functionCall.Arguments,
+ functionCall.Arguments ?? new Dictionary(),
jsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))
}
};
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
index d51371f24f3..67fc3d03a4f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
@@ -8,6 +8,7 @@
using Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.AspNetCore.Http.Json;
+using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
@@ -42,6 +43,12 @@ public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollectio
/// . Hosts serving multiple callers should register a provider,
/// require authentication on the mapped endpoints, and use a stable claim that uniquely identifies the caller.
/// Without a provider, all callers share the same in-memory namespace.
+ ///
+ /// Agents that expose tools must also
+ /// configure an . The store preserves the server-recorded approval request
+ /// across HTTP requests so an incoming decision can be matched to the exact function call shown to the user.
+ /// A function_approval_response request is rejected when no session store is configured.
+ ///
///
public static IServiceCollection AddOpenAIResponses(this IServiceCollection services)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs
index a0396feeccf..30cf4b102fe 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs
@@ -27,21 +27,38 @@ namespace Microsoft.Agents.AI.Hosting;
public class AIHostAgent : DelegatingAIAgent
{
private readonly AgentSessionStore _sessionStore;
+ private string? _capturedIsolationKey;
///
/// Initializes a new instance of the class.
///
/// The underlying agent implementation to wrap.
/// The session store to use for persisting conversation state.
+ ///
+ /// The optional logical hosting identity used only to partition persisted sessions across agent instances.
+ /// It does not replace or .
+ ///
///
/// or is .
///
- public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore)
+ public AIHostAgent(
+ AIAgent innerAgent,
+ AgentSessionStore sessionStore,
+ string? sessionStorageIdentity = null)
: base(innerAgent)
{
this._sessionStore = Throw.IfNull(sessionStore);
+ this.SessionStorageIdentity = sessionStorageIdentity is null
+ ? null
+ : Throw.IfNullOrWhitespace(sessionStorageIdentity);
}
+ ///
+ /// Gets the stable logical identity used to partition session storage, or when
+ /// session storage follows the wrapped agent instance identity.
+ ///
+ internal string? SessionStorageIdentity { get; }
+
///
/// Gets an existing agent session for the specified conversation, or creates a new one if none exists.
///
@@ -66,10 +83,9 @@ public ValueTask GetOrCreateSessionAsync(
_ = Throw.IfNull(key);
MarkFeatureUsed();
- return this._sessionStore.GetOrCreateSessionAsync(
- this.InnerAgent,
- key,
- cancellationToken);
+ return this._capturedIsolationKey is null
+ ? this._sessionStore.GetOrCreateSessionAsync(this, key, cancellationToken)
+ : this.GetOrCreateSessionWithCapturedIsolationKeyAsync(key, cancellationToken);
}
///
@@ -100,11 +116,51 @@ public ValueTask SaveSessionAsync(
_ = Throw.IfNull(session);
MarkFeatureUsed();
- return this._sessionStore.SaveSessionAsync(
- this.InnerAgent,
- key,
- session,
- cancellationToken);
+ return this._capturedIsolationKey is null
+ ? this._sessionStore.SaveSessionAsync(this, key, session, cancellationToken)
+ : this.SaveSessionWithCapturedIsolationKeyAsync(key, session, cancellationToken);
+ }
+
+ ///
+ /// Creates a host agent whose isolation-aware session store uses a key captured from the originating request.
+ ///
+ /// The trusted caller isolation key, or when isolation does not apply.
+ ///
+ /// A host agent bound to , or this instance when its store does not use
+ /// anywhere in its decorator pipeline or no key was supplied.
+ ///
+ public AIHostAgent BindIsolationKey(string? isolationKey)
+ {
+ if (isolationKey is null ||
+ this._sessionStore.GetService() is null)
+ {
+ return this;
+ }
+
+ // Keep the configured decorator pipeline intact. Session operations flow the captured key
+ // to the isolation layer when they reach it, even when other decorators wrap that layer.
+ var boundAgent = new AIHostAgent(this.InnerAgent, this._sessionStore, this.SessionStorageIdentity);
+ boundAgent._capturedIsolationKey = Throw.IfNullOrWhitespace(isolationKey);
+ return boundAgent;
+ }
+
+ private async ValueTask GetOrCreateSessionWithCapturedIsolationKeyAsync(
+ AgentSessionStoreKey key,
+ CancellationToken cancellationToken)
+ {
+ using IDisposable isolationScope =
+ IsolationKeyScopedAgentSessionStore.UseCapturedIsolationKey(this._capturedIsolationKey!);
+ return await this._sessionStore.GetOrCreateSessionAsync(this, key, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async ValueTask SaveSessionWithCapturedIsolationKeyAsync(
+ AgentSessionStoreKey key,
+ AgentSession session,
+ CancellationToken cancellationToken)
+ {
+ using IDisposable isolationScope =
+ IsolationKeyScopedAgentSessionStore.UseCapturedIsolationKey(this._capturedIsolationKey!);
+ await this._sessionStore.SaveSessionAsync(this, key, session, cancellationToken).ConfigureAwait(false);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs
index c6772d5bba3..89b8778a65f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs
@@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI.Hosting;
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private const string IsolationPartitionName = "isolation";
+ private static readonly AsyncLocal s_capturedIsolationKey = new();
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
@@ -55,9 +56,16 @@ public IsolationKeyScopedAgentSessionStore(
///
private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken)
{
- string? key = this._keyProvider != null
- ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
- : null;
+ // Background execution supplies the trusted request-time key through the async call flow.
+ // This takes precedence over ambient providers such as IHttpContextAccessor, whose request
+ // may already have ended by the time a nested isolation decorator is reached.
+ string? key = s_capturedIsolationKey.Value;
+ if (key is null)
+ {
+ key = this._keyProvider != null
+ ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
+ : null;
+ }
if (string.IsNullOrWhiteSpace(key))
{
@@ -85,6 +93,12 @@ private async ValueTask GetScopedKeyAsync(
return isolationKey is null ? key : key.WithPartition(IsolationPartitionName, isolationKey);
}
+ ///
+ /// Overrides ambient isolation lookup for the current asynchronous operation.
+ ///
+ internal static IDisposable UseCapturedIsolationKey(string isolationKey) =>
+ new CapturedIsolationKeyScope(Throw.IfNullOrWhitespace(isolationKey));
+
///
public override async ValueTask GetSessionAsync(
AIAgent agent,
@@ -115,4 +129,25 @@ public override async ValueTask SaveSessionAsync(
AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false);
await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false);
}
+
+ private sealed class CapturedIsolationKeyScope : IDisposable
+ {
+ private readonly string? _previousIsolationKey;
+ private bool _disposed;
+
+ public CapturedIsolationKeyScope(string isolationKey)
+ {
+ this._previousIsolationKey = s_capturedIsolationKey.Value;
+ s_capturedIsolationKey.Value = isolationKey;
+ }
+
+ public void Dispose()
+ {
+ if (!this._disposed)
+ {
+ s_capturedIsolationKey.Value = this._previousIsolationKey;
+ this._disposed = true;
+ }
+ }
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
index 084c9eaabce..56dc7ee3bbf 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
@@ -41,7 +41,7 @@ namespace Microsoft.Agents.AI.Hosting;
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
- private readonly ConcurrentDictionary<(string AgentId, AgentSessionStoreKey Key), JsonElement> _sessions = new();
+ private readonly ConcurrentDictionary<(string AgentIdentity, AgentSessionStoreKey Key), JsonElement> _sessions = new();
///
public override async ValueTask SaveSessionAsync(
@@ -54,7 +54,7 @@ public override async ValueTask SaveSessionAsync(
_ = Throw.IfNull(key);
_ = Throw.IfNull(session);
- var storageKey = (agent.Id, key);
+ var storageKey = (GetStorageIdentity(agent), key);
this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
@@ -67,8 +67,18 @@ public override async ValueTask SaveSessionAsync(
_ = Throw.IfNull(agent);
_ = Throw.IfNull(key);
- return this._sessions.TryGetValue((agent.Id, key), out JsonElement existingSession)
+ return this._sessions.TryGetValue((GetStorageIdentity(agent), key), out JsonElement existingSession)
? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false)
: null;
}
+
+ private static string GetStorageIdentity(AIAgent agent)
+ {
+ // Hosted execution can supply a logical storage identity that survives transient agent instances.
+ // Direct agents retain process-local instance isolation through their generated ID.
+ string? sessionStorageIdentity = agent.GetService()?.SessionStorageIdentity;
+ return sessionStorageIdentity is null
+ ? $"id:{agent.Id}"
+ : $"host:{sessionStorageIdentity}";
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs
index 054aa38b237..e12370a53ca 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs
@@ -73,7 +73,11 @@ public static Workflow Build(
string rootId = WorkflowActionVisitor.Steps.Root(workflowElement);
WorkflowFormulaState state = new(options.CreateRecalcEngine());
- state.Initialize(workflowElement.WrapWithBot(), options.Configuration);
+ state.Initialize(
+ workflowElement.WrapWithBot(),
+ options.Configuration,
+ options.AllowedEnvironmentVariables,
+ options.AllowProcessEnvironmentVariableFallback);
state.CaptureInitialState();
DeclarativeWorkflowExecutor rootExecutor =
new(rootId,
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
index 90439402dbd..b5b05b910d3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Extensions.Configuration;
@@ -37,6 +38,16 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
///
public IConfiguration? Configuration { get; init; }
+ ///
+ /// Gets the configuration or process environment variable names that may be exposed through the workflow Env scope.
+ ///
+ public IEnumerable? AllowedEnvironmentVariables { get; init; }
+
+ ///
+ /// Gets a value indicating whether the workflow may fall back to process environment variables for allowed Env names missing from .
+ ///
+ public bool AllowProcessEnvironmentVariableFallback { get; init; }
+
///
/// Optionally identifies a continued workflow conversation.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs
index c9fbe20b8f7..fff360570f3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs
@@ -38,7 +38,9 @@ internal ExternalInputRequest(string text)
/// requireApproval: true) over so that
/// hosts which speak the approval protocol see the approval-bearing content.
///
- AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
+ AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.GetInnerRequestContent();
+
+ private AIContent? GetInnerRequestContent()
{
IList? messages = this.AgentResponse?.Messages;
if (messages is null)
@@ -73,5 +75,16 @@ internal ExternalInputRequest(string text)
///
object IExternalRequestEnvelope.CreateResponse(IList messages)
- => new ExternalInputResponse(messages);
+ => new ExternalInputResponse(messages)
+ {
+ RequestId = GetContentId(this.GetInnerRequestContent()),
+ };
+
+ private static string? GetContentId(AIContent? content)
+ => content switch
+ {
+ ToolCallContent functionCall => functionCall.CallId,
+ InputRequestContent toolApprovalRequest => toolApprovalRequest.RequestId,
+ _ => null,
+ };
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs
index 0653a12ce5b..fe13433b3ae 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs
@@ -16,6 +16,12 @@ public sealed class ExternalInputResponse
///
public IList Messages { get; }
+ ///
+ /// Gets the request id that produced this response, if available.
+ ///
+ [JsonInclude]
+ public string? RequestId { get; internal init; }
+
internal bool HasMessages => this.Messages?.Count > 0;
///
@@ -23,8 +29,8 @@ public sealed class ExternalInputResponse
///
/// The external input message being provided to the workflow.
public ExternalInputResponse(ChatMessage message)
+ : this([message], requestId: null)
{
- this.Messages = [message];
}
///
@@ -33,7 +39,14 @@ public ExternalInputResponse(ChatMessage message)
/// The external input messages being provided to the workflow.
[JsonConstructor]
public ExternalInputResponse(IList messages)
+ : this(messages, requestId: null)
+ {
+ }
+
+ internal ExternalInputResponse(IList messages, string? requestId)
{
this.Messages = messages;
+ this.RequestId = requestId;
}
+
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
index bd9d590987e..816c8abf646 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
@@ -16,13 +16,14 @@ public static async ValueTask InvokeAgentAsync(
string executorId,
IWorkflowContext context,
string agentName,
+ string? agentVersion,
string? conversationId,
bool autoSend,
IEnumerable? inputMessages = null,
IDictionary? inputArguments = null,
CancellationToken cancellationToken = default)
{
- IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
+ IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, agentVersion, conversationId, inputMessages, inputArguments, cancellationToken);
// Foundry managed workflows treat responses produced on the workflow conversation
// as workflow output even when autoSend is explicitly false. Preserve that direct-run
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs
index 1b92235eee3..f7b2bfce695 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs
@@ -38,17 +38,38 @@ public static ValueTask QueueStateResetAsync(this IWorkflowContext context, Prop
public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) =>
context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken);
+ public static ValueTask QueueStateUpdateAsync(
+ this IWorkflowContext context,
+ PropertyPath variablePath,
+ TValue? value,
+ SensitivityLevel sensitivity,
+ CancellationToken cancellationToken = default)
+ {
+ string variableName = Throw.IfNull(variablePath.VariableName);
+ string namespaceAlias = Throw.IfNull(variablePath.NamespaceAlias);
+
+ return context is DeclarativeWorkflowContext declarativeContext
+ ? declarativeContext.QueueStateUpdateAsync(variableName, value, namespaceAlias, sensitivity, cancellationToken)
+ : context.QueueStateUpdateAsync(variableName, value, namespaceAlias, cancellationToken);
+ }
+
public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default)
{
DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context);
- await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.Environment, allowSystem: true, cancellationToken).ConfigureAwait(false);
+ await declarativeContext.UpdateStateAsync(
+ key,
+ value,
+ VariableScopeNames.Environment,
+ allowSystem: true,
+ sensitivity: SensitivityLevel.Sensitive,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
declarativeContext.State.Bind();
}
public static async ValueTask QueueSystemUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default)
{
DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context);
- await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken).ConfigureAwait(false);
+ await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken: cancellationToken).ConfigureAwait(false);
declarativeContext.State.Bind();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
index 5d052c64d3d..597ea831296 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs
@@ -24,8 +24,6 @@ internal abstract class DeclarativeActionExecutor(TAction model, Workfl
internal abstract class DeclarativeActionExecutor : Executor, IResettableExecutor, IModeledAction
{
- private readonly WorkflowFormulaState _state;
-
protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState state)
: base(model.Id.Value)
{
@@ -34,7 +32,7 @@ protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState sta
throw new DeclarativeModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name}).");
}
- this._state = state;
+ this.State = state;
this.Model = model;
}
@@ -51,9 +49,11 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui
public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); }
- public RecalcEngine Engine => this._state.Engine;
+ public RecalcEngine Engine => this.State.Engine;
+
+ public WorkflowExpressionEngine Evaluator => this.State.Evaluator;
- public WorkflowExpressionEngine Evaluator => this._state.Evaluator;
+ protected WorkflowFormulaState State { get; }
internal ILogger Logger { get; set; } = NullLogger.Instance;
@@ -64,7 +64,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui
///
public virtual ValueTask ResetAsync()
{
- this._state.Reset();
+ this.State.Reset();
return default;
}
@@ -89,7 +89,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf
try
{
- object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._state), cancellationToken).ConfigureAwait(false);
+ object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this.State), cancellationToken).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}");
if (this.EmitResultEvent)
@@ -123,19 +123,21 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf
/// This must be overridden to restore any state that was saved during checkpointing.
///
protected override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) =>
- this._state.RestoreAsync(context, cancellationToken);
+ this.State.RestoreAsync(context, cancellationToken);
- protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context)
+ protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context, SensitivityLevel sensitivity = SensitivityLevel.None)
{
if (targetPath is null)
{
return;
}
- await context.QueueStateUpdateAsync(targetPath, result).ConfigureAwait(false);
+ await context.QueueStateUpdateAsync(targetPath, result, sensitivity).ConfigureAwait(false);
+ string variableName = targetPath.VariableName ?? throw new DeclarativeActionException($"Invalid variable reference: '{targetPath}'.");
+ this.State.SetSensitivity(variableName, targetPath.NamespaceAlias, sensitivity);
#if DEBUG
- string? resultValue = result.Format();
+ string? resultValue = sensitivity == SensitivityLevel.Sensitive ? "" : result.Format();
string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " ";
Debug.WriteLine(
$"""
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
index 6616aa5d00c..183b2e785e7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs
@@ -58,7 +58,7 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati
// Copy keys to array to avoid modifying collection during enumeration.
foreach (string key in this.State.Keys(scopeName).ToArray())
{
- await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false);
+ await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
else
@@ -73,7 +73,18 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati
///
public async ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
- await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false);
+ await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false);
+ this.State.Bind();
+ }
+
+ internal async ValueTask QueueStateUpdateAsync(
+ string key,
+ T? value,
+ string? scopeName,
+ SensitivityLevel sensitivity,
+ CancellationToken cancellationToken = default)
+ {
+ await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, sensitivity: sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false);
this.State.Bind();
}
@@ -137,7 +148,13 @@ public ValueTask> ReadStateKeysAsync(string? scopeName = null, C
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
=> this.Source.SendMessageAsync(message, targetId, cancellationToken);
- public ValueTask UpdateStateAsync(string key, T? value, string? scopeName, bool allowSystem, CancellationToken cancellationToken = default)
+ public ValueTask UpdateStateAsync(
+ string key,
+ T? value,
+ string? scopeName,
+ bool allowSystem,
+ SensitivityLevel sensitivity = SensitivityLevel.None,
+ CancellationToken cancellationToken = default)
{
bool isManagedScope =
scopeName is not null && // null scope cannot be managed
@@ -165,47 +182,61 @@ scopeName is not null && // null scope cannot be managed
_ => QueueNativeStateAsync(value),
};
- ValueTask QueueEmptyStateAsync()
+ async ValueTask QueueEmptyStateAsync()
{
if (isManagedScope)
{
- this.State.Set(key, FormulaValue.NewBlank(), scopeName);
+ this.State.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity);
}
- return this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken);
+ await this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken).ConfigureAwait(false);
+ await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false);
}
- ValueTask QueueFormulaStateAsync(FormulaValue formulaValue)
+ async ValueTask QueueFormulaStateAsync(FormulaValue formulaValue)
{
if (isManagedScope)
{
- this.State.Set(key, formulaValue, scopeName);
+ this.State.Set(key, formulaValue, scopeName, sensitivity);
}
- return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
+ await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false);
+ await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false);
}
- ValueTask QueueDataValueStateAsync(DataValue dataValue)
+ async ValueTask QueueDataValueStateAsync(DataValue dataValue)
{
FormulaValue formulaValue = dataValue.ToFormula();
if (isManagedScope)
{
- this.State.Set(key, formulaValue, scopeName);
+ this.State.Set(key, formulaValue, scopeName, sensitivity);
}
- return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
+ await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false);
+ await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false);
}
- ValueTask QueueNativeStateAsync(object rawValue)
+ async ValueTask QueueNativeStateAsync(object rawValue)
{
FormulaValue formulaValue = rawValue.ToFormula();
if (isManagedScope)
{
- this.State.Set(key, formulaValue, scopeName);
+ this.State.Set(key, formulaValue, scopeName, sensitivity);
}
- return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
+ await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false);
+ await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private ValueTask QueueSensitivityUpdateAsync(string key, string? scopeName, SensitivityLevel sensitivity, CancellationToken cancellationToken)
+ {
+ if (scopeName is null || (!ManagedScopes.Contains(scopeName) && scopeName != VariableScopeNames.Environment))
+ {
+ return default;
}
+
+ return this.Source.QueueStateUpdateAsync(key, sensitivity, WorkflowFormulaState.GetSensitivityScopeName(scopeName), cancellationToken);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs
index aea534a258b..692787ab936 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs
@@ -33,5 +33,5 @@ protected ValueTask InvokeAgentAsync(
bool autoSend,
IEnumerable? inputMessages = null,
CancellationToken cancellationToken = default)
- => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken);
+ => agentProvider.InvokeAgentAsync(this.Id, context, agentName, agentVersion: null, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs
index 69cca12fafd..3217ea774af 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs
@@ -36,6 +36,27 @@ public static class IWorkflowContextExtensions
public static ValueTask FormatTemplateAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken = default) =>
context.FormatTemplateAsync([line], cancellationToken);
+ ///
+ /// Formats a template line using the workflow's declarative state
+ /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line.
+ ///
+ /// The workflow execution context used to restore persisted state prior to formatting.
+ /// The template line to format.
+ /// The formatted line and its sensitivity metadata.
+ public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line) =>
+ context.FormatTemplateWithSensitivityAsync(line, default);
+
+ ///
+ /// Formats a template line using the workflow's declarative state
+ /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line.
+ ///
+ /// The workflow execution context used to restore persisted state prior to formatting.
+ /// The template line to format.
+ /// A token that propagates notification when operation should be canceled.
+ /// The formatted line and its sensitivity metadata.
+ public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken) =>
+ context.FormatTemplateWithSensitivityAsync([line], cancellationToken);
+
///
/// Formats a template lines using the workflow's declarative state
/// and evaluating any embedded expressions (e.g., Power Fx) contained within each line.
@@ -52,16 +73,44 @@ public static ValueTask FormatTemplateAsync(this IWorkflowContext contex
/// var text = await context.FormatAsync("Hello @{User.Name}", "Count: @{Metrics.Count}");
///
public static async ValueTask FormatTemplateAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken = default)
+ {
+ EvaluationResult result = await context.FormatTemplateWithSensitivityAsync(lines, cancellationToken).ConfigureAwait(false);
+ ThrowIfSensitive(result.Sensitivity);
+ return result.Value;
+ }
+
+ ///
+ /// Formats a template lines using the workflow's declarative state
+ /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line.
+ ///
+ /// The workflow execution context used to restore persisted state prior to formatting.
+ /// The template lines to format.
+ /// The formatted lines and their sensitivity metadata.
+ public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines) =>
+ context.FormatTemplateWithSensitivityAsync(lines, default);
+
+ ///
+ /// Formats a template lines using the workflow's declarative state
+ /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line.
+ ///
+ /// The workflow execution context used to restore persisted state prior to formatting.
+ /// The template lines to format.
+ /// A token that propagates notification when operation should be canceled.
+ /// The formatted lines and their sensitivity metadata.
+ public static async ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken)
{
WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false);
StringBuilder builder = new();
+ SensitivityLevel sensitivity = SensitivityLevel.None;
foreach (string line in lines)
{
- builder.AppendLine(state.Engine.Format(TemplateLine.Parse(line)));
+ EvaluationResult result = state.Evaluator.Format(TemplateLine.Parse(line));
+ sensitivity = MaxSensitivity(sensitivity, result.Sensitivity);
+ builder.AppendLine(result.Value);
}
- return builder.ToString();
+ return new(builder.ToString(), sensitivity);
}
///
@@ -82,12 +131,27 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext
/// A token that propagates notification when operation should be canceled.
/// The evaluated expression value
public static async ValueTask EvaluateValueAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default)
+ {
+ EvaluationResult result = await context.EvaluateValueWithSensitivityAsync(expression, cancellationToken).ConfigureAwait(false);
+ ThrowIfSensitive(result.Sensitivity);
+ return result.Value;
+ }
+
+ ///
+ /// Evaluate an expression using the workflow's declarative state.
+ ///
+ /// The type of the evaluated value.
+ /// The workflow execution context used to restore persisted state prior to formatting.
+ /// The expression to evaluate.
+ /// A token that propagates notification when operation should be canceled.
+ /// The evaluated expression value and its sensitivity metadata.
+ public static async ValueTask> EvaluateValueWithSensitivityAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default)
{
WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false);
EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression));
- return (TValue?)result.Value.ToObject();
+ return new((TValue?)result.Value.ToObject(), result.Sensitivity);
}
///
@@ -103,10 +167,74 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext
WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false);
EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression));
+ ThrowIfSensitive(result.Sensitivity);
return result.Value.AsList();
}
+ ///
+ /// Reads a state value together with its sensitivity metadata.
+ ///
+ /// The type of the state value.
+ /// The workflow execution context used to read state.
+ /// The key of the state value.
+ /// An optional name that specifies the scope to read. If null, the default scope is used.
+ /// A token that propagates notification when operation should be canceled.
+ /// The state value and its sensitivity metadata.
+ public static async ValueTask> ReadStateWithSensitivityAsync(
+ this IWorkflowContext context,
+ string key,
+ string? scopeName = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (context is DeclarativeWorkflowContext declarativeContext)
+ {
+ string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName;
+ TValue? declarativeValue = await context.ReadStateAsync(key, effectiveScopeName, cancellationToken).ConfigureAwait(false);
+ SensitivityLevel declarativeSensitivity = declarativeContext.State.GetSensitivity(key, effectiveScopeName);
+ return new(declarativeValue, declarativeSensitivity);
+ }
+
+ string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName;
+ TValue? value = await context.ReadStateAsync(key, plainScopeName, cancellationToken).ConfigureAwait(false);
+ SensitivityLevel sensitivity = ShouldPersistSensitivity(plainScopeName)
+ ? await context.ReadStateAsync(key, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false)
+ : SensitivityLevel.None;
+ return new(value, sensitivity);
+ }
+
+ ///
+ /// Queues a state update using sensitivity metadata carried with the value.
+ ///
+ /// The type of the state value.
+ /// The workflow execution context used to queue state updates.
+ /// The key of the state value.
+ /// The value and sensitivity metadata to store.
+ /// An optional name that specifies the scope to update. If null, the default scope is used.
+ /// A token that propagates notification when operation should be canceled.
+ /// A task representing the queued state update.
+ public static async ValueTask QueueStateUpdateWithSensitivityAsync(
+ this IWorkflowContext context,
+ string key,
+ EvaluationResult value,
+ string? scopeName = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (context is DeclarativeWorkflowContext declarativeContext)
+ {
+ string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName;
+ await declarativeContext.QueueStateUpdateAsync(key, value.Value, effectiveScopeName, value.Sensitivity, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName;
+ await context.QueueStateUpdateAsync(key, value.Value, plainScopeName, cancellationToken).ConfigureAwait(false);
+ if (ShouldPersistSensitivity(plainScopeName))
+ {
+ await context.QueueStateUpdateAsync(key, value.Sensitivity, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
///
/// Convert the result of an expression to the specified target type.
///
@@ -127,7 +255,7 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext
/// The workflow execution context used to restore persisted state prior to formatting.
/// Describes the target type for the value conversion.
/// The key of the state value.
- /// An optional name that specifies the scope to read.If null, the default scope is used.
+ /// An optional name that specifies the scope to read. If null, the default scope is used.
/// A token that propagates notification when operation should be canceled.
/// The converted value
public static async ValueTask
/// The workflow execution context providing messaging and state services.
/// The set of variable names to initialize.
/// A representing the asynchronous execution operation.
protected async ValueTask InitializeEnvironmentAsync(IWorkflowContext context, params string[] variableNames)
{
- foreach (string variableName in variableNames)
+ foreach (string variableName in variableNames.Where(this._allowedEnvironmentVariables.Contains))
{
await context.QueueEnvironmentUpdateAsync(variableName, GetEnvironmentVariable(variableName)).ConfigureAwait(false);
}
string GetEnvironmentVariable(string name)
{
- if (this._configuration is not null)
- {
- return this._configuration[name] ?? string.Empty;
- }
-
- return Environment.GetEnvironmentVariable(name) ?? string.Empty;
+ string? configurationValue = this._configuration?[name];
+ return configurationValue ?? (this._allowProcessEnvironmentVariableFallback ? Environment.GetEnvironmentVariable(name) ?? string.Empty : string.Empty);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs
index 21c14de546f..16740053de6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs
@@ -7,6 +7,7 @@
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -42,7 +43,13 @@ private IEnumerable GetContent()
{
foreach (AddConversationMessageContent content in this.Model.Content)
{
- AIContent? messageContent = content.Type.Value.ToContent(this.Engine.Format(content.Value), content.MediaType);
+ EvaluationResult contentResult = this.Evaluator.Format(content.Value);
+ if (contentResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive conversation message content: {this.Id}.");
+ }
+
+ AIContent? messageContent = content.Type.Value.ToContent(contentResult.Value, content.MediaType);
if (messageContent is not null)
{
yield return messageContent;
@@ -57,8 +64,12 @@ private IEnumerable GetContent()
return null;
}
- RecordDataValue? metadataValue = this.Evaluator.GetValue(this.Model.Metadata).Value;
+ EvaluationResult metadataResult = this.Evaluator.GetValue(this.Model.Metadata);
+ if (metadataResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive conversation message metadata: {this.Id}.");
+ }
- return metadataValue.ToMetadata();
+ return metadataResult.Value.ToMetadata();
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
index 381abcb84dc..3b63ffeb26a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
@@ -45,6 +45,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages
Throw.IfNull(this.Model.Messages, $"{nameof(this.Model)}.{nameof(this.Model.Messages)}");
EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages);
+ if (expressionResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive conversation messages: {this.Id}.");
+ }
+
DataValue messages = expressionResult.Value;
return messages.ToChatMessages();
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs
index 41fd8468e0e..e110ee4272a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs
@@ -25,6 +25,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
{
throw this.Exception($"Require '{variablePath}' to be a table, not: '{table.GetType().Name}'.");
}
+ SensitivityLevel tableSensitivity = this.GetSensitivity(variablePath);
TableChangeType changeType = this.Model.ChangeType.Value;
switch (this.Model.ChangeType.Value)
@@ -33,6 +34,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
EvaluationResult addResult = this.Evaluator.GetValue(addItemValue);
FormulaValue addValue = addResult.Value.ToFormula();
+ SensitivityLevel addSensitivity = MaxSensitivity(tableSensitivity, addResult.Sensitivity);
RecordType recordType = tableValue.Type.ToRecord();
RecordValue newRecord;
TableValue resultTable;
@@ -47,35 +49,36 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
resultTable = tableValue;
}
- await this.AssignAsync(variablePath, resultTable, context).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context).ConfigureAwait(false);
+ await this.AssignAsync(variablePath, resultTable, context, addSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context, addSensitivity).ConfigureAwait(false);
break;
case TableChangeType.Remove:
ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
EvaluationResult removeResult = this.Evaluator.GetValue(removeItemValue);
+ SensitivityLevel removeSensitivity = MaxSensitivity(tableSensitivity, removeResult.Sensitivity);
if (removeResult.Value is TableDataValue removeItemTable)
{
await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context).ConfigureAwait(false);
+ await this.AssignAsync(variablePath, tableValue, context, removeSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context, removeSensitivity).ConfigureAwait(false);
}
break;
case TableChangeType.Clear:
await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
+ await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false);
break;
case TableChangeType.TakeFirst:
RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value;
if (firstRow is not null)
{
await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context).ConfigureAwait(false);
+ await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false);
}
else
{
- await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false);
}
break;
case TableChangeType.TakeLast:
@@ -83,12 +86,12 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
if (lastRow is not null)
{
await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context).ConfigureAwait(false);
+ await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false);
}
else
{
- await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false);
}
break;
}
@@ -120,4 +123,10 @@ IEnumerable GetValues()
}
}
}
+
+ private SensitivityLevel GetSensitivity(PropertyPath? path) =>
+ path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None;
+
+ private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) =>
+ left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs
index 79b32428a87..7475916bf11 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs
@@ -25,6 +25,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
{
throw this.Exception($"Require '{this.Model.ItemsVariable.Path}' to be a table, not: '{table.GetType().Name}'.");
}
+ SensitivityLevel tableSensitivity = this.GetSensitivity(this.Model.ItemsVariable);
EditTableOperation? changeType = this.Model.ChangeType;
if (changeType is AddItemOperation addItemOperation)
@@ -32,6 +33,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}");
EvaluationResult expressionResult = this.Evaluator.GetValue(addItemValue);
FormulaValue addValue = expressionResult.Value.ToFormula();
+ SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity);
RecordType recordType = tableValue.Type.ToRecord();
TableValue resultTable;
if (!recordType.FieldNames.Any() && !tableValue.Rows.Any())
@@ -45,21 +47,22 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
resultTable = tableValue;
}
- await this.AssignAsync(this.Model.ItemsVariable, resultTable, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ItemsVariable, resultTable, context, mutationSensitivity).ConfigureAwait(false);
}
else if (changeType is ClearItemsOperation)
{
await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false);
}
else if (changeType is RemoveItemOperation removeItemOperation)
{
ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}");
EvaluationResult expressionResult = this.Evaluator.GetValue(removeItemValue);
+ SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity);
if (expressionResult.Value.ToFormula() is TableValue removeItemTable)
{
await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, mutationSensitivity).ConfigureAwait(false);
}
}
else if (changeType is TakeLastItemOperation takeLastOperation)
@@ -68,12 +71,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
if (lastRow is not null)
{
await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false);
}
else
{
- await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
+ await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false);
}
}
else if (changeType is TakeFirstItemOperation takeFirstOperation)
@@ -82,12 +85,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
if (firstRow is not null)
{
await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false);
- await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
- await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false);
+ await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false);
}
else
{
- await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
+ await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false);
}
}
@@ -118,4 +121,10 @@ IEnumerable GetValues()
}
}
}
+
+ private SensitivityLevel GetSensitivity(PropertyPath? path) =>
+ path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None;
+
+ private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) =>
+ left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs
index f154ad7f97b..d829f0ba8ae 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs
@@ -26,9 +26,11 @@ public static class Steps
private const string IndexStateKey = nameof(_index);
private const string ValuesStateKey = nameof(_values);
private const string HasValueStateKey = nameof(HasValue);
+ private const string SensitivityStateKey = nameof(_sensitivity);
private int _index;
private FormulaValue[] _values;
+ private SensitivityLevel _sensitivity;
public ForeachExecutor(Foreach model, WorkflowFormulaState state)
: base(model, state)
@@ -55,6 +57,7 @@ public ForeachExecutor(Foreach model, WorkflowFormulaState state)
{
this._values = [expressionResult.Value.ToFormula()];
}
+ this._sensitivity = expressionResult.Sensitivity;
await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false);
@@ -67,7 +70,15 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel
{
FormulaValue value = this._values[this._index];
- await context.QueueStateUpdateAsync(Throw.IfNull(this.Model.Value), value, cancellationToken).ConfigureAwait(false);
+ PropertyPath valuePath = Throw.IfNull(this.Model.Value);
+ if (context is DeclarativeWorkflowContext)
+ {
+ await this.AssignAsync(valuePath, value, context, this._sensitivity).ConfigureAwait(false);
+ }
+ else
+ {
+ await context.QueueStateUpdateAsync(valuePath, value, cancellationToken).ConfigureAwait(false);
+ }
if (this.Model.Index is not null)
{
@@ -122,6 +133,7 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context
await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false);
+ await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false);
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
@@ -147,5 +159,6 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co
this._values = [.. savedValues.Select(value => value.ToFormula())];
this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
+ this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs
index 2b0378f9998..4b33bdc8fe7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -67,9 +68,10 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable? inputParameters = this.GetStructuredInputs();
- AgentResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false);
+ AgentResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, agentVersion, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false);
ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray();
if (actionableMessages.Length > 0)
@@ -154,6 +156,11 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable expressionResult = this.Evaluator.GetValue(this.AgentInput.Messages);
+ if (expressionResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive agent input messages: {this.Id}.");
+ }
+
userInput = expressionResult.Value;
}
@@ -202,6 +209,11 @@ private string GetAgentName() =>
this.AgentUsage.Name,
$"{nameof(this.Model)}.{nameof(this.Model.Agent)}.{nameof(this.Model.Agent.Name)}")).Value;
+ private string? GetAgentVersion() =>
+ this.AgentUsage.Version is null
+ ? null
+ : this.Evaluator.GetValue(this.AgentUsage.Version).Value.ToString(CultureInfo.InvariantCulture);
+
private bool GetAutoSendValue()
{
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs
index 386c3712b95..d849b168cbf 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs
@@ -29,6 +29,7 @@ internal sealed class InvokeFunctionToolExecutor(
DeclarativeActionExecutor(model, state)
{
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots);
+ private const string HasApprovalRequiredInvocationStateKey = nameof(_hasApprovalRequiredInvocation);
private const string PendingCallIdsStateKey = nameof(_pendingNonApprovalCallIds);
private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot";
@@ -39,6 +40,8 @@ internal sealed class InvokeFunctionToolExecutor(
///
private readonly ConcurrentDictionary _approvalSnapshots = new(StringComparer.Ordinal);
+ private bool _hasApprovalRequiredInvocation;
+
///
/// Per-invocation call ids for in-flight non-approval requests; used to match the
/// returning on the response path. Used as a set;
@@ -94,6 +97,7 @@ public static class Steps
// Capture the evaluated parameters keyed by request id; the matching response
// resumes from this snapshot.
this._approvalSnapshots[requestId] = new ApprovalSnapshot(functionName, arguments);
+ this._hasApprovalRequiredInvocation = true;
requestMessage.Contents.Add(new ToolApprovalRequestContent(requestId, functionCall));
}
@@ -125,24 +129,29 @@ public async ValueTask CaptureResponseAsync(
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
+ string? responseRequestId = response.RequestId;
// Match the inbound result by its per-invocation call id.
FunctionResultContent? matchingResult = response.Messages
.SelectMany(m => m.Contents)
.OfType()
- .FirstOrDefault(r => this.IsKnownPendingId(r.CallId));
+ .FirstOrDefault(r => this.IsCorrelatedPendingNonApprovalResult(r, responseRequestId));
+ FunctionResultContent? approvedFunctionResult = null;
- // Legacy non-approval backstop: when no pendings are tracked, accept a result
- // whose CallId equals this.Id. The runtime has already routed the response to
- // this executor's port and the framework does not invoke a function here.
+ // Legacy non-approval backstop: when no pendings are tracked and approval is
+ // not required, accept a result whose CallId equals this.Id. The runtime has
+ // already routed the response to this executor's port and the framework does
+ // not invoke a function here.
if (matchingResult is null
+ && !this._hasApprovalRequiredInvocation
&& this._pendingNonApprovalCallIds.IsEmpty
&& this._approvalSnapshots.IsEmpty)
{
matchingResult = response.Messages
.SelectMany(m => m.Contents)
.OfType()
- .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal));
+ .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal)
+ && IsCorrelatedResponse(r.CallId, responseRequestId));
}
// When the caller approved an approval-required function call but didn't execute it
@@ -160,8 +169,9 @@ public async ValueTask CaptureResponseAsync(
// Prefer an approval matching a pending snapshot; otherwise take the first
// present approval.
ToolApprovalResponseContent? approval =
- approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId))
- ?? approvals.FirstOrDefault();
+ approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId)
+ && IsCorrelatedResponse(r.RequestId, responseRequestId))
+ ?? approvals.FirstOrDefault(r => IsCorrelatedResponse(r.RequestId, responseRequestId));
if (approval is not null)
{
@@ -177,12 +187,17 @@ public async ValueTask CaptureResponseAsync(
else if (this._approvalSnapshots.TryRemove(approval.RequestId, out ApprovalSnapshot? snapshot))
{
matchingResult = await this.InvokeRegisteredFunctionAsync(approval.RequestId, snapshot, cancellationToken).ConfigureAwait(false);
+ approvedFunctionResult = matchingResult;
}
else
{
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
}
}
+ else if (!this._approvalSnapshots.IsEmpty)
+ {
+ await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
+ }
}
if (matchingResult is not null)
@@ -196,55 +211,77 @@ public async ValueTask CaptureResponseAsync(
AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
-
- // Drop the per-invocation entry now that the response has been processed.
- this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _);
- this._approvalSnapshots.TryRemove(matchingResult.CallId, out _);
}
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
- await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
+ await this.AssignAsync(
+ this.Model.Output.Messages?.Path,
+ this.GetSideEffectMessages(response).ToFormula(),
+ context).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
// Note: We transform messages containing FunctionResultContent or FunctionCallContent
- // to assistant text messages because workflow-generated CallIds don't correspond to
+ // to user text messages because workflow-generated CallIds don't correspond to
// actual AI-generated tool calls and would be rejected by the API.
if (conversationId is not null)
{
- foreach (ChatMessage message in TransformConversationMessages(response.Messages))
+ IEnumerable sideEffectMessages = this.GetSideEffectMessages(response);
+ if (approvedFunctionResult is not null)
+ {
+ sideEffectMessages = sideEffectMessages.Append(new ChatMessage(ChatRole.Tool, [approvedFunctionResult]));
+ }
+
+ foreach (ChatMessage message in TransformConversationMessages(sideEffectMessages))
{
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
}
}
+ if (matchingResult is not null)
+ {
+ // Drop the per-invocation entry now that the response side effects have been processed.
+ this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _);
+ this._approvalSnapshots.TryRemove(matchingResult.CallId, out _);
+ }
+
// Completes the action after processing the function result.
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
- private bool IsKnownPendingId(string callId) =>
- this._pendingNonApprovalCallIds.ContainsKey(callId) || this._approvalSnapshots.ContainsKey(callId);
+ private IEnumerable GetSideEffectMessages(ExternalInputResponse response)
+ {
+ if (!this._hasApprovalRequiredInvocation)
+ {
+ return response.Messages;
+ }
+
+ return this.FilterRejectedApprovalResults(response);
+ }
///
public override ValueTask ResetAsync()
{
this._approvalSnapshots.Clear();
+ this._hasApprovalRequiredInvocation = false;
this._pendingNonApprovalCallIds.Clear();
return default;
}
///
///
- /// Persists pending approval snapshots and non-approval call ids so they survive
- /// checkpoint/restore cycles.
+ /// Persists approval classification, pending approval snapshots, and non-approval
+ /// call ids so they survive checkpoint/restore cycles.
///
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Dictionary snapshotCopy = this._approvalSnapshots.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, snapshotCopy, null, cancellationToken).ConfigureAwait(false);
+ await context.QueueStateUpdateAsync(HasApprovalRequiredInvocationStateKey, this._hasApprovalRequiredInvocation, null, cancellationToken).ConfigureAwait(false);
+
List pendingCopy = [.. this._pendingNonApprovalCallIds.Keys];
await context.QueueStateUpdateAsync(PendingCallIdsStateKey, pendingCopy, null, cancellationToken).ConfigureAwait(false);
@@ -259,7 +296,6 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
-
this._approvalSnapshots.Clear();
Dictionary? snapshots = await context.ReadStateAsync>(
ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
@@ -271,6 +307,10 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co
}
}
+ this._hasApprovalRequiredInvocation =
+ await context.ReadStateAsync(HasApprovalRequiredInvocationStateKey, null, cancellationToken).ConfigureAwait(false)
+ || !this._approvalSnapshots.IsEmpty;
+
this._pendingNonApprovalCallIds.Clear();
List? pending = await context.ReadStateAsync>(
PendingCallIdsStateKey, null, cancellationToken).ConfigureAwait(false);
@@ -289,14 +329,15 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co
if (legacy is not null)
{
this._approvalSnapshots.TryAdd(this.Id, legacy);
+ this._hasApprovalRequiredInvocation = true;
await context.QueueStateUpdateAsync(
LegacyApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
}
}
///
- /// Transforms messages containing function-related content to assistant text messages.
- /// Messages with FunctionResultContent are converted to assistant messages with the result as text.
+ /// Transforms messages containing function-related content to user text messages.
+ /// Messages with FunctionResultContent are converted to user messages with the result as text.
/// Messages with only FunctionCallContent are excluded as they have no informational value.
///
private static IEnumerable TransformConversationMessages(IEnumerable messages)
@@ -309,7 +350,8 @@ private static IEnumerable TransformConversationMessages(IEnumerabl
if (hasFunctionResult)
{
- // Convert function results to assistant text message
+ // Convert function results to user text messages so externally supplied results
+ // are not attributed to the assistant.
List updatedContents = [];
foreach (AIContent content in message.Contents)
{
@@ -330,7 +372,7 @@ private static IEnumerable TransformConversationMessages(IEnumerabl
if (updatedContents.Count > 0)
{
- yield return new ChatMessage(ChatRole.Assistant, updatedContents);
+ yield return new ChatMessage(ChatRole.User, updatedContents);
}
}
else if (!hasFunctionCall)
@@ -341,6 +383,41 @@ private static IEnumerable TransformConversationMessages(IEnumerabl
}
}
+ private IEnumerable FilterRejectedApprovalResults(ExternalInputResponse response)
+ {
+ foreach (ChatMessage message in response.Messages)
+ {
+ List contents =
+ [.. message.Contents.Where(c => c is not ToolApprovalResponseContent
+ && (c is not FunctionResultContent functionResult
+ || this.IsCorrelatedPendingNonApprovalResult(functionResult, response.RequestId)))];
+ if (contents.Count == message.Contents.Count)
+ {
+ yield return message;
+ }
+ else if (contents.Count > 0)
+ {
+ yield return new ChatMessage
+ {
+ Role = message.Role,
+ AuthorName = message.AuthorName,
+ Contents = contents,
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ RawRepresentation = message.RawRepresentation,
+ AdditionalProperties = message.AdditionalProperties,
+ };
+ }
+ }
+ }
+
+ private bool IsCorrelatedPendingNonApprovalResult(FunctionResultContent functionResult, string? responseRequestId) =>
+ this._pendingNonApprovalCallIds.ContainsKey(functionResult.CallId)
+ && IsCorrelatedResponse(functionResult.CallId, responseRequestId);
+
+ private static bool IsCorrelatedResponse(string contentId, string? responseRequestId) =>
+ responseRequestId is null || string.Equals(contentId, responseRequestId, StringComparison.Ordinal);
+
private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result)
{
if (this.Model.Output?.Result is null)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs
index 57fe319aaff..16f061f37a6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs
@@ -30,7 +30,7 @@ internal sealed class ParseValueExecutor(ParseValue model, WorkflowFormulaState
object? parsedResult = expressionResult.Value.ToObject().ConvertType(targetType);
parsedValue = parsedResult.ToFormula();
- await this.AssignAsync(this.Model.Variable.Path, parsedValue, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.Variable.Path, parsedValue, context, expressionResult.Sensitivity).ConfigureAwait(false);
return default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs
index 4ad88dd40cb..dacdb9151f6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs
@@ -10,6 +10,7 @@
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
@@ -161,13 +162,13 @@ private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, C
long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value;
if (actualCount >= repeatCount)
{
- DataValue defaultValue = DataValue.Blank();
+ EvaluationResult defaultValue = new(DataValue.Blank(), SensitivityLevel.None);
if (this.Model.DefaultValue is not null)
{
ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue);
- defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value;
+ defaultValue = this.Evaluator.GetValue(defaultValueExpression);
}
- await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
+ await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.Value.ToFormula(), context, defaultValue.Sensitivity).ConfigureAwait(false);
string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse);
await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false);
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
@@ -187,6 +188,12 @@ private string FormatPrompt(ActivityTemplateBase? promptTemplate)
return string.Empty;
}
- return this.Engine.Format(messageActivity.Text).Trim();
+ EvaluationResult promptResult = this.Evaluator.Format(messageActivity.Text);
+ if (promptResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive question prompt: {this.Id}.");
+ }
+
+ return promptResult.Value.Trim();
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs
index 3b9794b197f..4763422b105 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs
@@ -3,10 +3,10 @@
using System;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -18,7 +18,13 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
{
if (this.Model.Activity is MessageActivityTemplate messageActivity)
{
- string activityText = this.Engine.Format(messageActivity.Text).Trim();
+ EvaluationResult activityResult = this.Evaluator.Format(messageActivity.Text);
+ if (activityResult.Sensitivity == SensitivityLevel.Sensitive)
+ {
+ throw new DeclarativeActionException($"Cannot send sensitive activity text: {this.Id}.");
+ }
+
+ string activityText = activityResult.Value.Trim();
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs
index e81126e9a5e..1015c0a0dd1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs
@@ -31,7 +31,7 @@ internal sealed class SetMultipleVariablesExecutor(SetMultipleVariables model, W
{
EvaluationResult expressionResult = this.Evaluator.GetValue(assignment.Value);
- await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context).ConfigureAwait(false);
+ await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs
index 37b8d43e8a5..8d7f2924c4e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs
@@ -2,10 +2,10 @@
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
@@ -19,9 +19,9 @@ internal sealed class SetTextVariableExecutor(SetTextVariable model, WorkflowFor
Throw.IfNull(this.Model.Variable);
Throw.IfNull(this.Model.Value);
- FormulaValue expressionResult = FormulaValue.New(this.Engine.Format(this.Model.Value));
+ EvaluationResult expressionResult = this.Evaluator.Format(this.Model.Value);
- await this.AssignAsync(this.Model.Variable.Path, expressionResult, context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.Variable.Path, FormulaValue.New(expressionResult.Value), context, expressionResult.Sensitivity).ConfigureAwait(false);
return default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs
index 6fd4002df5c..d75a47ca00a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs
@@ -21,7 +21,7 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat
EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value);
- await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false);
+ await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false);
return default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs
index 6c6fe5649fe..c6cc9cbea65 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs
@@ -37,7 +37,6 @@ PowerFxConfig CreateConfig()
config.MaxCallDepth = maximumCallDepth.Value;
}
- config.EnableSetFunction();
config.AddFunction(new AgentMessage());
config.AddFunction(new UserMessage());
config.AddFunction(new MessageText.StringInput());
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs
index 95b8f9ab93d..c46016317f3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs
@@ -37,22 +37,43 @@ public static WorkflowTypeInfo Describe(this TElement workflowElement)
[.. semanticModel.GetVariables(workflowElement.SchemaName.Value).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())]);
}
- public static void Initialize(this WorkflowFormulaState scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase
+ public static void Initialize(
+ this WorkflowFormulaState scopes,
+ TElement workflowElement,
+ IConfiguration? configuration,
+ IEnumerable? allowedEnvironmentVariables,
+ bool allowProcessEnvironmentVariableFallback) where TElement : BotElement, IDialogBase
{
scopes.InitializeSystem();
SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig);
- scopes.InitializeEnvironment(semanticModel, configuration);
+ scopes.InitializeEnvironment(semanticModel, configuration, allowedEnvironmentVariables, allowProcessEnvironmentVariableFallback);
scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value);
}
- private static void InitializeEnvironment(this WorkflowFormulaState scopes, SemanticModel semanticModel, IConfiguration? configuration)
+ private static void InitializeEnvironment(
+ this WorkflowFormulaState scopes,
+ SemanticModel semanticModel,
+ IConfiguration? configuration,
+ IEnumerable? allowedEnvironmentVariables,
+ bool allowProcessEnvironmentVariableFallback)
{
+ HashSet allowedVariables = new(allowedEnvironmentVariables ?? [], StringComparer.Ordinal);
foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot())
{
- string? environmentValue = configuration is not null ? configuration[variableName] : Environment.GetEnvironmentVariable(variableName);
+ if (!allowedVariables.Contains(variableName))
+ {
+ continue;
+ }
+
+ string? environmentValue = configuration?[variableName];
+ if (environmentValue is null && allowProcessEnvironmentVariableFallback)
+ {
+ environmentValue = Environment.GetEnvironmentVariable(variableName);
+ }
+
FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue);
- scopes.Set(variableName, variableValue, VariableScopeNames.Environment);
+ scopes.Set(variableName, variableValue, VariableScopeNames.Environment, SensitivityLevel.Sensitive);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs
index a22a857635a..eb0232d1b0f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs
@@ -3,11 +3,13 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
+using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.ObjectModel;
using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Agents.ObjectModel.Exceptions;
using Microsoft.PowerFx;
+using Microsoft.PowerFx.Syntax;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
@@ -15,11 +17,13 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
internal sealed class WorkflowExpressionEngine
{
- private readonly RecalcEngine _engine;
+ private readonly WorkflowFormulaState _state;
+ private readonly ParserOptions? _parserOptions;
- public WorkflowExpressionEngine(RecalcEngine engine)
+ public WorkflowExpressionEngine(WorkflowFormulaState state)
{
- this._engine = engine;
+ this._state = state;
+ this._parserOptions = state.AllowsSideEffects ? new ParserOptions { AllowsSideEffects = true } : null;
}
public EvaluationResult GetValue(BoolExpression boolean) => this.Evaluate(boolean);
@@ -41,6 +45,55 @@ public WorkflowExpressionEngine(RecalcEngine engine)
public EvaluationResult GetValue(EnumExpression expression) where TValue : EnumWrapper =>
this.Evaluate(expression);
+ public EvaluationResult Format(IEnumerable template)
+ {
+ Throw.IfNull(template);
+
+ SensitivityLevel sensitivity = SensitivityLevel.None;
+ List segments = [];
+ foreach (EvaluationResult result in template.Select(this.Format))
+ {
+ sensitivity = MaxSensitivity(sensitivity, result.Sensitivity);
+ segments.Add(result.Value);
+ }
+
+ return new(string.Concat(segments), sensitivity);
+ }
+
+ public EvaluationResult Format(TemplateLine? line)
+ {
+ if (line is null)
+ {
+ return new(string.Empty, SensitivityLevel.None);
+ }
+
+ SensitivityLevel sensitivity = SensitivityLevel.None;
+ List segments = [];
+ foreach (EvaluationResult result in line.Segments.Select(this.Format))
+ {
+ sensitivity = MaxSensitivity(sensitivity, result.Sensitivity);
+ segments.Add(result.Value);
+ }
+
+ return new(string.Concat(segments), sensitivity);
+ }
+
+ private EvaluationResult Format(TemplateSegment segment)
+ {
+ if (segment is TextSegment textSegment)
+ {
+ return new(textSegment.Value ?? string.Empty, SensitivityLevel.None);
+ }
+
+ if (segment is ExpressionSegment { Expression: not null } expressionSegment)
+ {
+ EvaluationResult result = this.EvaluateScope(expressionSegment.Expression);
+ return new(result.Value.Format(), result.Sensitivity);
+ }
+
+ throw new DeclarativeModelException($"Unsupported segment type: {segment.GetType().Name}");
+ }
+
private EvaluationResult Evaluate(BoolExpression expression)
{
Throw.IfNull(expression);
@@ -274,13 +327,136 @@ private EvaluationResult EvaluateScope(ExpressionBase expression)
expression.VariableReference?.ToString() :
expression.ExpressionText;
- FormulaValue result = this._engine.Eval(expressionText);
+ FormulaValue result = this._state.Engine.Eval(expressionText, options: this._parserOptions);
if (result is ErrorValue errorValue)
{
throw new DeclarativeActionException(errorValue.Format());
}
- return new(result, SensitivityLevel.None);
+ return new(result, this.GetSensitivity(expression));
+ }
+
+ private SensitivityLevel GetSensitivity(ExpressionBase expression)
+ {
+ if (expression.VariableReference is { VariableName: string variableName })
+ {
+ return GetReferenceSensitivity(expression.VariableReference.NamespaceAlias, variableName);
+ }
+
+ string? expressionText = expression.ExpressionText;
+ if (string.IsNullOrWhiteSpace(expressionText))
+ {
+ return SensitivityLevel.None;
+ }
+
+ CheckResult checkResult = this._state.Engine.Check(expressionText, options: this._parserOptions);
+ checkResult.ThrowOnErrors();
+
+ SensitivityLevel sensitivity = SensitivityLevel.None;
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(checkResult.Parse.Root))
+ {
+ sensitivity = MaxSensitivity(sensitivity, GetReferenceSensitivity(reference.ScopeName, reference.VariableName));
+ }
+
+ return sensitivity;
+
+ SensitivityLevel GetReferenceSensitivity(string? scopeName, string variableName) =>
+ scopeName is null && VariableScopeNames.IsValidName(variableName)
+ ? this._state.GetScopeSensitivity(variableName)
+ : this._state.GetSensitivity(variableName, scopeName);
+ }
+
+ private static IEnumerable<(string? ScopeName, string VariableName)> GetVariableReferences(TexlNode node)
+ {
+ switch (node)
+ {
+ case DottedNameNode dottedNameNode:
+ if (TryGetDottedReference(dottedNameNode, out (string? ScopeName, string VariableName) dottedReference))
+ {
+ yield return dottedReference;
+ }
+ else
+ {
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(dottedNameNode.Left))
+ {
+ yield return reference;
+ }
+ }
+ yield break;
+
+ case FirstNameNode firstNameNode:
+ yield return (null, firstNameNode.Ident.Name.Value);
+ yield break;
+
+ case AsNode asNode:
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(asNode.Left))
+ {
+ yield return reference;
+ }
+ yield break;
+
+ case BinaryOpNode binaryOpNode:
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Left))
+ {
+ yield return reference;
+ }
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Right))
+ {
+ yield return reference;
+ }
+ yield break;
+
+ case UnaryOpNode unaryOpNode:
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(unaryOpNode.Child))
+ {
+ yield return reference;
+ }
+ yield break;
+
+ case CallNode callNode:
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(callNode.Args))
+ {
+ yield return reference;
+ }
+ yield break;
+
+ case VariadicBase variadicBase:
+ foreach (TexlNode childNode in variadicBase.ChildNodes)
+ {
+ foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(childNode))
+ {
+ yield return reference;
+ }
+ }
+ yield break;
+ }
}
+
+ private static bool TryGetDottedReference(DottedNameNode dottedNameNode, out (string? ScopeName, string VariableName) reference)
+ {
+ List names = [];
+ TexlNode node = dottedNameNode;
+ while (node is DottedNameNode current)
+ {
+ names.Add(current.Right.Name.Value);
+ node = current.Left;
+ }
+
+ if (node is not FirstNameNode firstNameNode)
+ {
+ reference = default;
+ return false;
+ }
+
+ names.Add(firstNameNode.Ident.Name.Value);
+ names.Reverse();
+ reference = names.Count > 1 && VariableScopeNames.IsValidName(names[0])
+ ? (names[0], names[1])
+ : (null, names[0]);
+ return true;
+ }
+
+ private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) =>
+ left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs
index aaff60b08b4..87f726a77e0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics;
@@ -27,6 +28,8 @@ internal sealed class WorkflowFormulaState
VariableScopeNames.System,
];
+ private const string SensitivityScopePrefix = "__Microsoft_Agents_AI_Workflows_Declarative_Sensitivity:";
+
private readonly Dictionary _scopes;
private Dictionary _initialScopes;
@@ -37,13 +40,16 @@ internal sealed class WorkflowFormulaState
public WorkflowExpressionEngine Evaluator { get; }
- public WorkflowFormulaState(RecalcEngine engine)
+ public bool AllowsSideEffects { get; }
+
+ public WorkflowFormulaState(RecalcEngine engine, bool allowsSideEffects = false)
{
this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => GetScopeName(scopeName), _ => new WorkflowScope());
this._initialScopes = this.CreateScopeSnapshot();
this.Engine = engine;
- this.Evaluator = new WorkflowExpressionEngine(engine);
+ this.AllowsSideEffects = allowsSideEffects;
+ this.Evaluator = new WorkflowExpressionEngine(this);
this.Bind();
}
@@ -59,8 +65,38 @@ public FormulaValue Get(string variableName, string? scopeName = null)
return FormulaValue.NewBlank();
}
- public void Set(string variableName, FormulaValue value, string? scopeName = null) =>
- this.GetScope(scopeName ?? DefaultScopeName)[variableName] = value;
+ public void Set(string variableName, FormulaValue value, string? scopeName = null, SensitivityLevel sensitivity = SensitivityLevel.None)
+ {
+ WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName);
+ scope[variableName] = value;
+ scope.Sensitivities[variableName] = sensitivity;
+ }
+
+ public SensitivityLevel GetSensitivity(string variableName, string? scopeName = null)
+ {
+ if (scopeName is not null && !VariableScopeNames.IsValidName(scopeName))
+ {
+ return SensitivityLevel.None;
+ }
+
+ WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName);
+ return scope.Sensitivities.TryGetValue(variableName, out SensitivityLevel sensitivity) ? sensitivity : SensitivityLevel.None;
+ }
+
+ public SensitivityLevel GetScopeSensitivity(string scopeName)
+ {
+ if (!VariableScopeNames.IsValidName(scopeName))
+ {
+ return SensitivityLevel.None;
+ }
+
+ return this.GetScope(scopeName).Sensitivities.Values.Any(static sensitivity => sensitivity == SensitivityLevel.Sensitive)
+ ? SensitivityLevel.Sensitive
+ : SensitivityLevel.None;
+ }
+
+ public void SetSensitivity(string variableName, string? scopeName, SensitivityLevel sensitivity) =>
+ this.GetScope(scopeName ?? DefaultScopeName).Sensitivities[variableName] = sensitivity;
public bool SetInitialized() => Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 0;
@@ -96,13 +132,14 @@ async Task ReadScopeAsync(string scopeName)
foreach (string key in keys)
{
PortableValue? value = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false);
+ SensitivityLevel sensitivity = await context.ReadStateAsync(key, GetSensitivityScopeName(scopeName), cancellationToken).ConfigureAwait(false);
if (value is null)
{
- this.Set(key, FormulaValue.NewBlank(), scopeName);
+ this.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity);
continue;
}
FormulaValue formulaValue = value.ToFormula();
- this.Set(key, formulaValue, scopeName);
+ this.Set(key, formulaValue, scopeName, sensitivity);
Debug.WriteLine($"RESTORED: {scopeName}.{key} => {formulaValue.Type}");
}
@@ -119,10 +156,16 @@ private void RestoreInitialState()
{
WorkflowScope scope = this._scopes[initialScopeEntry.Key];
scope.Clear();
+ scope.Sensitivities.Clear();
foreach (KeyValuePair initialValueEntry in initialScopeEntry.Value)
{
scope[initialValueEntry.Key] = initialValueEntry.Value;
}
+
+ foreach (KeyValuePair initialSensitivityEntry in initialScopeEntry.Value.Sensitivities)
+ {
+ scope.Sensitivities[initialSensitivityEntry.Key] = initialSensitivityEntry.Value;
+ }
}
}
@@ -157,6 +200,8 @@ void Bind(string scopeName, string? targetScope = null)
private WorkflowScope GetScope(string? scopeName) => this._scopes[GetScopeName(scopeName)];
+ public static string GetSensitivityScopeName(string scopeName) => $"{SensitivityScopePrefix}{GetScopeName(scopeName)}";
+
public static string GetScopeName(string? scopeName)
{
WorkflowDiagnostics.SetFoundryProduct();
@@ -185,6 +230,15 @@ public WorkflowScope()
public WorkflowScope(IDictionary values)
: base(values)
{
+ if (values is WorkflowScope scope)
+ {
+ foreach (KeyValuePair sensitivity in scope.Sensitivities)
+ {
+ this.Sensitivities[sensitivity.Key] = sensitivity.Value;
+ }
+ }
}
+
+ public Dictionary Sensitivities { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index ab058de62d4..8af74cb2369 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -1 +1,14 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable?
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
index ab058de62d4..8af74cb2369 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -1 +1,14 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable?
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index ab058de62d4..8af74cb2369 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -1 +1,14 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable?
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index ab058de62d4..8af74cb2369 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -1 +1,14 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable?
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index ab058de62d4..8af74cb2369 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1 +1,14 @@
#nullable enable
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable?
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool
+Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void
+Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string?
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!>
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
index e1c1765349c..419c4a50f29 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
@@ -77,7 +77,8 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st
/// Thrown when the input data object does not match the expected response type.
public ExternalResponse CreateResponse(object data)
{
- if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(Throw.IfNull(data).GetType()))
+ data = Throw.IfNull(data);
+ if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(data.GetType()))
{
throw new InvalidOperationException(
$"Message type {data.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}.");
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
index 47167cd9e53..69b6c1e9bcc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
@@ -423,7 +423,7 @@ await Task.WhenAll(executorNotifyTask,
restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false);
this._lastCheckpointInfo = checkpointInfo;
- this.StepTracer.Reload(this.StepTracer.StepNumber);
+ this.StepTracer.Reload(checkpoint.StepNumber);
async ValueTask UpdateCheckpointIndexAsync()
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
index 8ccfb957294..353c38f25c9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
@@ -167,6 +167,8 @@ async ValueTask PrepareExternalDeliveryAsync()
$"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}.");
}
+ response = pendingRequest.RewrapResponse(response);
+
// Consume only after validation so a rejected response leaves the legitimate one able to complete.
if (!this.CompleteRequest(response.RequestId))
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
index 52dae66b881..bcc8616dfb0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
@@ -3,8 +3,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
@@ -88,7 +91,13 @@ public async ValueTask HandleAsync(ExternalRequest message, IWo
Debug.Assert(this._allowWrapped);
Throw.IfNull(message);
- if (!message.Data.IsType(this.Port.Request, out var requestData))
+ Type? originalRequestType = ResolveType(message.PortInfo.RequestType, this.Port.Request);
+ if (originalRequestType is null || !this.Port.Request.IsAssignableFrom(originalRequestType))
+ {
+ throw new InvalidOperationException($"Request type {this.Port.Request} is not valid for original request, whose request type is {message.PortInfo.RequestType}");
+ }
+
+ if (!message.Data.IsType(originalRequestType, out var requestData))
{
throw new InvalidOperationException($"Message type {message.Data.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}");
}
@@ -107,6 +116,25 @@ public async ValueTask HandleAsync(ExternalRequest message, IWo
return request;
}
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Request types are preserved by their workflow port and serialization registrations.")]
+ internal static Type? ResolveType(TypeId typeId, Type assignableToType)
+ {
+ Throw.IfNull(typeId);
+ Throw.IfNull(assignableToType);
+
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()
+ .Where(assembly => string.Equals(assembly.GetName().Name, typeId.SimpleAssemblyName, StringComparison.Ordinal)))
+ {
+ Type? resolvedType = assembly.GetType(typeId.NormalizedTypeName, throwOnError: false);
+ if (resolvedType is not null && assignableToType.IsAssignableFrom(resolvedType))
+ {
+ return resolvedType;
+ }
+ }
+
+ return null;
+ }
+
public async ValueTask HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs
index ee7183b701f..a93a733ec73 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs
@@ -76,9 +76,16 @@ public override async Task GetResponseAsync(
var autoApprovableNames = ApprovalRequirement.GetApprovalNotRequiredToolNames(this, options);
- messages = InjectPendingAutoApprovals(messages, session);
+ var (messagesToSend, injectedAutoApprovals) = InjectPendingAutoApprovals(messages, session);
- var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ var response = await base.GetResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false);
+
+ // The injected auto-approvals are cleared only now that the run has succeeded, so a failed run leaves them
+ // in the session and the next run injects them again instead of leaving the tool calls unanswered.
+ if (injectedAutoApprovals)
+ {
+ session.StateBag.TryRemoveValue(StateBagKey);
+ }
RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session);
@@ -103,24 +110,43 @@ public override async IAsyncEnumerable GetStreamingResponseA
var autoApprovableNames = ApprovalRequirement.GetApprovalNotRequiredToolNames(this, options);
- messages = InjectPendingAutoApprovals(messages, session);
+ var (messagesToSend, injectedAutoApprovals) = InjectPendingAutoApprovals(messages, session);
List? autoApproved = null;
+ // Set only once the stream has run to completion, so that any abnormal end - an exception from the inner
+ // client, a cancellation, or a consumer that stops enumerating early - leaves the stored state exactly as it
+ // was. A caught exception is not enough on its own: breaking out of the enumeration disposes this iterator
+ // without throwing, and the run then persists nothing either.
+ bool completedNormally = false;
+
try
{
- await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ await foreach (var update in base.GetStreamingResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false))
{
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
{
yield return update;
}
}
+
+ completedNormally = true;
}
finally
{
- if (autoApproved is { Count: > 0 })
+ // Both writes are gated, mirroring the non-streaming path: the requests collected here were filtered out
+ // of the stream and so never reached the caller, and storing them on an abnormal end would overwrite the
+ // batch that was injected this run and still needs re-injecting.
+ if (completedNormally)
{
- session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
+ if (injectedAutoApprovals)
+ {
+ session.StateBag.TryRemoveValue(StateBagKey);
+ }
+
+ if (autoApproved is { Count: > 0 })
+ {
+ session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
+ }
}
}
}
@@ -159,7 +185,13 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
/// All stored requests are unconditionally injected as approved responses regardless of whether the
/// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn.
///
- private static IEnumerable InjectPendingAutoApprovals(
+ ///
+ /// The messages to send to the inner client, and whether any auto-approvals were injected into them. The
+ /// stored auto-approvals are cleared by the caller only once the run has completed successfully, so that a
+ /// failed run leaves them in the session and the next run injects them again rather than leaving the
+ /// auto-approved calls unanswered.
+ ///
+ private static (IEnumerable Messages, bool Injected) InjectPendingAutoApprovals(
IEnumerable messages,
AgentSession session)
{
@@ -169,11 +201,9 @@ private static IEnumerable InjectPendingAutoApprovals(
AgentJsonUtilities.DefaultOptions)
|| pendingRequests is not { Count: > 0 })
{
- return messages;
+ return (messages, false);
}
- session.StateBag.TryRemoveValue(StateBagKey);
-
List approvalResponses = [];
foreach (var request in pendingRequests)
{
@@ -181,7 +211,7 @@ private static IEnumerable InjectPendingAutoApprovals(
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
- return messages.Concat([userMessage]);
+ return (messages.Concat([userMessage]), true);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs
index 82b2c59bd8f..722130be726 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs
@@ -23,6 +23,7 @@ namespace Microsoft.Agents.AI;
/// carried by an approval response. This decorator adds an extra layer of assurance above FICC: it guarantees that
/// only approvals the framework actually requested are honored, and that an approved call runs with exactly the tool
/// name and arguments that were surfaced for approval.
+/// This binding also applies to tools that do not require human approval.
///
///
/// This decorator sits above in the pipeline. On outbound responses it
@@ -84,9 +85,17 @@ public override async Task GetResponseAsync(
return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
}
- messages = this.ValidateInboundApprovalResponses(messages, options, session);
+ var (messagesToSend, hasPendingRequests) = this.ValidateInboundApprovalResponses(messages, session);
- var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ var response = await base.GetResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false);
+
+ // The records are consumed only now that the run has succeeded, so a failed run leaves them intact and the
+ // caller can supply the same approval response again. This runs before the requests surfaced by this run
+ // are recorded, so it never discards those.
+ if (hasPendingRequests)
+ {
+ session.StateBag.TryRemoveValue(StateBagKey);
+ }
this.RecordPendingApprovalRequests(response.Messages, session);
@@ -109,13 +118,19 @@ public override async IAsyncEnumerable GetStreamingResponseA
yield break;
}
- messages = this.ValidateInboundApprovalResponses(messages, options, session);
+ var (messagesToSend, hasPendingRequests) = this.ValidateInboundApprovalResponses(messages, session);
List? emitted = null;
+ // Set only once the stream has run to completion, so that any abnormal end - an exception from the inner
+ // client, a cancellation, or a consumer that stops enumerating early - leaves the records intact and lets the
+ // caller supply the same approval response again. A caught exception is not enough on its own: breaking out of
+ // the enumeration disposes this iterator without throwing, and the run then persists nothing either.
+ bool completedNormally = false;
+
try
{
- await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ await foreach (var update in base.GetStreamingResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false))
{
foreach (var content in update.Contents)
{
@@ -127,9 +142,19 @@ public override async IAsyncEnumerable GetStreamingResponseA
yield return update;
}
+
+ completedNormally = true;
}
finally
{
+ if (completedNormally && hasPendingRequests)
+ {
+ session.StateBag.TryRemoveValue(StateBagKey);
+ }
+
+ // Recorded regardless of how the stream ended, because each request was already handed to the caller
+ // before the stream stopped and the caller may act on it. Consumers commonly stop enumerating as soon as
+ // they see an approval request, and the record has to be in place for the answer to bind.
if (emitted is { Count: > 0 })
{
this.MergePendingApprovalRequests(emitted, session);
@@ -172,7 +197,15 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
/// function invocation middleware. That is deliberate: a payload whose approval was rejected surfaces as an
/// error instead of silently continuing as though the call had never been requested.
///
- private IEnumerable ValidateInboundApprovalResponses(IEnumerable messages, ChatOptions? options, AgentSession session)
+ /// The inbound messages.
+ /// The session holding the recorded pending approval requests.
+ ///
+ /// The messages to send to the inner client, and whether the session held any recorded pending requests, which
+ /// the caller consumes once the inner call has succeeded.
+ ///
+ private (IEnumerable Messages, bool HasPendingRequests) ValidateInboundApprovalResponses(
+ IEnumerable