Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bda341ab7 | |||
| 9c3a1a4af7 | |||
| 228754d7fa | |||
| 8461667fe4 | |||
| 12621e0a74 | |||
| e1e005f226 | |||
| e289320027 | |||
| ae7fa3389c | |||
| 4aa737eee5 | |||
| 8c4da3c3b9 | |||
| 5fafa18569 | |||
| ee27065359 | |||
| 9645d33cde | |||
| 9a06fa3f42 | |||
| 6d25fb1e9c | |||
| e926ad2859 | |||
| 7cfa905486 | |||
| 3221011427 | |||
| 3aadac53c8 | |||
| 35c6b880f7 | |||
| 3a5d00be54 | |||
| 56d13bce4e | |||
| 27d82b1567 | |||
| 5e52c6a718 | |||
| 30996433ac | |||
| e85b3c8ba8 | |||
| 4ca093371e | |||
| 8a0731ad92 | |||
| 6fff2c9b1f | |||
| 0d75365331 | |||
| db979b616a | |||
| d0a4165f17 | |||
| 4357ff5742 | |||
| 221f4b6df1 | |||
| a9f7b2b788 | |||
| adcc3de654 | |||
| 034f5fa119 | |||
| 48e547506b | |||
| 5eb3eb745e | |||
| c987529df3 | |||
| aaaa56bc60 | |||
| 18ceb182b1 | |||
| ec32e86646 | |||
| 94bbfb2ac8 | |||
| 4b1afd9052 | |||
| 45c515b8a7 | |||
| b2a2fcbd87 | |||
| 7302d0bf23 | |||
| 422160eabe | |||
| 5a1d96df67 | |||
| 74a144085a | |||
| 594954700a | |||
| a4d4eafa5e | |||
| da056275e6 | |||
| 1da571860a | |||
| d56e81357e | |||
| 5f3ca8f93c | |||
| 4d3c7844d6 | |||
| 07511b80c9 | |||
| e84b5a07c1 | |||
| 84d5a5eec1 | |||
| 8d379168b2 | |||
| 18997c2fde | |||
| 5cc1b8e3c3 | |||
| 9ce55cae00 | |||
| 06c0fc2b10 |
+127
-2
@@ -1,5 +1,130 @@
|
||||
# Code ownership assignments
|
||||
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# Policy: a PR needs one approval, and it must come from a code owner of the changed
|
||||
# files ("Require review from Code Owners" + "Required approvals: 1" on `main`).
|
||||
# A PR touching several CODEOWNERS patterns will request review from the applicable
|
||||
# code owners, but an approval from any applicable code owner is sufficient to satisfy
|
||||
# GitHub's required-code-owner review.
|
||||
#
|
||||
# Order matters: the LAST matching pattern wins, so a module rule fully replaces the
|
||||
# catch-all rather than adding to it. @chetantoshniwal is included on every line as a
|
||||
# repository-wide fallback owner. All owners on a line have equal approval authority.
|
||||
#
|
||||
# CONVENTION: owners are written in the order
|
||||
# @chetantoshniwal <owner A> <owner B> [...]
|
||||
# @chetantoshniwal is at the beginning for aesthetics. The owners share equal approval
|
||||
# power and responsibility.
|
||||
#
|
||||
# RULE: every path must list at least two owners besides @chetantoshniwal. An author
|
||||
# cannot approve their own PR, so a path with a single module owner leaves only Chetan
|
||||
# to review whenever that owner is the author, which defeats the point of naming a
|
||||
# module owner.
|
||||
#
|
||||
# Samples: owned by all core developers of that language, not by the module a sample
|
||||
# demonstrates. Any core Python developer can approve any Python sample, and any core
|
||||
# .NET developer can approve any .NET sample. No dedicated rule is needed -- samples
|
||||
# fall through to the /python and /dotnet rules, which already list those developers.
|
||||
#
|
||||
# Tests: same as samples. Tests that live inside a package (python/packages/<pkg>/tests)
|
||||
# are covered by that package's rule instead, since they sit under its path.
|
||||
|
||||
# Default owners for everything not matched by a module rule below.
|
||||
* @chetantoshniwal @westey-m
|
||||
|
||||
# Repository-level paths: every core Agent Framework developer is a code owner, so any
|
||||
# one of them can approve. Be explicit now and we can use the AgentFramework team in the future.
|
||||
/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
|
||||
|
||||
# Repository-level paths that require specific owners
|
||||
/.devcontainer @chetantoshniwal @westey-m @rogerbarreto @SergeyMenshykh
|
||||
/declarative-agents @chetantoshniwal @moonbox3 @peibekwe
|
||||
|
||||
# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
|
||||
# Python packages
|
||||
/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/ag-ui/ @chetantoshniwal @moonbox3 @giles17
|
||||
/python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/chatkit/ @chetantoshniwal @moonbox3 @giles17
|
||||
/python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
|
||||
/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @moonbox3 @TaoChenOSU
|
||||
/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg
|
||||
/python/packages/declarative/ @chetantoshniwal @moonbox3 @peibekwe
|
||||
/python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3
|
||||
/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 @giles17
|
||||
/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg
|
||||
/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU
|
||||
/python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
/python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
|
||||
/python/packages/orchestrations/ @chetantoshniwal @moonbox3 @TaoChenOSU
|
||||
/python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
|
||||
# Core .NET developers: @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
|
||||
# .NET projects
|
||||
/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Shared/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Abstractions/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Anthropic/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.CopilotStudio/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Foundry/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/ @chetantoshniwal @rogerbarreto @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Harness/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe
|
||||
/dotnet/src/Microsoft.Agents.AI.Mem0/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.OpenAI/ @chetantoshniwal @westey-m @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Purview/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Valkey/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto
|
||||
|
||||
python/samples/getting_started/azure_functions/ @microsoft/agentframework-durabletask-developers
|
||||
python/samples/getting_started/durabletask/ @microsoft/agentframework-durabletask-developers
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -64,6 +64,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: |
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only, not the untrusted PR head.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
env:
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
environment: 'integration'
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
python-changes: ${{ steps.detect-changes.outputs.python }}
|
||||
steps:
|
||||
- name: Check out trusted workflow helpers
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/prek
|
||||
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
# installability starts differing across supported Python versions.
|
||||
UV_PYTHON: "3.13"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -449,7 +449,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -497,7 +497,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -413,7 +413,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -545,7 +545,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -606,7 +606,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -660,7 +660,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -317,7 +317,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -392,7 +392,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -428,7 +428,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -467,7 +467,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -513,7 +513,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -553,7 +553,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -590,7 +590,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -641,7 +641,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -681,7 +681,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -728,7 +728,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -772,7 +772,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -840,7 +840,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -904,7 +904,7 @@ jobs:
|
||||
- validate-autogen-migration
|
||||
- validate-semantic-kernel-migration
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download all validation reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
|
||||
@@ -172,7 +172,7 @@ parsing a structured payload into a typed record), without coupling the holder t
|
||||
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
|
||||
`AgentSessionStore` key or a workflow checkpoint session id.
|
||||
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
|
||||
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
|
||||
`UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal.
|
||||
- Persist session/checkpoint state only after the run or stream has completed.
|
||||
|
||||
## E2E Code Samples
|
||||
|
||||
@@ -120,6 +120,10 @@ Code-reading landmarks:
|
||||
- `_process_model_function_calls(...)` handles only calls from a completed model response.
|
||||
- `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch.
|
||||
- `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer.
|
||||
- `FunctionInvocationLayer._update_function_invocation_continuation_state(...)` updates continuation state after
|
||||
every service response. Provider layers may override it to carry provider-specific continuation metadata into
|
||||
the next service call, but must delegate to the base implementation so generic conversation continuation remains
|
||||
synchronized with the active `AgentSession`.
|
||||
|
||||
### Approval pause and resume
|
||||
|
||||
@@ -331,11 +335,28 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
- Service-managed continuation may omit inline reasoning/call items only when the hosted service already owns them.
|
||||
- Missing non-reconstructable reasoning fails explicitly before a provider request instead of silently dropping the
|
||||
content.
|
||||
- Foundry clients do not request `reasoning.encrypted_content` implicitly; callers may opt in explicitly when the
|
||||
selected deployment supports encrypted reasoning.
|
||||
- Compaction preserves or excludes the complete reasoning/call/result group atomically.
|
||||
|
||||
### Approval request and resume
|
||||
|
||||
- A tool that requires approval does not execute before an approved response.
|
||||
- With an `AgentSession`, every surfaced local or hosted approval request is stored as an immutable snapshot in one
|
||||
active model batch. A new surfaced batch replaces an abandoned batch instead of accumulating session state.
|
||||
- Approval request IDs use the provider function `call_id`, whose conversation-level uniqueness is required for
|
||||
function-call/result correlation. Duplicate request IDs within one batch are rejected as malformed.
|
||||
- An inbound response is honored only when its request id matches the pending server-held snapshot.
|
||||
- Approval requests replayed in inbound message history do not create, replace, or resurrect approval authority.
|
||||
- The executable call id, tool name, arguments, and local or hosted tool metadata are sourced from the recorded
|
||||
request, never from the response payload.
|
||||
- A matched approval response consumes its pending entry once. Unmatched, duplicate, and replayed responses do not
|
||||
reach local execution.
|
||||
- Tool lookup uses the recorded name against the current registry. A same-name implementation upgrade is allowed;
|
||||
removing the name prevents local execution.
|
||||
- Only the strict boolean `True` grants approval. Missing decisions and non-boolean values are rejection, not consent.
|
||||
- Direct chat-client invocation without an `AgentSession` preserves pass-through compatibility, matching .NET;
|
||||
authorization sinks still require strict `True`.
|
||||
- An approved tool executes exactly once.
|
||||
- A rejected tool executes zero times and produces one synthetic rejection `function_result` using the original
|
||||
function `call_id`.
|
||||
@@ -353,6 +374,20 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
|
||||
transcript items.
|
||||
- A current hosted approval response must be sent once on the immediate resume request.
|
||||
- AG-UI removes a local approval response from its request and snapshot replay when a terminal result belongs to an
|
||||
already-consumed occurrence, including result-before-response replay. A client-authored result in the occurrence
|
||||
that is still registered as pending does not prove completion: AG-UI removes that result, keeps the validated
|
||||
response for local execution, and leaves hosted approval responses as provider protocol data.
|
||||
- Hosted AG-UI approval interrupts expose an accept/reject decision only; argument edits are rejected because the
|
||||
hosted provider executes the server-owned request rather than client-edited arguments.
|
||||
- AG-UI tool approval resumes accept the standard `approved` decision and full-replacement `editedArgs` payload.
|
||||
Existing MAF clients remain compatible through the `accepted` decision alias and direct partial argument edits.
|
||||
- An AG-UI `cancelled` resume is a valid terminal decision, not a run error. In a resume covering parallel open
|
||||
interrupts, resolved siblings still execute and cancelled calls do not. An identical cancellation retry during
|
||||
the retained terminal window also completes normally without restoring authority.
|
||||
- AG-UI Approval State capacity is enforced independently for each trusted application scope. Abandoned pending
|
||||
authority expires after its configured window, and indeterminate execution records remain non-retryable until
|
||||
their separate safety window permits reclamation. Reclamation never recreates approval authority.
|
||||
- A server-issued approval request must not be replayed inline during service-side continuation.
|
||||
- History providers may retain approval control contents in their backing store for audit, but base history replay
|
||||
filters them before later model calls.
|
||||
@@ -364,7 +399,13 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
- Model-bound history contains one function call/result pair per completed logical occurrence.
|
||||
- Append-only history must not replay stale approval request/response wrappers to the model.
|
||||
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
|
||||
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
|
||||
- A streaming response rebuilt from updates by an intermediate middleware must carry over the inner response's
|
||||
conversation id and its internal-conversation-id marker, so framework-managed continuation appends only the latest
|
||||
message instead of replaying a transcript the provider already holds. The rebuilt response mirrors the inner
|
||||
conversation id exactly, including clearing it, and never retains an id emitted by an earlier service call in the
|
||||
same turn.
|
||||
- A trusted terminal result consumes the corresponding approval authority in explicit stateless replay; a result in a
|
||||
server-registered pending occurrence cannot consume that authority before local execution.
|
||||
|
||||
## Scenario-to-test matrix
|
||||
|
||||
@@ -395,12 +436,17 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| Rejected streaming resume | Rejection result update precedes final text and tool executes zero times. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-rejected]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[rejected]` |
|
||||
| Mixed approved/rejected batch | Every call gets one correctly correlated terminal result. | `packages/core/tests/core/test_function_invocation_logic.py::test_rejected_approval` |
|
||||
| Persisted approval replay | Resume executes with the prior call available. | `test_persisted_approval_messages_replay_correctly` |
|
||||
| Hosted approval pass-through | Hosted requests/responses are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_mixed_local_and_hosted_approval_flow` |
|
||||
| Hosted approval pass-through | Hosted requests/responses are bound to the recorded provider request and are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_session_approval_binding_reconstructs_hosted_response`, `test_mixed_local_and_hosted_approval_flow` |
|
||||
| Approval-time user input | Every user-input request from one approved execution returns in order with assistant role and no extra model call; the execution consumes one call-budget unit. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_all_user_input_requests_without_another_model_call`, `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_user_input_counts_toward_function_call_budget` |
|
||||
| Mixed terminal result and follow-up input | Completed siblings remain tool-role while only follow-up input requests use assistant-role messages/updates. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_separates_terminal_results_from_follow_up_requests`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_mixed_approval_resume_roles_serialize_function_result_as_tool` |
|
||||
| Approval-time middleware termination | Terminal result returns with no extra model call in either response mode. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_honors_middleware_termination` |
|
||||
| Approval re-entry after iteration budget | Pending approved calls resolve once even when prior model calls consumed `max_iterations`. | `packages/core/tests/core/test_harness_tool_approval.py::test_auto_approval_resolves_after_iteration_budget_is_exhausted` |
|
||||
| Approval resume with reasoning | Model-bound resume history retains reasoning before the call and terminal result in both modes. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_replays_reasoning_with_function_call_group` |
|
||||
| Session-bound substituted response | A response is rebound to the immutable recorded call and cannot replace its call id, tool name, or arguments. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_binding_rebinds_consumes_and_rejects_duplicates` |
|
||||
| Truthy non-boolean decision | Strings, integers, null, and other non-booleans do not authorize execution. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_binding_treats_truthy_non_boolean_as_rejection`, `packages/core/tests/core/test_types.py::test_function_approval_response_deserialization_rejects_non_boolean_decisions`, `packages/ag-ui/tests/ag_ui/test_message_adapters.py::test_function_approval_requires_real_boolean`, `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_resolve_approval_responses_treats_non_boolean_decision_as_rejection` |
|
||||
| Active batch replacement | A newly surfaced model batch replaces abandoned approval authority instead of growing session state. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_binding_replaces_abandoned_batch` |
|
||||
| Duplicate request id | Ambiguous request IDs within one active batch fail explicitly. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_batch_rejects_duplicate_request_ids` |
|
||||
| Tool registry changes | Same-name upgrades may execute the recorded operation; removing the recorded name executes nothing. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_allows_same_name_tool_upgrade`, `test_approval_resume_does_not_execute_when_recorded_tool_disappears` |
|
||||
|
||||
### Approval correlation and replay
|
||||
|
||||
@@ -419,6 +465,8 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| Missing result call id | A malformed result does not steal another approval's result. | `test_replace_approval_contents_with_results_skips_results_without_call_id` |
|
||||
| Empty approval message cleanup | Fully consumed approval messages are removed from normalized model input. | `test_replace_approval_contents_with_results_prunes_emptied_messages` |
|
||||
| Later stateless turn | A prior terminal approval response cannot execute again. | `test_resolved_approval_response_is_inert_on_later_stateless_turn` |
|
||||
| Unbound or duplicate response | A response with no pending session request is removed; one request authorizes at most one response. | `test_session_approval_binding_rebinds_consumes_and_rejects_duplicates` |
|
||||
| Forged inbound request history | A caller-supplied request wrapper cannot replace the server snapshot or resurrect consumed authority. | `test_session_approval_binding_does_not_trust_inbound_request_history` |
|
||||
| Pending history turn | An unresolved approval batch is omitted atomically from unrelated model input while a later decision can still resume it once. | `packages/core/tests/core/test_harness_tool_approval.py::test_pending_approval_from_file_history_stays_resumable_without_model_orphan` |
|
||||
| Duplicate function-call prevention | Approval normalization does not create a second call for one round. | `test_no_duplicate_function_calls_after_approval_processing` |
|
||||
| Rejection call id | Rejection result uses the function call id, not only the approval id. | `test_rejection_result_uses_function_call_id` |
|
||||
@@ -436,9 +484,16 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| Auto-approval callback | Callback receives the original function call and executes the approved set once. | `test_tool_approval_middleware_auto_approval_rule_receives_function_call` |
|
||||
| Shared call budget | Auto-approved re-entry does not reset `max_function_calls`, and every executed approval group counts even when it pauses for input. | `test_tool_approval_middleware_auto_approved_loops_share_function_call_budget`, `test_approval_resume_user_input_counts_toward_function_call_budget` |
|
||||
| Standing tool rule | Tool-level approval applies only to later matching tools. | `test_tool_approval_middleware_always_approve_tool_rule` |
|
||||
| Forged standing rule | An unbound or substituted hosted response cannot create a standing middleware approval rule for caller-selected metadata. | `test_tool_approval_middleware_drops_forged_standing_approval`, `test_tool_approval_middleware_rebinds_hosted_standing_approval` |
|
||||
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
|
||||
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
|
||||
| Provider-injected approval tool | A tool added during `before_run` defers to in-run resolution, executes once, and emits one result. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes` |
|
||||
| AG-UI provider boundary | Completed local approval controls from AG-UI request and snapshot replay are absent from raw chat-client input while deferred and hosted approvals keep their respective in-run/provider paths. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_does_not_forward_resolved_local_approval_control_to_chat_client`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_canonical_resume_preserves_hosted_approval_for_provider`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_removes_duplicate_completed_controls`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_pairs_reused_call_ids_by_occurrence`, `packages/ag-ui/tests/ag_ui/test_run.py::test_canonical_hosted_approval_resume_rejects_edited_arguments_without_mutating_pending` |
|
||||
| AG-UI standard approval payload | Agent and workflow tool approvals emit canonical `tool_call` interrupts. `approved` plus full-replacement `editedArgs` executes once and replays idempotently, while legacy `accepted` plus direct partial edits remains supported. Hosted approvals remain decision-only. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_entry_applies_standard_full_replacement_edited_args`, `test_endpoint_agent_approval_replayed_standard_edited_resume_is_idempotent`, `test_endpoint_agent_approval_resume_entry_applies_edited_arguments`, `test_workflow_endpoint_emits_canonical_tool_approval_interrupt`, `test_workflow_endpoint_accepts_canonical_tool_approval_resume`, `test_workflow_endpoint_applies_canonical_approval_edited_args`, `test_workflow_endpoint_accepts_legacy_partial_approval_edits`, `test_workflow_endpoint_hosted_approval_rejects_argument_edits` |
|
||||
| AG-UI cancellation | A cancelled interrupt executes zero times and completes normally, including an identical retry during retained cancellation state; resolved siblings in the same complete resume still execute once. Workflow cancellation clears both runner correlation and the owning agent executor's pending request so later approvals remain resumable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_cancelled_resume_entry_completes_without_execution`, `test_endpoint_agent_approval_replayed_cancellation_completes_idempotently`, `test_endpoint_agent_approval_mixed_cancelled_and_resolved_resume_executes_resolved_tool`, `test_endpoint_workflow_request_info_cancelled_resume_completes_normally`, `test_workflow_endpoint_cancelled_agent_approval_does_not_block_next_approval` |
|
||||
| AG-UI approval retention and capacity | Pending authority expires automatically, indeterminate outcomes remain non-retryable until their safety window permits reclamation, and one trusted scope cannot consume another scope's occurrence quota. | `packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py::test_abandoned_pending_occurrence_expires_and_releases_capacity`, `test_indeterminate_occurrence_is_reclaimed_after_its_safety_window`, `test_capacity_is_enforced_per_trusted_scope` |
|
||||
| AG-UI local executor unavailable on resume | A claimed local occurrence whose executor disappeared releases its unstarted claim, reports temporary unavailability, and remains safely retryable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_remains_retryable_when_local_tool_is_temporarily_unavailable` |
|
||||
| AG-UI forwarded execution interruption | A provider failure, cancellation, or stream close after forwarding an approval recovers the open occurrence as indeterminate when no idempotency key proves retry safety. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_hosted_approval_becomes_indeterminate_when_provider_stream_fails` |
|
||||
|
||||
### Errors, control flow, and limits
|
||||
|
||||
@@ -465,18 +520,20 @@ that manually replay messages own the equivalent rule: do not resend an approval
|
||||
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
|
||||
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
|
||||
| Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` |
|
||||
| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` |
|
||||
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
|
||||
| Streaming message injection with per-service-call persistence | A streaming response rebuilt from updates mirrors the inner conversation id exactly, including clearing it, and keeps its internal marker, so the next iteration appends only the latest message rather than replaying the whole turn on top of provider-held history, and never persists a conversation id from an earlier injected service call. | `packages/core/tests/core/test_middleware_with_chat.py::TestChatMiddleware::test_message_injection_middleware_streaming_preserves_inner_continuation_state`, `test_message_injection_middleware_streaming_keeps_service_conversation_id_external`, `test_message_injection_middleware_streaming_clears_conversation_id_when_final_call_has_none`, `test_message_injection_middleware_conversation_id_matches_across_streaming_modes`, `packages/core/tests/core/test_harness_agent.py::test_streaming_harness_tool_call_does_not_duplicate_transcript` |
|
||||
| Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` |
|
||||
| OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
|
||||
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
|
||||
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
|
||||
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
|
||||
| Foundry encrypted reasoning opt-in | Foundry clients omit `reasoning.encrypted_content` by default and preserve an explicit caller opt-in. | `packages/foundry/tests/foundry/test_foundry_chat_client.py::test_get_response_does_not_request_encrypted_reasoning_by_default`, `test_get_response_preserves_explicit_encrypted_reasoning_opt_in`, `packages/foundry/tests/foundry/test_foundry_agent.py::test_foundry_agent_basic_call_does_not_request_unsupported_encrypted_reasoning`, `test_foundry_agent_preserves_caller_requested_encrypted_reasoning`, `packages/foundry_hosting/tests/test_responses_int.py::TestReasoningHostedMcpReplay::test_second_turn_replays_mcp_call_with_encrypted_reasoning` |
|
||||
| Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` |
|
||||
| Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests |
|
||||
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_resume_emits_tool_call_result`, `test_approval_resume_result_has_content`, `test_approval_resume_snapshot_replaces_approval_payload_with_tool_result`, `test_approval_resume_zero_updates_emits_tool_result` |
|
||||
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `test_rejection_does_not_emit_tool_call_result`, `test_mixed_approve_reject_emits_only_approved_tool_result`, `test_resolve_approval_responses_returns_only_approved` |
|
||||
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `test_resolve_approval_responses_preserves_follow_up_user_input_group` |
|
||||
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `test_resolve_approval_responses_returns_failure_when_grouped_execution_raises` |
|
||||
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
|
||||
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approved_call_emits_one_live_result_under_original_identity`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_persists_replayable_tool_results`, `test_endpoint_agent_approval_replayed_resume_entry_reprojects_retained_result` |
|
||||
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_rejected_call_does_not_execute_or_emit_live_result`, `test_mixed_batch_preserves_approved_result_identity_and_order`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_rejection_releases_already_approved_sibling` |
|
||||
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_follow_up_group_remains_in_history_without_live_tool_result` |
|
||||
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_execution_failure_emits_one_terminal_error_result` |
|
||||
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_no_approval_path_emits_no_approval_specific_duplicate_result` |
|
||||
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
|
||||
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
|
||||
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |
|
||||
@@ -510,6 +567,7 @@ uv run poe syntax -P openai
|
||||
uv run poe pyright -P openai
|
||||
uv run poe test-typing -P openai
|
||||
uv run poe test -P ag-ui
|
||||
uv run poe test -P declarative
|
||||
uv run --directory packages/foundry_hosting poe test
|
||||
```
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ only to approved first-party endpoints.
|
||||
| 15 | `core.in_memory_skills_source` | In-memory / programmatic skills | `agent_framework.InMemorySkillsSource` |
|
||||
| 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` |
|
||||
| 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` |
|
||||
| 18–31 | _reserved_ | core growth | — |
|
||||
| 18 | `core.agent_hooks` | Agent Hooks middleware | `agent_framework.create_agent_hooks_middleware` |
|
||||
| 19–31 | _reserved_ | core growth | — |
|
||||
| 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` |
|
||||
| 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` |
|
||||
| 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` |
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<PackageVersion Include="Google.GenAI" Version="1.6.0" />
|
||||
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
|
||||
<!-- Microsoft.Azure.* -->
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.61.0" />
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
@@ -104,6 +104,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.CosmosNoSql" Version="1.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
|
||||
<!-- Agent SDKs -->
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider/AgentWithMemory_Step07_FileMemoryProvider.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step08_MemoryUsingCosmosNoSql/AgentWithMemory_Step08_MemoryUsingCosmosNoSql.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
|
||||
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<ItemGroup Condition="'$(InjectSharedDiagnosticIds)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\DiagnosticIds\*.cs" LinkBase="Shared\DiagnosticIds" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedUsage)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Usage\*.cs" LinkBase="Shared\Usage" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedRedaction)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Redaction\*.cs" LinkBase="Shared\Redaction" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -532,6 +532,26 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "AgentWithMemory_Step08_MemoryUsingCosmosNoSql",
|
||||
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step08_MemoryUsingCosmosNoSql",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT", "COSMOS_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "COSMOS_DATABASE_NAME"],
|
||||
MustContain =
|
||||
[
|
||||
"First session:",
|
||||
"Second session (recalling prior chat history from Cosmos DB):",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should contain two joke responses.",
|
||||
"The first joke should be about a pirate (as explicitly requested).",
|
||||
"The second joke should also be pirate-themed or similar to what the user likes, since chat history from the first session should be recalled from Cosmos DB.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentWithRAG ────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.16.0</VersionPrefix>
|
||||
<VersionPrefix>1.17.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260730</DateSuffix>
|
||||
<DateSuffix>260804</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.16.0</GitTag>
|
||||
<GitTag>1.17.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -228,7 +228,7 @@ dotnet run
|
||||
|
||||
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
|
||||
|
||||
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
|
||||
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ builder.Services.AddAGUIServer();
|
||||
builder.WebHost.UseUrls("http://localhost:8888");
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@
|
||||
<ItemGroup>
|
||||
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
|
||||
Microsoft Agent Framework adapter. -->
|
||||
<PackageReference Include="AgentMemory" Version="1.2.0" />
|
||||
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
|
||||
<PackageReference Include="AgentMemory" Version="1.3.0" />
|
||||
<PackageReference Include="AgentMemory.AgentFramework" Version="1.3.0" />
|
||||
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.CosmosNoSql" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to persist chat history in Azure Cosmos DB for NoSQL using the ChatHistoryMemoryProvider.
|
||||
// The agent can then use chat history from prior conversations to inform responses in new conversations.
|
||||
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.CosmosNoSql;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-large";
|
||||
var embeddingDimensions = 3072;
|
||||
if (Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_DIMENSIONS") is string embeddingDimensionsValue &&
|
||||
(!int.TryParse(embeddingDimensionsValue, out embeddingDimensions) || embeddingDimensions <= 0))
|
||||
{
|
||||
throw new InvalidOperationException("FOUNDRY_EMBEDDING_DIMENSIONS must be a positive integer.");
|
||||
}
|
||||
var cosmosEndpoint = Environment.GetEnvironmentVariable("COSMOS_ENDPOINT") ?? throw new InvalidOperationException("COSMOS_ENDPOINT is not set.");
|
||||
var cosmosDatabaseName = Environment.GetEnvironmentVariable("COSMOS_DATABASE_NAME") ?? "agent-memory";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
using CosmosClient cosmosClient = new(
|
||||
cosmosEndpoint,
|
||||
credential,
|
||||
new CosmosClientOptions
|
||||
{
|
||||
UseSystemTextJsonSerializerWithOptions = JsonSerializerOptions.Default,
|
||||
});
|
||||
|
||||
DatabaseResponse databaseResponse = await cosmosClient.CreateDatabaseIfNotExistsAsync(cosmosDatabaseName);
|
||||
|
||||
VectorStore vectorStore = new CosmosNoSqlVectorStore(
|
||||
databaseResponse.Database,
|
||||
new CosmosNoSqlVectorStoreOptions
|
||||
{
|
||||
JsonSerializerOptions = JsonSerializerOptions.Default,
|
||||
EmbeddingGenerator = aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetEmbeddingClient(embeddingDeploymentName)
|
||||
.AsIEmbeddingGenerator(),
|
||||
});
|
||||
|
||||
var userId = $"sample-{Guid.NewGuid():N}";
|
||||
|
||||
// Create the agent and add the ChatHistoryMemoryProvider to store chat messages in Cosmos DB.
|
||||
AIAgent agent = aiProjectClient
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { ModelId = deploymentName, Instructions = "You are good at telling jokes." },
|
||||
Name = "Joker",
|
||||
AIContextProviders = [new ChatHistoryMemoryProvider(
|
||||
vectorStore,
|
||||
collectionName: "chathistory",
|
||||
vectorDimensions: embeddingDimensions,
|
||||
// Callback to configure the initial state of the ChatHistoryMemoryProvider.
|
||||
// The ChatHistoryMemoryProvider stores its state in the AgentSession and this callback
|
||||
// will be called whenever the ChatHistoryMemoryProvider cannot find existing state in the session,
|
||||
// typically the first time it is used with a new session.
|
||||
_ => new ChatHistoryMemoryProvider.State(
|
||||
// Configure the scope values under which chat messages will be stored.
|
||||
// In this case, we are using a per-run user ID and a unique session ID for each new session.
|
||||
storageScope: new() { UserId = userId, SessionId = Guid.NewGuid().ToString("N") },
|
||||
// Configure the scope which would be used to search for relevant prior messages.
|
||||
// In this case, we are searching for any messages for the user across all sessions.
|
||||
searchScope: new() { UserId = userId }))]
|
||||
});
|
||||
|
||||
// Start a new session for the agent conversation.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Run the agent with the session that stores conversation history in Cosmos DB.
|
||||
Console.WriteLine("First session:");
|
||||
Console.WriteLine(await agent.RunAsync("I like jokes about Pirates. Tell me a joke about a pirate.", session));
|
||||
|
||||
// Start a second session. Since we configured the search scope to be across all sessions for the user,
|
||||
// the agent should remember that the user likes pirate jokes.
|
||||
AgentSession session2 = await agent.CreateSessionAsync();
|
||||
|
||||
// Run the agent with the second session.
|
||||
Console.WriteLine("Second session (recalling prior chat history from Cosmos DB):");
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke that I might like.", session2));
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Agent with Memory Using Azure Cosmos DB for NoSQL
|
||||
|
||||
This sample uses `ChatHistoryMemoryProvider` with `CosmosNoSqlVectorStore` to persist chat history in Azure Cosmos DB for NoSQL and recall relevant messages in a new agent session.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Authenticating to Microsoft Foundry and Azure Cosmos DB with `DefaultAzureCredential`
|
||||
- Storing chat messages in an Azure Cosmos DB vector store
|
||||
- Creating the configured database and chat-history container when they do not exist
|
||||
- Recalling relevant chat history across agent sessions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
2. A Microsoft Foundry project with:
|
||||
- A chat model deployment (the default is `gpt-5.4-mini`)
|
||||
- A `text-embedding-3-large` deployment with 3,072 dimensions
|
||||
3. An Azure Cosmos DB for NoSQL account with [vector search enabled](https://learn.microsoft.com/azure/cosmos-db/nosql/vector-search)
|
||||
4. An Azure identity that can create the configured database and container and read and write items
|
||||
5. Azure CLI authentication (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | *(required)* |
|
||||
| `COSMOS_ENDPOINT` | Azure Cosmos DB account endpoint | *(required)* |
|
||||
| `FOUNDRY_MODEL` | Chat model deployment name | `gpt-5.4-mini` |
|
||||
| `FOUNDRY_EMBEDDING_MODEL` | Embedding model deployment name | `text-embedding-3-large` |
|
||||
| `FOUNDRY_EMBEDDING_DIMENSIONS` | Number of dimensions produced by the embedding deployment | `3072` |
|
||||
| `COSMOS_DATABASE_NAME` | Database used to store agent memory | `agent-memory` |
|
||||
|
||||
## Run the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The first session stores the user's preference for pirate jokes. The second session uses a different `AgentSession` but the same per-run user search scope, allowing the agent to retrieve that preference from Azure Cosmos DB without recalling data from earlier sample runs.
|
||||
@@ -11,6 +11,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|
||||
|[File Based Memory](./AgentWithMemory_Step07_FileMemoryProvider/)|This sample demonstrates how to use the `FileMemoryProvider` to give an agent tools for storing and recalling memories as files, and how to configure the folder that those memory files are written to.|
|
||||
|[Memory with Azure Cosmos DB for NoSQL](./AgentWithMemory_Step08_MemoryUsingCosmosNoSql/)|This sample demonstrates how to persist and retrieve chat history across sessions with Azure Cosmos DB for NoSQL.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ TextSearchStore textSearchStore = new(vectorStore, "product-and-policy-info", 30
|
||||
await textSearchStore.UpsertDocumentsAsync(GetSampleDocuments());
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the TextSearchStore.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
async Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchAdapterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Here we are limiting the search results to the single top result to demonstrate that we are accurately matching
|
||||
// specific search results for each question, but in a real world case, more results should be used.
|
||||
@@ -49,7 +49,7 @@ Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchRe
|
||||
Text = r.Text ?? string.Empty,
|
||||
RawRepresentation = r
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
@@ -63,7 +63,7 @@ AIAgent agent = aiProjectClient
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { ModelId = deploymentName, Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
|
||||
AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)],
|
||||
AIContextProviders = [new TextSearchProvider(SearchAdapterAsync, textSearchOptions)],
|
||||
// Since we are using ChatCompletion which stores chat history locally, we can also add a message filter
|
||||
// that removes messages produced by the TextSearchProvider before they are added to the chat history, so that
|
||||
// we don't bloat chat history with all the search result messages.
|
||||
|
||||
+3
-3
@@ -40,7 +40,7 @@ await UploadDataFromMarkdown(afOverviewUrl, "Microsoft Agent Framework Overview"
|
||||
await UploadDataFromMarkdown(afMigrationUrl, "Semantic Kernel to Microsoft Agent Framework Migration Guide", documentationCollection, 2000, 200);
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the collection.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
async Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchAdapterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
List<TextSearchProvider.TextSearchResult> results = [];
|
||||
await foreach (var result in documentationCollection.SearchAsync(text, 5, cancellationToken: ct))
|
||||
@@ -54,7 +54,7 @@ Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchRe
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
@@ -72,7 +72,7 @@ AIAgent agent = aiProjectClient
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { ModelId = deploymentName, Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief." },
|
||||
AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)],
|
||||
AIContextProviders = [new TextSearchProvider(SearchAdapterAsync, textSearchOptions)],
|
||||
// Configure a filter on the InMemoryChatHistoryProvider so that we don't persist the messages produced by the TextSearchProvider in chat history.
|
||||
// The default is to persist all messages except those that came from chat history in the first place.
|
||||
// You may choose to persist the TextSearchProvider messages, if you want the search output to be provided to the model in future interactions as well.
|
||||
|
||||
@@ -23,7 +23,7 @@ var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ??
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// A sample function to load the next three calendar events for the user.
|
||||
Func<Task<string[]>> loadNextThreeCalendarEvents = async () =>
|
||||
async Task<string[]> LoadNextThreeCalendarEventsAsync()
|
||||
{
|
||||
// In a real implementation, this method would connect to a calendar service
|
||||
return
|
||||
@@ -32,7 +32,7 @@ Func<Task<string[]>> loadNextThreeCalendarEvents = async () =>
|
||||
"Team meeting today at 17:00",
|
||||
"Birthday party today at 20:00"
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
// Create an agent with an AI context provider attached that aggregates two other providers.
|
||||
// You must dissable client side conversation storage for clients that support it:
|
||||
@@ -64,7 +64,7 @@ AIAgent agent = new AIProjectClient(
|
||||
// The agent will call each provider in sequence, accumulating context from each.
|
||||
AIContextProviders = [
|
||||
new TodoListAIContextProvider(),
|
||||
new CalendarSearchAIContextProvider(loadNextThreeCalendarEvents)
|
||||
new CalendarSearchAIContextProvider(LoadNextThreeCalendarEventsAsync)
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -101,10 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
|
||||
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
|
||||
@@ -20,9 +20,9 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.T
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ var agent = new AzureOpenAIClient(
|
||||
]);
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
|
||||
@@ -13,9 +13,9 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
|
||||
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
@@ -157,10 +157,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
|
||||
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -725,7 +725,7 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
|
||||
? HttpCompletionOption.ResponseHeadersRead
|
||||
: HttpCompletionOption.ResponseContentRead;
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
using var response = await client.SendAsync( // CodeQL [SM03781] False positive: ValidateProxyTarget confirms the target host, scheme, and port match the configured backend, so the user-supplied path and query cannot change the destination.
|
||||
request, completionOption, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
|
||||
|
||||
@@ -11,8 +11,16 @@ using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
// The terminal stream events are named the same in two namespaces this file pulls in, and the short
|
||||
// name binds to the one the event objects are not. Naming them here keeps `is` checks against the
|
||||
// types the response stream actually produces.
|
||||
using ResponseCompletedEvent = Azure.AI.AgentServer.Responses.Models.ResponseCompletedEvent;
|
||||
using ResponseFailedEvent = Azure.AI.AgentServer.Responses.Models.ResponseFailedEvent;
|
||||
using ResponseIncompleteEvent = Azure.AI.AgentServer.Responses.Models.ResponseIncompleteEvent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
@@ -112,16 +120,19 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
|
||||
// by design — per-user isolation applies only when a user identity was resolved (hosted).
|
||||
var conversationId = request.GetConversationId();
|
||||
var sessionConversationId = HostedConversationKey.Resolve(
|
||||
var agentSessionId = HostedConversationKey.Resolve(
|
||||
conversationId, request.PreviousResponseId, context.ResponseId);
|
||||
|
||||
var chatClientAgent = agent.GetService<ChatClientAgent>();
|
||||
var agentOptions = agent.GetService<ChatClientAgentOptions>();
|
||||
var hostingOptions = this._serviceProvider.GetService<IOptions<FoundryResponsesOptions>>()?.Value;
|
||||
var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false;
|
||||
|
||||
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
|
||||
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
|
||||
: chatClientAgent is not null
|
||||
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Load the session for this conversation, or start a new one. The store returns null when
|
||||
// nothing is persisted for the key, so a fresh conversation and a resumed one both end up with
|
||||
// a session to run against.
|
||||
AgentSession? session = !string.IsNullOrWhiteSpace(agentSessionId)
|
||||
? await sessionStore.GetOrCreateSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only).
|
||||
// It is re-applied to the ambient HostedCallContext immediately before each outbound egress
|
||||
@@ -160,24 +171,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
yield return stream.EmitCreated();
|
||||
yield return stream.EmitInProgress();
|
||||
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
// 4. Convert input: the current input items become the run's messages. Earlier turns are not
|
||||
// added here; whatever holds the history for this agent supplies them, see step 5.
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history only for fresh sessions. When a session already exists
|
||||
// (e.g. resuming a workflow paused at an external-input port), the workflow's
|
||||
// checkpointed state already contains the prior turns' messages — replaying history
|
||||
// would re-drive completed actions and break HITL resume semantics.
|
||||
var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId))
|
||||
&& session?.StateBag?.Count > 0;
|
||||
if (!isResume)
|
||||
{
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
|
||||
}
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
@@ -191,7 +188,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(request);
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(
|
||||
request,
|
||||
agentOptions?.ChatOptions?.RawRepresentationFactory,
|
||||
hostingOptions);
|
||||
chatOptions.Instructions = request.Instructions;
|
||||
|
||||
// Inject Foundry Toolbox tools when the toolbox service is available.
|
||||
@@ -338,6 +338,23 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// We only use a volatile provider for the conversation history if the agent is a ChatClientAgent and the allow setting is not intentionally set or not custom chat history provider is intentionally supplied.
|
||||
var useVolatileChatHistoryProvider =
|
||||
!allowStoredOutputEnabled
|
||||
&& agent.GetService<ChatClientAgent>() is not null
|
||||
&& agentOptions?.ChatHistoryProvider is null;
|
||||
|
||||
// This will create a temporary in-memory provider for the conversation history, which will be dropped at the end of this run.
|
||||
// This is used to avoid storing the conversation history as the SDK will by default do the same via the (InMemory/Foundry)ResponsesProvider internal implementation.
|
||||
if (useVolatileChatHistoryProvider)
|
||||
{
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
options.AdditionalProperties ??= [];
|
||||
options.AdditionalProperties.Add<ChatHistoryProvider>(
|
||||
new VolatileChatHistoryProvider(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag)));
|
||||
}
|
||||
|
||||
// 6. Set up consent context for -32006 OAuth consent interception.
|
||||
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
|
||||
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
|
||||
@@ -350,6 +367,22 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
bool emittedTerminal = false;
|
||||
bool notAllowedStoreUsageDetected = false;
|
||||
|
||||
// Set when this turn is being failed, so its session is not kept. A turn that ends incomplete,
|
||||
// waiting on OAuth consent or interrupted by a shutdown, is not a failure: the caller comes back
|
||||
// for it and needs the state that was built up, the tool approval ids among it.
|
||||
bool turnFailed = false;
|
||||
|
||||
// A successful terminal event, held until the run is wound up and the session can be checked.
|
||||
ResponseStreamEvent? completedEvent = null;
|
||||
|
||||
// Check whenever the agent is storing messages when it should not.
|
||||
bool CheckNotAllowedStoreUsage() =>
|
||||
// For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null.
|
||||
// If for any reason this property is set it means that the storage setting was enabled when it shouldn't.
|
||||
!allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null };
|
||||
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
@@ -418,6 +451,17 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
if (failedEvent is not null)
|
||||
{
|
||||
// The run may have failed precisely because the agent stored the turn: the session
|
||||
// picks up that conversation id before the agent goes on to complain about having
|
||||
// two history managers. Report the cause rather than the symptom.
|
||||
if (CheckNotAllowedStoreUsage())
|
||||
{
|
||||
notAllowedStoreUsageDetected = true;
|
||||
turnFailed = true;
|
||||
throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
|
||||
}
|
||||
|
||||
turnFailed = true;
|
||||
yield return failedEvent;
|
||||
yield break;
|
||||
}
|
||||
@@ -430,10 +474,21 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
yield break;
|
||||
}
|
||||
|
||||
// A completed event is held back rather than sent straight out. The id of any
|
||||
// conversation the agent's own service kept only lands on the session once the run is
|
||||
// fully wound up, which is after this point, so sending the event now could tell the
|
||||
// caller the turn finished and then hand them a failure for the very same turn.
|
||||
if (evt is ResponseCompletedEvent)
|
||||
{
|
||||
completedEvent = evt;
|
||||
emittedTerminal = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// yield is in the outer try (finally-only) — allowed by C#
|
||||
yield return evt!;
|
||||
|
||||
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
if (evt is ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
{
|
||||
emittedTerminal = true;
|
||||
}
|
||||
@@ -443,12 +498,32 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Persist session after streaming completes (successful or not). The user id partitions the
|
||||
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
|
||||
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
|
||||
// Only after the the agent ran when can check precisely if the session had been used to store messages in the backend for validation.
|
||||
if (CheckNotAllowedStoreUsage())
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
notAllowedStoreUsageDetected = true;
|
||||
turnFailed = true;
|
||||
}
|
||||
|
||||
// Persist the session for the next turn of this conversation, unless this one is being failed.
|
||||
if (session is not null && !turnFailed)
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (notAllowedStoreUsageDetected)
|
||||
{
|
||||
this._logger.LogError(
|
||||
"Agent '{AgentName}' should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent.",
|
||||
agent.Name);
|
||||
|
||||
throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
|
||||
}
|
||||
|
||||
if (completedEvent is not null)
|
||||
{
|
||||
yield return completedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -42,7 +43,8 @@ public abstract class AgentSessionStore
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a serialized agent session from persistent storage.
|
||||
/// Retrieves a serialized agent session from persistent storage, or <see langword="null"/> when
|
||||
/// no session is stored for the given identifiers.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
|
||||
@@ -55,12 +57,41 @@ public abstract class AgentSessionStore
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous retrieval operation.
|
||||
/// The task result contains the session, or a new session if not found.
|
||||
/// A task that represents the asynchronous retrieval operation. The task result contains the restored
|
||||
/// session, or <see langword="null"/> when nothing is stored for the given identifiers. This is a plain
|
||||
/// lookup: it never creates a session. Use <see cref="GetOrCreateSessionAsync"/> to get a ready-to-use
|
||||
/// session (loading an existing one or creating a new one), and use this method when the caller needs to
|
||||
/// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it).
|
||||
/// </returns>
|
||||
public abstract ValueTask<AgentSession> GetSessionAsync(
|
||||
public abstract ValueTask<AgentSession?> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the stored session for the given identifiers, or creates a new one via
|
||||
/// <see cref="AIAgent.CreateSessionAsync"/> when none is stored.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
|
||||
/// <param name="userId">The per-user partition key; see <see cref="GetSessionAsync"/> for its meaning.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task whose result is always a usable session, never <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// This is the convenience path for callers that only need a session to work with and do not care whether
|
||||
/// it was loaded or freshly created. It is implemented in terms of <see cref="GetSessionAsync"/>, so a
|
||||
/// store overriding that method gets this behavior for free.
|
||||
/// </remarks>
|
||||
public virtual async ValueTask<AgentSession> GetOrCreateSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
|
||||
?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
|
||||
$"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore).";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
|
||||
@@ -216,13 +216,13 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
|
||||
string path = this.GetSessionPath(agent, conversationId, userId);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse and clone so the document buffer can be released.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for hosting agents behind the Foundry Responses API.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FoundryResponsesOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent's own chat client is allowed to store the
|
||||
/// responses it produces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A hosted turn is already recorded by the storage provider that runs around this handler, and
|
||||
/// that record is the conversation the caller reads back. When the service behind the agent's chat
|
||||
/// client also stores the turn, the same exchange is written a second time onto a trail of its own,
|
||||
/// which nothing here reads and no one reconciles with the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// While this is <see langword="false"/>, hosting turns that storage off for every run (the "store"
|
||||
/// property in the JSON representation), and the readiness probe reports an agent whose
|
||||
/// configuration would keep it on. Set it to <see langword="true"/> to leave the agent's own
|
||||
/// setting exactly as the container configured it, in which case hosting neither changes it nor
|
||||
/// checks it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
public bool AllowStoredOutputEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include an encrypted version of reasoning tokens in
|
||||
/// reasoning item outputs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API
|
||||
/// statelessly (like when the store parameter is set to false, or when an organization is enrolled
|
||||
/// in the zero data retention program). It applies only while
|
||||
/// <see cref="AllowStoredOutputEnabled"/> is <see langword="false"/>, because that is when hosting
|
||||
/// turns storage off and the reasoning items would otherwise be lost between turns.
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="true"/>.
|
||||
/// </value>
|
||||
public bool IncludeReasoningEncryptedContent { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the service behind a hosted agent's chat client from storing the responses it produces, and
|
||||
/// reports the deployment that ends up storing them anyway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A hosted turn is already recorded by the AgentServer SDK's storage provider, which runs around the
|
||||
/// handler, and that record is the conversation the caller reads back. A service that also stores the
|
||||
/// turn writes the same exchange a second time onto a trail of its own, which nothing here reads and
|
||||
/// no one reconciles with the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Turning storage off is a container concern, so a deployment that still stores is a server-side
|
||||
/// misconfiguration rather than a bad request, and is reported as such.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class HostedStoredOutputCompatibility
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP status returned when the agent's own service stored the turn. <c>501 Not Implemented</c>
|
||||
/// is a server-side classification, because the deployment, not the caller, is misconfigured; it is
|
||||
/// also non-retryable and distinct from the generic <c>500</c> so it stands out in telemetry.
|
||||
/// </summary>
|
||||
internal const int MisconfiguredAgentStatusCode = 501;
|
||||
|
||||
/// <summary>
|
||||
/// Stable error code emitted in the response body so callers and tooling can match the condition.
|
||||
/// </summary>
|
||||
internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the error to throw when the agent's own service kept the turn.
|
||||
/// </summary>
|
||||
internal static ResponsesApiException CreateMisconfiguredAgentError() =>
|
||||
new(
|
||||
new Error(
|
||||
MisconfiguredAgentErrorCode,
|
||||
"The agent should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."),
|
||||
MisconfiguredAgentStatusCode);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Reports, on the <c>GET /readiness</c> probe, a registered agent configured to have its own service
|
||||
/// store the responses it produces, so a container that would record the conversation twice is caught
|
||||
/// before it takes any traffic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each agent is run for real, with its chat client replaced for that run by
|
||||
/// <see cref="StoredOutputProbeChatClient"/>, which answers without calling anything. The run therefore
|
||||
/// builds the very request the agent would have sent, and the probe reads the store setting off it.
|
||||
/// Nothing hosting adds per request is applied here, so what the probe sees is how the container
|
||||
/// configured its agent. Nothing leaves the container either.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a confirmed "this asks to be stored" fails the probe. An agent that is not a
|
||||
/// <see cref="ChatClientAgent"/>, a request that carries no such setting, and a run that could not be
|
||||
/// completed are all reported as healthy: this package cannot tell what those would do, and a
|
||||
/// readiness probe is the wrong place to turn an uncertainty into an outage.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly FoundryResponsesOptions _hostingOptions;
|
||||
private readonly ILogger<HostedStoredOutputHealthCheck>? _logger;
|
||||
|
||||
public HostedStoredOutputHealthCheck(
|
||||
IServiceProvider serviceProvider,
|
||||
IOptions<FoundryResponsesOptions>? hostingOptions = null,
|
||||
ILogger<HostedStoredOutputHealthCheck>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._hostingOptions = hostingOptions?.Value ?? new FoundryResponsesOptions();
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (this._hostingOptions.AllowStoredOutputEnabled)
|
||||
{
|
||||
return HealthCheckResult.Healthy(
|
||||
"The hosted agent backend storage usage was detected and the stored output enabled setting is explicitly allowing it.");
|
||||
}
|
||||
|
||||
List<string> storingAgents = [];
|
||||
var checkedAgents = 0;
|
||||
|
||||
foreach (var agent in this.ResolveAgents())
|
||||
{
|
||||
if (agent.GetService<ChatClientAgent>() is null)
|
||||
{
|
||||
// Hosting only reaches the store setting through ChatClientAgent's chat options, so any
|
||||
// other agent runs untouched and there is nothing to report.
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedAgents++;
|
||||
if (await this.StoresItsOwnResponsesAsync(agent, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
storingAgents.Add(agent.Name ?? agent.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (storingAgents.Count > 0)
|
||||
{
|
||||
return new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Stored output: {storingAgents.Count} registered agent(s) should not have server side storage enabled. This will produce a new untracked conversation/response in the server while the hosted agent will also generate a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."),
|
||||
data: new Dictionary<string, object>(StringComparer.Ordinal) { ["storingAgents"] = storingAgents });
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy(
|
||||
string.Create(CultureInfo.InvariantCulture, $"Stored output: {checkedAgents} agent(s) checked, none asking to store responses of their own."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with its chat client replaced by one that calls nothing, and reports whether the
|
||||
/// request the agent built asks for the response to be stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The run carries no chat options of its own, so the agent's own configuration is what reaches the
|
||||
/// probe. Overriding the setting here, the way the request handler does per turn, would only show
|
||||
/// the override back.
|
||||
/// <para>
|
||||
/// The agent's chat history provider is stood down for this run, because it would otherwise read
|
||||
/// and write its own store on every readiness probe. A provider backed by a database would then be
|
||||
/// doing external calls, and adding this probe's empty turn to a real conversation, for a run that
|
||||
/// asks the agent nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<bool> StoresItsOwnResponsesAsync(AIAgent agent, CancellationToken cancellationToken)
|
||||
{
|
||||
var probe = new StoredOutputProbeChatClient();
|
||||
var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = _ => probe };
|
||||
runOptions.AdditionalProperties ??= [];
|
||||
runOptions.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());
|
||||
|
||||
try
|
||||
{
|
||||
await agent.RunAsync([], options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The agent could not complete a run it was never really asked to answer, which says nothing
|
||||
// about how it stores responses and is not held against it. A cancellation of its own, a
|
||||
// timeout inside the agent for instance, lands here too; only the health check's own
|
||||
// cancellation is left to propagate.
|
||||
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
|
||||
{
|
||||
this._logger.LogDebug(ex, "Could not probe the stored output setting for agent '{AgentName}'.", agent.Name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (probe.StoredOutputEnabled is null && this._logger?.IsEnabled(LogLevel.Debug) is true)
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
"Agent '{AgentName}' builds a request whose stored output setting could not be determined.",
|
||||
agent.Name);
|
||||
}
|
||||
|
||||
return probe.StoredOutputEnabled is true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every agent this container can serve: the ones registered under a name, plus the default.
|
||||
/// </summary>
|
||||
private List<AIAgent> ResolveAgents()
|
||||
{
|
||||
var agents = new List<AIAgent>(this._serviceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey));
|
||||
|
||||
if (this._serviceProvider.GetService<AIAgent>() is { } defaultAgent && !agents.Contains(defaultAgent))
|
||||
{
|
||||
agents.Add(defaultAgent);
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,15 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(agent, conversationId, userId);
|
||||
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
|
||||
|
||||
return sessionContent switch
|
||||
if (!this._sessions.TryGetValue(key, out var existingSession))
|
||||
{
|
||||
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store
|
||||
|
||||
@@ -7,7 +7,11 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using OpenAI.Responses;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
using MessageRole = Azure.AI.AgentServer.Responses.Models.MessageRole;
|
||||
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
@@ -87,10 +91,21 @@ internal static class InputConverter
|
||||
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <param name="agentRawRepresentationFactory">
|
||||
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any, so a request that has
|
||||
/// to set one of its own can run it rather than replace it.
|
||||
/// </param>
|
||||
/// <param name="hostingOptions">
|
||||
/// How this container was configured. When it allows the agent's own service to store responses,
|
||||
/// the setting is left exactly as the container configured it.
|
||||
/// </param>
|
||||
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
|
||||
public static ChatOptions ConvertToChatOptions(CreateResponse request)
|
||||
public static ChatOptions ConvertToChatOptions(
|
||||
CreateResponse request,
|
||||
Func<IChatClient, object?>? agentRawRepresentationFactory = null,
|
||||
FoundryResponsesOptions? hostingOptions = null)
|
||||
{
|
||||
return new ChatOptions
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
@@ -100,6 +115,22 @@ internal static class InputConverter
|
||||
// the client-provided model would override it (causing failures when
|
||||
// clients send placeholder values like "hosted-agent").
|
||||
};
|
||||
|
||||
if (hostingOptions?.AllowStoredOutputEnabled is true)
|
||||
{
|
||||
// The container opted into keeping its own recording, so nothing here touches the setting,
|
||||
// not even to pass the agent's own factory along: leaving it unset lets ChatClientAgent fall
|
||||
// back to the agent's untouched.
|
||||
return options;
|
||||
}
|
||||
|
||||
// The caller's own store flag is not carried across, this setting is the responsibility of the hosted agent implementation.
|
||||
DisableStoredOutput(
|
||||
options,
|
||||
agentRawRepresentationFactory,
|
||||
hostingOptions?.IncludeReasoningEncryptedContent ?? true);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -133,6 +164,64 @@ internal static class InputConverter
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs a factory on <paramref name="options"/> that turns storage off on the request the agent's
|
||||
/// chat client is about to build.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat options for this run.</param>
|
||||
/// <param name="agentRawRepresentationFactory">
|
||||
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any. It is invoked here and
|
||||
/// its result is what gets the setting, because <c>ChatClientAgent</c> chains the two by taking the
|
||||
/// agent's only when the request's returns null. A request factory that always answers would
|
||||
/// otherwise drop whatever the container configured.
|
||||
/// </param>
|
||||
/// <param name="includeReasoningEncryptedContent">
|
||||
/// Whether to ask for the encrypted form of the reasoning tokens, which is what keeps reasoning
|
||||
/// usable across turns while storage is off.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Both OpenAI request shapes carry the setting, so a chat client speaking either protocol is
|
||||
/// covered. Anything else is a request type with no notion of storing a response, and is handed back
|
||||
/// untouched.
|
||||
/// </remarks>
|
||||
private static void DisableStoredOutput(
|
||||
ChatOptions options,
|
||||
Func<IChatClient, object?>? agentRawRepresentationFactory,
|
||||
bool includeReasoningEncryptedContent)
|
||||
{
|
||||
options.RawRepresentationFactory = chatClient =>
|
||||
{
|
||||
switch (agentRawRepresentationFactory?.Invoke(chatClient))
|
||||
{
|
||||
case CreateResponseOptions responseOptions:
|
||||
return LocalDisableStoredOutput(responseOptions, includeReasoningEncryptedContent);
|
||||
|
||||
case ChatCompletionOptions completionOptions:
|
||||
completionOptions.StoredOutputEnabled = false;
|
||||
return completionOptions;
|
||||
|
||||
case { } configuredByTheAgent:
|
||||
return configuredByTheAgent;
|
||||
|
||||
default:
|
||||
return LocalDisableStoredOutput(new CreateResponseOptions(), includeReasoningEncryptedContent);
|
||||
}
|
||||
};
|
||||
|
||||
static CreateResponseOptions LocalDisableStoredOutput(CreateResponseOptions responseOptions, bool includeReasoningEncryptedContent)
|
||||
{
|
||||
responseOptions.StoredOutputEnabled = false;
|
||||
|
||||
if (includeReasoningEncryptedContent &&
|
||||
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
|
||||
{
|
||||
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
|
||||
}
|
||||
|
||||
return responseOptions;
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
|
||||
{
|
||||
return item switch
|
||||
|
||||
@@ -51,13 +51,18 @@ public static class FoundryHostingExtensions
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
|
||||
/// agent's own service to store the responses it produces.
|
||||
/// </param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services)
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action<FoundryResponsesOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
ConfigureFoundryResponsesOptions(services, configure);
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
@@ -86,8 +91,16 @@ public static class FoundryHostingExtensions
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
|
||||
/// agent's own service to store the responses it produces.
|
||||
/// </param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
|
||||
public static IServiceCollection AddFoundryResponses(
|
||||
this IServiceCollection services,
|
||||
AIAgent agent,
|
||||
AgentSessionStore? agentSessionStore = null,
|
||||
Action<FoundryResponsesOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
@@ -95,6 +108,7 @@ public static class FoundryHostingExtensions
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
ConfigureFoundryResponsesOptions(services, configure);
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -112,6 +126,41 @@ public static class FoundryHostingExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the caller's <see cref="FoundryResponsesOptions"/> and registers the readiness check that
|
||||
/// reports an agent configured to have its own service store the responses it produces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The check is registered on the same <c>/readiness</c> pipeline that <see cref="MapFoundryResponses"/>
|
||||
/// maps, so a container that would record the conversation twice never takes traffic.
|
||||
/// <c>AddCheck</c> does not dedupe by name, so a repeated registration is guarded here.
|
||||
/// </remarks>
|
||||
private static void ConfigureFoundryResponsesOptions(IServiceCollection services, Action<FoundryResponsesOptions>? configure)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
const string HealthCheckName = "foundry-stored-output";
|
||||
services.Configure<HealthCheckServiceOptions>(opts =>
|
||||
{
|
||||
foreach (var existing in opts.Registrations)
|
||||
{
|
||||
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Registrations.Add(new HealthCheckRegistration(
|
||||
name: HealthCheckName,
|
||||
factory: sp => ActivatorUtilities.CreateInstance<HostedStoredOutputHealthCheck>(sp),
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: ["foundry", "responses", "readiness"]));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
|
||||
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using OpenAI.Responses;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A chat client that answers without calling anything, and records whether the request it was handed
|
||||
/// asks for the response to be stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Used by <see cref="HostedStoredOutputHealthCheck"/> to run an agent for real, through
|
||||
/// <c>ChatClientAgentRunOptions.ChatClientFactory</c>, and see the request that agent builds on its own.
|
||||
/// Nothing hosting would add later is applied here, so what this observes is the container's own
|
||||
/// configuration. Nothing leaves the process either.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reply carries no conversation id, so the agent is not led to believe a service kept the
|
||||
/// conversation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class StoredOutputProbeChatClient : IChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the observed request asked for the response to be stored, or <see langword="null"/> when
|
||||
/// the run never reached the client or the request carries no such setting.
|
||||
/// </summary>
|
||||
public bool? StoredOutputEnabled { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.Observe(options);
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
this.Observe(options);
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceKey is null && serviceType?.IsInstanceOfType(this) is true ? this : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private void Observe(ChatOptions? options)
|
||||
{
|
||||
// Building the request is what the agent's chat client would do next, so running the factory
|
||||
// here shows the very setting that would have gone out.
|
||||
var rawRepresentation = options?.RawRepresentationFactory?.Invoke(this);
|
||||
this.StoredOutputEnabled = MapStoreSettingFromRawRepresentation(rawRepresentation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads whether a request the agent's chat client would send asks for the response to be stored.
|
||||
/// Returns <see langword="null"/> when the request shape could not be inferred, which is a request
|
||||
/// type this package has nothing to say about.
|
||||
/// </summary>
|
||||
private static bool? MapStoreSettingFromRawRepresentation(object? rawRepresentation) => rawRepresentation switch
|
||||
{
|
||||
// by default when the stored output setting is not set, the service stores the response, and we need to make the distinction from null (different request type)
|
||||
// so we can throw the right error in the health check
|
||||
CreateResponseOptions responseOptions => responseOptions.StoredOutputEnabled ?? true,
|
||||
ChatCompletionOptions completionOptions => completionOptions.StoredOutputEnabled ?? true,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ChatHistoryProvider"/> that holds a conversation in a field, for the lifetime of one
|
||||
/// request and no longer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider, which
|
||||
/// writes every turn the caller asked it to store and serves it back through
|
||||
/// <see cref="Azure.AI.AgentServer.Responses.ResponseContext.GetHistoryAsync"/>. That happens around
|
||||
/// the handler, not through it. An agent with no provider of its own is given that record here, so it
|
||||
/// reads the conversation the way it reads any other history, and anything it stores back is dropped
|
||||
/// with this instance rather than kept somewhere the storage provider never sees.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Within a single run the provider still does its ordinary work: an agent calling tools goes back to
|
||||
/// the chat client several times, and each of those calls needs the messages the earlier ones produced.
|
||||
/// Those live here until the run ends and the instance is dropped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Supplied as a run-scoped override through <see cref="AgentRunOptions.AdditionalProperties"/>, so it
|
||||
/// serves the turn without changing the agent. An agent that does not read its history through a
|
||||
/// provider ignores it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VolatileChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
private readonly List<ChatMessage> _messages;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VolatileChatHistoryProvider"/> class holding the
|
||||
/// conversation so far.
|
||||
/// </summary>
|
||||
/// <param name="history">The turns of this conversation the hosting service has recorded.</param>
|
||||
public VolatileChatHistoryProvider(IEnumerable<ChatMessage>? history = null)
|
||||
{
|
||||
this._messages = history is null ? [] : [.. history];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(this._messages);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.AddRange(context.RequestMessages);
|
||||
if (context.ResponseMessages is not null)
|
||||
{
|
||||
this._messages.AddRange(context.ResponseMessages);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
+38
-30
@@ -30,19 +30,20 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> can resume that other caller's
|
||||
/// persisted thread. Hosts that serve more than one user must compose a principal
|
||||
/// dimension into the lookup key — typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> and <c>taskId</c> arrive
|
||||
/// from the wire and are treated as chain-resume identifiers — <em>not</em> as
|
||||
/// authorization tokens. Both the <see cref="AgentSessionStore"/> and
|
||||
/// <see cref="ITaskStore"/> contracts carry no principal/owner dimension by default,
|
||||
/// so when a persistent store is registered any caller who knows or guesses another
|
||||
/// caller's <c>contextId</c> or <c>taskId</c> can access that other caller's data.
|
||||
/// Hosts that serve more than one user must compose a principal dimension into the
|
||||
/// lookup key — typically by calling <c>UseClaimsBasedAgentIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// <see cref="AgentIsolationKeyProvider"/>). When an <see cref="AgentIsolationKeyProvider"/>
|
||||
/// is registered, both the session store and the task store are automatically wrapped
|
||||
/// with tenant-scoped isolation. When no isolation provider is registered, behavior
|
||||
/// is unchanged — the bare identifiers are used directly, which is appropriate for
|
||||
/// first-run / single-user / prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
@@ -65,10 +66,10 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
|
||||
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
|
||||
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
|
||||
/// a custom <see cref="AgentIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
@@ -91,10 +92,10 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
|
||||
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
|
||||
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
|
||||
/// a custom <see cref="AgentIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
@@ -116,10 +117,10 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
|
||||
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
|
||||
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
|
||||
/// a custom <see cref="AgentIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
@@ -154,10 +155,10 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
|
||||
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
|
||||
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
|
||||
/// a custom <see cref="AgentIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
@@ -179,6 +180,8 @@ public static class A2AServerServiceCollectionExtensions
|
||||
|
||||
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
|
||||
{
|
||||
var isolationKeyProvider = serviceProvider.GetService<AgentIsolationKeyProvider>();
|
||||
|
||||
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
|
||||
if (agentHandler is null)
|
||||
{
|
||||
@@ -186,7 +189,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
@@ -201,7 +203,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
}
|
||||
|
||||
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
|
||||
ITaskStore taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
|
||||
|
||||
// Wrap the task store with isolation key scoping, same as the session store above.
|
||||
if (taskStore is not IsolationKeyScopedTaskStore)
|
||||
{
|
||||
taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null);
|
||||
}
|
||||
|
||||
return new A2AServer(
|
||||
agentHandler,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="ITaskStore"/> that scopes task keys by an isolation key
|
||||
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that tasks are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class mirrors the isolation pattern of <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// but applies it to the A2A task store, preventing cross-tenant task access in multi-tenant deployments.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both the store key and the persisted <see cref="AgentTask.ContextId"/> are scoped with the isolation
|
||||
/// key. Scoping the persisted context is what allows list queries to be constrained to the calling
|
||||
/// tenant, because list results are matched against the task body rather than the store key. Scoped
|
||||
/// values are stripped again before being returned, so callers only ever observe bare identifiers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class IsolationKeyScopedTaskStore : ITaskStore
|
||||
{
|
||||
private readonly ITaskStore _innerStore;
|
||||
private readonly AgentIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedTaskStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="ITaskStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="strict">
|
||||
/// When <see langword="true"/>, an <see cref="InvalidOperationException"/> is thrown if the isolation key
|
||||
/// cannot be determined. When <see langword="false"/>, the task ID is passed through unmodified.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
public IsolationKeyScopedTaskStore(
|
||||
ITaskStore innerStore,
|
||||
AgentIsolationKeyProvider? keyProvider,
|
||||
bool strict)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(innerStore);
|
||||
|
||||
this._innerStore = innerStore;
|
||||
this._keyProvider = keyProvider;
|
||||
this._strict = strict;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AgentTask?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var task = await this._innerStore.GetTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return task is null ? null : UnscopeTask(task, key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._innerStore.SaveTaskAsync(ScopeId(taskId, key), ScopeTask(task, key), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._innerStore.DeleteTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <see cref="ListTasksResponse.TotalSize"/> is reported by the inner store. When no
|
||||
/// <see cref="ListTasksRequest.ContextId"/> filter is supplied it therefore counts tasks across all
|
||||
/// isolation keys, because a wrapper cannot narrow the count without enumerating the whole store.
|
||||
/// The returned tasks themselves are always constrained to the current isolation key.
|
||||
/// </remarks>
|
||||
public async Task<ListTasksResponse> ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (key is not null && !string.IsNullOrEmpty(request.ContextId))
|
||||
{
|
||||
// Clone the request to avoid mutating the caller's object.
|
||||
request = CloneRequestWithContextId(request, ScopeId(request.ContextId!, key));
|
||||
}
|
||||
|
||||
var response = await this._innerStore.ListTasksAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// Tasks are persisted with a scoped ContextId, so any entry that does not carry the current
|
||||
// isolation key belongs to another tenant and must not be returned.
|
||||
var scopedTasks = new List<AgentTask>(response.Tasks.Count);
|
||||
foreach (var task in response.Tasks)
|
||||
{
|
||||
if (IsInScope(task, key))
|
||||
{
|
||||
scopedTasks.Add(UnscopeTask(task, key));
|
||||
}
|
||||
}
|
||||
|
||||
response.Tasks = scopedTasks;
|
||||
response.PageSize = scopedTasks.Count;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped identifiers.
|
||||
/// </summary>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Prefixes a bare identifier with the escaped isolation key, or returns it unchanged when no key applies.
|
||||
/// </summary>
|
||||
private static string ScopeId(string id, string? key)
|
||||
=> key is null ? id : $"{EscapeIsolationKey(key)}::{id}";
|
||||
|
||||
/// <summary>
|
||||
/// Strips the isolation key prefix from a scoped identifier, or returns it unchanged when the prefix is absent.
|
||||
/// </summary>
|
||||
private static string UnscopeId(string scopedId, string? key)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
return scopedId;
|
||||
}
|
||||
|
||||
string prefix = $"{EscapeIsolationKey(key)}::";
|
||||
|
||||
return scopedId.StartsWith(prefix, StringComparison.Ordinal)
|
||||
? scopedId.Substring(prefix.Length)
|
||||
: scopedId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a persisted task carries the supplied isolation key.
|
||||
/// </summary>
|
||||
private static bool IsInScope(AgentTask task, string key)
|
||||
=> task.ContextId?.StartsWith($"{EscapeIsolationKey(key)}::", StringComparison.Ordinal) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> is scoped by the isolation key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The task instance is copied rather than mutated because the A2A server reuses it for live event
|
||||
/// notification after persisting; mutating it would surface the scoped context on the wire.
|
||||
/// </remarks>
|
||||
private static AgentTask ScopeTask(AgentTask task, string? key)
|
||||
=> key is null ? task : CloneTaskWithContextId(task, ScopeId(task.ContextId, key));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> has the isolation key removed.
|
||||
/// </summary>
|
||||
private static AgentTask UnscopeTask(AgentTask task, string? key)
|
||||
=> key is null ? task : CloneTaskWithContextId(task, UnscopeId(task.ContextId, key));
|
||||
|
||||
private static AgentTask CloneTaskWithContextId(AgentTask task, string contextId)
|
||||
=> new()
|
||||
{
|
||||
Id = task.Id,
|
||||
ContextId = contextId,
|
||||
Status = task.Status,
|
||||
History = task.History,
|
||||
Artifacts = task.Artifacts,
|
||||
Metadata = task.Metadata,
|
||||
};
|
||||
|
||||
private static ListTasksRequest CloneRequestWithContextId(ListTasksRequest request, string contextId)
|
||||
=> new()
|
||||
{
|
||||
ContextId = contextId,
|
||||
Tenant = request.Tenant,
|
||||
Status = request.Status,
|
||||
PageSize = request.PageSize,
|
||||
PageToken = request.PageToken,
|
||||
HistoryLength = request.HistoryLength,
|
||||
StatusTimestampAfter = request.StatusTimestampAfter,
|
||||
IncludeArtifacts = request.IncludeArtifacts,
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -91,9 +91,9 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// principal dimension into the lookup key. The recommended way is to wrap the
|
||||
/// keyed <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <see cref="AgentIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
@@ -113,7 +113,7 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
|
||||
+17
-17
@@ -11,63 +11,63 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// An <see cref="AgentIsolationKeyProvider"/> that extracts an isolation key for agent-owned resources
|
||||
/// from a claim in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
|
||||
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// This provider is suitable for ASP.NET Core web applications where agent-owned resources are partitioned
|
||||
/// by authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
|
||||
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>
|
||||
/// must uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
|
||||
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
|
||||
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
|
||||
/// would receive the same isolation key and could read or overwrite one another's persisted data.
|
||||
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
|
||||
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
|
||||
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
|
||||
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
|
||||
/// provider maps a different claim).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// is missing, the provider returns <see langword="null"/>. Consuming stores then enforce strict or
|
||||
/// pass-through behavior based on their configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
public class ClaimsIdentityAgentIsolationKeyProvider : AgentIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
public ClaimsIdentityAgentIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
options ??= new ClaimsIdentityAgentIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// Extracts the isolation key for agent-owned resources from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
@@ -81,7 +81,7 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
|
||||
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
|
||||
/// multiple claims of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
+5
-5
@@ -5,12 +5,12 @@ using System.Security.Claims;
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// Options for configuring <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
public class ClaimsIdentityAgentIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// Gets or sets the claim type to extract from the user's identity to isolate agent-owned resources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -28,8 +28,8 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
|
||||
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
|
||||
/// across all callers. Two distinct principals that share the same value for a non-unique claim
|
||||
/// would receive the same session-isolation key and could read or overwrite one another's
|
||||
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
|
||||
/// would receive the same agent-isolation key and could read or overwrite one another's
|
||||
/// persisted data. Only override this value with a claim that is guaranteed unique and stable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
@@ -13,11 +13,11 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// Registers an <see cref="AgentIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate isolation keys for agent-owned resources.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <param name="options">Optional configuration for the claims-based isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -31,24 +31,24 @@ public static class ServiceCollectionExtensions
|
||||
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
|
||||
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
|
||||
/// such as Entra's <c>oid</c>) should override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
|
||||
/// absent, which causes strict-mode session stores to fail.
|
||||
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
|
||||
/// absent, which causes strict-mode stores to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> If you override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
|
||||
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
|
||||
/// uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
|
||||
/// the host can prove their uniqueness across all callers, because distinct principals that share the
|
||||
/// same claim value would receive the same isolation key and could access one another's sessions.
|
||||
/// same claim value would receive the same isolation key and could access one another's persisted data.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
public static IServiceCollection UseClaimsBasedAgentIsolation(
|
||||
this IServiceCollection services,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
|
||||
ServiceDescriptor descriptor = new(typeof(AgentIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
|
||||
services.Add(descriptor);
|
||||
|
||||
return services;
|
||||
@@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
return new ClaimsIdentityAgentIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -6,24 +6,26 @@ using System.Threading.Tasks;
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// Provides an abstract base class for resolving keys that isolate resources owned by hosted agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
|
||||
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
|
||||
/// implement the key resolution logic appropriate to their hosting environment.
|
||||
/// The <c>Agent</c> prefix identifies the hosting API domain; it does not mean that agent instances
|
||||
/// themselves are isolated. The returned key scopes agent-owned resources, such as sessions and A2A
|
||||
/// tasks, to a logical partition (e.g., user ID, tenant ID, or composite key). Other agent resources,
|
||||
/// such as memory or retrieval data, can use the same key when they require the same isolation boundary.
|
||||
/// Derived classes implement the key resolution logic appropriate to their hosting environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// Consuming stores can then enforce strict behavior (throwing an exception) or fall back to unscoped
|
||||
/// storage based on their configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
public abstract class AgentIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// Asynchronously retrieves the isolation key for agent-owned resources in the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
@@ -35,5 +37,5 @@ public abstract class SessionIsolationKeyProvider
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
public abstract ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -27,10 +27,10 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>sessionStoreId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
/// <c>sessionStoreId</c> to include an isolation key resolved from an
|
||||
/// <see cref="AgentIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentityAgentIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedAgentIsolation(...)</c>). When no provider is registered, the
|
||||
/// store behaves as a single-namespace persistence layer — appropriate for
|
||||
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
|
||||
@@ -59,7 +59,7 @@ public static class HostedAgentBuilderExtensions
|
||||
|
||||
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
var isolationKeyProvider = sp.GetService<AgentIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly AgentIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
@@ -21,7 +21,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
@@ -29,7 +29,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
AgentIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
@@ -51,12 +51,12 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
|
||||
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// when <see cref="AgentIsolationKeyProvider.GetIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
|
||||
|
||||
@@ -32,9 +32,9 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// or guesses another caller's identifier can resume that other caller's persisted
|
||||
/// thread. Multi-user hosts must wrap this store in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// <see cref="AgentIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -132,13 +133,80 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
|
||||
agent.RunStreamingAsync([], null, runOptions, cancellationToken);
|
||||
|
||||
await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false))
|
||||
await foreach (AgentResponseUpdate update in WithFailureDetectionAsync(agentResponse, agentVersionResult.Name, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
update.AuthorName = agentVersionResult.Name;
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces a failed Responses API run as <see cref="ErrorContent"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Microsoft.Extensions.AI.OpenAI</c> maps the <c>response.failed</c> event onto a
|
||||
/// contentless update, leaving a failed run indistinguishable from an empty successful one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The failed update is replaced rather than supplemented: it carries the provider's error text
|
||||
/// in its raw representation, and updates reach clients verbatim regardless of the host's
|
||||
/// exception-detail policy.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static async IAsyncEnumerable<AgentResponseUpdate> WithFailureDetectionAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
string? authorName,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
update.AuthorName = authorName;
|
||||
|
||||
yield return TryCreateFailureUpdate(update, authorName, out AgentResponseUpdate? failureUpdate)
|
||||
? failureUpdate
|
||||
: update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="ErrorContent"/> update when <paramref name="update"/> represents a failed run.
|
||||
/// </summary>
|
||||
private static bool TryCreateFailureUpdate(
|
||||
AgentResponseUpdate update,
|
||||
string? authorName,
|
||||
[NotNullWhen(true)] out AgentResponseUpdate? failureUpdate)
|
||||
{
|
||||
failureUpdate = null;
|
||||
|
||||
if (update.RawRepresentation is not ChatResponseUpdate chatUpdate ||
|
||||
chatUpdate.RawRepresentation is not StreamingResponseFailedUpdate failedUpdate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ResponseError? error = failedUpdate.Response?.Error;
|
||||
|
||||
// A failure with no detail must still explain itself to the client.
|
||||
ErrorContent errorContent =
|
||||
new(string.IsNullOrWhiteSpace(error?.Message) ? DefaultFailureMessage : error!.Message)
|
||||
{
|
||||
ErrorCode = error?.Code.ToString() is { Length: > 0 } code ? code : DefaultFailureCode,
|
||||
};
|
||||
|
||||
failureUpdate =
|
||||
new(ChatRole.Assistant, [errorContent])
|
||||
{
|
||||
AuthorName = authorName,
|
||||
ResponseId = update.ResponseId ?? failedUpdate.Response?.Id,
|
||||
CreatedAt = update.CreatedAt,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private const string DefaultFailureMessage = "The agent run failed.";
|
||||
private const string DefaultFailureCode = "failed";
|
||||
|
||||
private async Task<ProjectsAgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string agentKey = $"{agentName}:{agentVersion}";
|
||||
|
||||
+43
-1
@@ -77,7 +77,9 @@ internal static class AgentProviderExtensions
|
||||
|
||||
updates.Add(update);
|
||||
|
||||
if (autoSend)
|
||||
// Error updates are withheld: they reach the client verbatim, bypassing the host's
|
||||
// exception-detail policy. The detail still arrives via the thrown exception.
|
||||
if (autoSend && !HasError(update))
|
||||
{
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -85,6 +87,9 @@ internal static class AgentProviderExtensions
|
||||
|
||||
AgentResponse response = updates.ToAgentResponse();
|
||||
|
||||
// Fail before the response is announced as completed or copied to the conversation.
|
||||
ThrowIfFailed(response, agentName);
|
||||
|
||||
if (autoSend)
|
||||
{
|
||||
await context.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
|
||||
@@ -111,4 +116,41 @@ internal static class AgentProviderExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether an update carries an agent error rather than usable content.
|
||||
/// </summary>
|
||||
private static bool HasError(AgentResponseUpdate update) =>
|
||||
update.Contents.Any(content => content is ErrorContent);
|
||||
|
||||
/// <summary>
|
||||
/// Fails the action when the agent reported an error rather than a usable response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Any top-level <see cref="ErrorContent"/> is a failure, covering both a failed run and a
|
||||
/// refusal, and excluding <c>incomplete</c>, which carries partial content instead. The detail
|
||||
/// is folded into the exception so one error path stays under the host's exception-detail policy.
|
||||
/// </remarks>
|
||||
private static void ThrowIfFailed(AgentResponse response, string agentName)
|
||||
{
|
||||
// The last error wins: a run that fails without detail yields a generic placeholder first,
|
||||
// and the specific cause follows as its own error.
|
||||
ErrorContent? error =
|
||||
response.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.OfType<ErrorContent>()
|
||||
.LastOrDefault();
|
||||
|
||||
if (error is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string errorCode = string.IsNullOrWhiteSpace(error.ErrorCode) ? "unknown" : error.ErrorCode!;
|
||||
string errorMessage = string.IsNullOrWhiteSpace(error.Message) ? "No error message was provided." : error.Message!;
|
||||
|
||||
// No inner exception: DeclarativeActionException is unwrapped to its inner exception when
|
||||
// reported, which would discard this message.
|
||||
throw new DeclarativeActionException($"Agent '{agentName}' failed [{errorCode}]: {errorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
+72
-13
@@ -2,29 +2,40 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
|
||||
internal static partial class StringExtensions
|
||||
internal static class StringExtensions
|
||||
{
|
||||
#if NET
|
||||
[GeneratedRegex(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Multiline)]
|
||||
private static partial Regex TrimJsonDelimiterRegex();
|
||||
#else
|
||||
private static Regex TrimJsonDelimiterRegex() => s_trimJsonDelimiterRegex;
|
||||
private static readonly Regex s_trimJsonDelimiterRegex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline);
|
||||
#endif
|
||||
private const string JsonDelimiter = "```";
|
||||
|
||||
public static string TrimJsonDelimiter(this string value)
|
||||
{
|
||||
value = value.Trim();
|
||||
|
||||
Match match = TrimJsonDelimiterRegex().Match(value);
|
||||
return match.Success ?
|
||||
match.Groups[1].Value.Trim() :
|
||||
value;
|
||||
// Scan linearly so malformed fenced input cannot trigger regex backtracking.
|
||||
int openingDelimiterIndex = FindOpeningDelimiter(value);
|
||||
if (openingDelimiterIndex < 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
int contentIndex = openingDelimiterIndex + JsonDelimiter.Length;
|
||||
while (contentIndex < value.Length && IsWordCharacter(value[contentIndex]))
|
||||
{
|
||||
contentIndex++;
|
||||
}
|
||||
|
||||
while (contentIndex < value.Length && char.IsWhiteSpace(value[contentIndex]))
|
||||
{
|
||||
contentIndex++;
|
||||
}
|
||||
|
||||
int closingDelimiterIndex = FindClosingDelimiter(value, contentIndex);
|
||||
return closingDelimiterIndex < 0 ?
|
||||
value :
|
||||
value.Substring(contentIndex, closingDelimiterIndex - contentIndex).Trim();
|
||||
}
|
||||
|
||||
public static FormulaValue ToFormula(this string? value) =>
|
||||
@@ -34,6 +45,54 @@ internal static partial class StringExtensions
|
||||
|
||||
public static string FormatName(this string identifier) => FormatIdentifier(identifier, skipFirst: true);
|
||||
|
||||
private static int FindOpeningDelimiter(string value)
|
||||
{
|
||||
for (int index = 0; index <= value.Length - JsonDelimiter.Length; index++)
|
||||
{
|
||||
if ((index == 0 || value[index - 1] == '\n') && IsDelimiterAt(value, index))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindClosingDelimiter(string value, int startIndex)
|
||||
{
|
||||
for (int index = startIndex; index <= value.Length - JsonDelimiter.Length; index++)
|
||||
{
|
||||
if (IsDelimiterAt(value, index) && IsLineEnd(value, index + JsonDelimiter.Length))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsDelimiterAt(string value, int index) =>
|
||||
value[index] == '`' &&
|
||||
value[index + 1] == '`' &&
|
||||
value[index + 2] == '`';
|
||||
|
||||
private static bool IsLineEnd(string value, int index) =>
|
||||
index == value.Length ||
|
||||
value[index] == '\n' ||
|
||||
(value[index] == '\r' && index + 1 < value.Length && value[index + 1] == '\n');
|
||||
|
||||
// Keep language qualifier handling compatible with .NET regex \w semantics.
|
||||
private static bool IsWordCharacter(char value) =>
|
||||
char.GetUnicodeCategory(value) is
|
||||
UnicodeCategory.UppercaseLetter or
|
||||
UnicodeCategory.LowercaseLetter or
|
||||
UnicodeCategory.TitlecaseLetter or
|
||||
UnicodeCategory.ModifierLetter or
|
||||
UnicodeCategory.OtherLetter or
|
||||
UnicodeCategory.NonSpacingMark or
|
||||
UnicodeCategory.DecimalDigitNumber or
|
||||
UnicodeCategory.ConnectorPunctuation;
|
||||
|
||||
private static string FormatIdentifier(string identifier, bool skipFirst = false)
|
||||
{
|
||||
string[] words = identifier.Split('_');
|
||||
|
||||
@@ -280,9 +280,8 @@ internal static class SemanticAnalyzer
|
||||
if (!first.DerivesFromExecutor)
|
||||
{
|
||||
allDiagnostics.Add(Diagnostic.Create(
|
||||
DiagnosticDescriptors.NotAnExecutor,
|
||||
DiagnosticDescriptors.ProtocolClassNotAnExecutor,
|
||||
classLocation,
|
||||
first.ClassName,
|
||||
first.ClassName));
|
||||
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
|
||||
}
|
||||
@@ -290,7 +289,7 @@ internal static class SemanticAnalyzer
|
||||
if (!first.IsPartialClass)
|
||||
{
|
||||
allDiagnostics.Add(Diagnostic.Create(
|
||||
DiagnosticDescriptors.ClassMustBePartial,
|
||||
DiagnosticDescriptors.ProtocolClassMustBePartial,
|
||||
classLocation,
|
||||
first.ClassName));
|
||||
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
|
||||
|
||||
+22
@@ -104,4 +104,26 @@ internal static class DiagnosticDescriptors
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true));
|
||||
|
||||
/// <summary>
|
||||
/// MAFGENWF008: Executor with protocol attributes must be partial.
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor ProtocolClassMustBePartial = Register(new(
|
||||
id: "MAFGENWF008",
|
||||
title: "Executor with protocol attributes must be partial",
|
||||
messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but is not declared as partial",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true));
|
||||
|
||||
/// <summary>
|
||||
/// MAFGENWF009: Protocol attributes on non-Executor class.
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor ProtocolClassNotAnExecutor = Register(new(
|
||||
id: "MAFGENWF009",
|
||||
title: "Protocol attributes on non-Executor class",
|
||||
messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but does not derive from Executor",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// This attribute can be applied multiple times to declare multiple message types.
|
||||
/// It is inherited by derived classes, allowing base executors to declare common message types.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When this attribute is applied to an executor class and the workflows source generator is referenced,
|
||||
/// the class must be declared <c>partial</c> so the generator can add its protocol configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// This attribute can be applied multiple times to declare multiple output types.
|
||||
/// It is inherited by derived classes, allowing base executors to declare common output types.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When this attribute is applied to an executor class and the workflows source generator is referenced,
|
||||
/// the class must be declared <c>partial</c> so the generator can add its protocol configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
|
||||
@@ -132,7 +132,7 @@ internal sealed class MessageMerger
|
||||
_ = finishReasons.Add(response.FinishReason.Value);
|
||||
}
|
||||
|
||||
usage = MergeUsage(usage, response.Usage);
|
||||
usage = UsageAggregator.Combine(usage, response.Usage);
|
||||
additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ internal sealed class MessageMerger
|
||||
Messages = current.Messages.Concat(incoming.Messages).ToList(),
|
||||
ResponseId = current.ResponseId,
|
||||
RawRepresentation = rawRepresentation,
|
||||
Usage = MergeUsage(current.Usage, incoming.Usage),
|
||||
Usage = UsageAggregator.Combine(current.Usage, incoming.Usage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,40 +269,5 @@ internal sealed class MessageMerger
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
static UsageDetails? MergeUsage(UsageDetails? current, UsageDetails? incoming)
|
||||
{
|
||||
if (current is null)
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
AdditionalPropertiesDictionary<long>? additionalCounts = current.AdditionalCounts;
|
||||
if (incoming is null)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (additionalCounts is null)
|
||||
{
|
||||
additionalCounts = incoming.AdditionalCounts;
|
||||
}
|
||||
else if (incoming.AdditionalCounts is not null)
|
||||
{
|
||||
foreach (string key in incoming.AdditionalCounts.Keys)
|
||||
{
|
||||
additionalCounts[key] = incoming.AdditionalCounts[key] +
|
||||
(additionalCounts.TryGetValue(key, out long? existingCount) ? existingCount.Value : 0);
|
||||
}
|
||||
}
|
||||
|
||||
return new UsageDetails
|
||||
{
|
||||
InputTokenCount = current.InputTokenCount + incoming.InputTokenCount,
|
||||
OutputTokenCount = current.OutputTokenCount + incoming.OutputTokenCount,
|
||||
TotalTokenCount = current.TotalTokenCount + incoming.TotalTokenCount,
|
||||
AdditionalCounts = additionalCounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectSharedUsage>true</InjectSharedUsage>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
@@ -226,7 +227,10 @@ internal sealed class WorkflowTelemetryContext
|
||||
{
|
||||
return JsonSerializer.Serialize(value, value.GetType());
|
||||
}
|
||||
catch (JsonException)
|
||||
// Telemetry must never fail workflow execution. Serialization can throw arbitrary exceptions
|
||||
// (unregistered polymorphic types, reflection-disabled AOT, throwing property getters, custom
|
||||
// converters), so fall back for every serialization failure.
|
||||
catch (Exception)
|
||||
{
|
||||
return $"[Unserializable: {value.GetType().FullName}]";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -238,6 +240,41 @@ public sealed class ChatClientAgentOptions
|
||||
/// </value>
|
||||
public bool DisableApprovalResponseBinding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable bypassing that stores invocable (backend) function
|
||||
/// calls in the session state and executes them on the next request when they are returned alongside
|
||||
/// declaration-only (frontend) function calls in the same response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="FunctionInvokingChatClient"/> terminates the function-calling loop as soon as it encounters
|
||||
/// a non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
|
||||
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the caller
|
||||
/// unexecuted. When the caller only resolves the declaration-only call (for example an AG-UI frontend
|
||||
/// tool), the backend call's <c>call_id</c> is left orphaned, which causes the AI provider to reject the
|
||||
/// next request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When this property is set to <see langword="true"/>, an <see cref="InvocableFunctionBypassingChatClient"/>
|
||||
/// decorator is injected above <see cref="FunctionInvokingChatClient"/> in the pipeline. For responses that
|
||||
/// contain both invocable and declaration-only function calls, the decorator removes the invocable calls,
|
||||
/// stores them in the session, and returns only the declaration-only calls to the caller. On the next
|
||||
/// request the stored calls are re-injected as pre-approved responses so
|
||||
/// <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When using a custom chat client stack, you can add an <see cref="InvocableFunctionBypassingChatClient"/>
|
||||
/// manually via the <see cref="ChatClientBuilderExtensions.UseInvocableFunctionBypassing"/>
|
||||
/// extension method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool EnableInvocableFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
@@ -258,5 +295,6 @@ public sealed class ChatClientAgentOptions
|
||||
EnableMessageInjection = this.EnableMessageInjection,
|
||||
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
|
||||
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
|
||||
EnableInvocableFunctionBypassing = this.EnableInvocableFunctionBypassing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
@@ -221,4 +223,50 @@ public static class ChatClientBuilderExtensions
|
||||
return builder.Use((innerClient, services) =>
|
||||
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an <see cref="InvocableFunctionBypassingChatClient"/> to the chat client pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline.
|
||||
/// When <see cref="FunctionInvokingChatClient"/> returns a response containing both an invocable (backend)
|
||||
/// <see cref="FunctionCallContent"/> and a declaration-only (frontend) <see cref="FunctionCallContent"/> in
|
||||
/// the same iteration, this decorator removes the invocable calls, stores them in the session, and returns
|
||||
/// only the declaration-only calls to the caller. On the next request the stored calls are re-injected as
|
||||
/// pre-approved responses so <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must be
|
||||
/// positioned <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> that does not
|
||||
/// correspond to a request it recorded, so that a forged approval cannot execute. The responses this decorator
|
||||
/// injects are synthetic and have no such request, so placing the binding client below this decorator would
|
||||
/// silently discard them and prevent the stored calls from ever executing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This extension method is intended for use with custom chat client stacks when
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
||||
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
|
||||
/// <see cref="ChatClientAgentOptions.EnableInvocableFunctionBypassing"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator is intended for use within the context of a running <see cref="ChatClientAgent"/> with
|
||||
/// an active session. When invoked outside of an agent run (for example when the built chat client is used
|
||||
/// directly), the decorator becomes a no-op, passing the request through unchanged and logging a warning.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
||||
/// <param name="loggerFactory">
|
||||
/// An optional <see cref="ILoggerFactory"/> used to create a logger for the decorator. When not provided,
|
||||
/// the factory is resolved from the pipeline's <see cref="IServiceProvider"/>; if none is available,
|
||||
/// logging is a no-op.
|
||||
/// </param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static ChatClientBuilder UseInvocableFunctionBypassing(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
return builder.Use((innerClient, services) =>
|
||||
new InvocableFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public static class ChatClientExtensions
|
||||
// ApprovalNotRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
|
||||
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
|
||||
// making the first Use() call outermost. By adding this decorator here, the resulting pipeline is:
|
||||
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → FunctionInvokingChatClient
|
||||
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → [InvocableFunctionBypassingChatClient] → FunctionInvokingChatClient
|
||||
// → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → leaf IChatClient
|
||||
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
|
||||
// that don't actually require approval, storing them for automatic re-injection on the next request.
|
||||
@@ -78,6 +78,18 @@ public static class ChatClientExtensions
|
||||
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
|
||||
}
|
||||
|
||||
// InvocableFunctionBypassingChatClient is opt-in via EnableInvocableFunctionBypassing. It is
|
||||
// registered after the approval decorators and immediately before FunctionInvokingChatClient, so it
|
||||
// sits directly above FICC (ChatClientBuilder.Build applies factories in reverse order). It intercepts
|
||||
// FICC responses that contain both invocable (backend) and declaration-only (frontend) function calls,
|
||||
// removes the invocable calls, stores them in the session, and re-injects them as pre-approved
|
||||
// responses on the next request so FICC reconstructs and executes them.
|
||||
if (options?.EnableInvocableFunctionBypassing is true)
|
||||
{
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
new InvocableFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that lets an agent expose both invocable (backend) tools and
|
||||
/// declaration-only (frontend) tools at the same time, working around
|
||||
/// <see cref="FunctionInvokingChatClient"/> terminating the function-calling loop before invoking
|
||||
/// sibling backend tool calls when a declaration-only tool call appears in the same iteration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="FunctionInvokingChatClient"/> terminates the loop as soon as it encounters a
|
||||
/// non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
|
||||
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the
|
||||
/// caller unexecuted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline. On outbound
|
||||
/// responses that contain <em>both</em> an invocable (backend) <see cref="FunctionCallContent"/> and a
|
||||
/// declaration-only <see cref="FunctionCallContent"/>, it removes the invocable calls from the response,
|
||||
/// stores them in the session's <see cref="AgentSessionStateBag"/>, and returns only the declaration-only
|
||||
/// calls to the caller to resolve. On the next request — after the caller has resolved the
|
||||
/// declaration-only calls — the stored invocable calls are re-injected as pre-approved
|
||||
/// <see cref="ToolApprovalResponseContent"/> so that <see cref="FunctionInvokingChatClient"/> reconstructs
|
||||
/// and executes them, producing the missing <see cref="FunctionResultContent"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The stored calls are re-injected as approved <see cref="ToolApprovalResponseContent"/> rather than as
|
||||
/// bare <see cref="FunctionCallContent"/> because <see cref="FunctionInvokingChatClient"/> only re-executes
|
||||
/// calls that arrive from incoming history as approval responses; it does not execute bare
|
||||
/// <see cref="FunctionCallContent"/> present in the history. This is the same mechanism used by
|
||||
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, and a lone approved response without a
|
||||
/// matching request in the history is accepted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator operates within the context of a running <see cref="AIAgent"/> with an active
|
||||
/// <see cref="AgentRunContext.Session"/>. When invoked without an ambient run context or session
|
||||
/// (for example when the chat client is used directly outside of an agent run), the decorator becomes
|
||||
/// a no-op: it passes the request through to the inner client unchanged and logs a warning.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must sit
|
||||
/// <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> without a request it
|
||||
/// recorded, so that a forged approval cannot execute; the responses injected here are synthetic and have no
|
||||
/// such request. The default agent pipeline already orders them correctly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class InvocableFunctionBypassingChatClient : DelegatingChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The key used in <see cref="AgentSessionStateBag"/> to store bypassed invocable function calls
|
||||
/// between agent runs.
|
||||
/// </summary>
|
||||
internal const string StateBagKey = "_bypassedInvocableFunctionCalls";
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private bool _warnedNoSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvocableFunctionBypassingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
|
||||
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> used to create a logger for diagnostics.</param>
|
||||
public InvocableFunctionBypassingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<InvocableFunctionBypassingChatClient>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this.TryGetSession(out var session))
|
||||
{
|
||||
return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
messages = InjectPendingBypassedCalls(messages, session);
|
||||
|
||||
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this.RemoveAndStoreBypassableInvocableCalls(response.Messages, options, session);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this.TryGetSession(out var session))
|
||||
{
|
||||
await foreach (var passthrough in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return passthrough;
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
messages = InjectPendingBypassedCalls(messages, session);
|
||||
|
||||
// Stream updates live until a surfaced (non-informational) FunctionCallContent appears, then hold the
|
||||
// tail so the strip/store decision can observe every call in the same batch before re-emitting.
|
||||
// No whole-response coalescing is required because FunctionInvokingChatClient emits each call as a
|
||||
// complete FunctionCallContent (it never splits a call across updates).
|
||||
//
|
||||
// FunctionInvokingChatClient buffers per iteration: it yields its buffered updates at the end of each
|
||||
// iteration and then streams the next iteration live. When it invokes a call locally it flips
|
||||
// FunctionCallContent.InformationalOnly to true in place, on the very instances held here. A buffered
|
||||
// tail whose calls have all become informational therefore has nothing left to strip, so it is
|
||||
// released and live streaming resumes. Only a genuinely bypassable batch (where the loop terminated,
|
||||
// leaving the calls non-informational) is held to the end of the stream.
|
||||
List<ChatResponseUpdate>? tail = null;
|
||||
|
||||
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (tail is not null && !ContainsNonInformationalFunctionCall(tail))
|
||||
{
|
||||
foreach (var buffered in tail)
|
||||
{
|
||||
yield return buffered;
|
||||
}
|
||||
|
||||
tail = null;
|
||||
}
|
||||
|
||||
if (tail is null && !UpdateHasNonInformationalFunctionCall(update))
|
||||
{
|
||||
yield return update;
|
||||
continue;
|
||||
}
|
||||
|
||||
(tail ??= []).Add(update);
|
||||
}
|
||||
|
||||
if (tail is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var contentLists = new IList<AIContent>[tail.Count];
|
||||
for (int i = 0; i < tail.Count; i++)
|
||||
{
|
||||
contentLists[i] = tail[i].Contents;
|
||||
}
|
||||
|
||||
this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
|
||||
|
||||
// Every buffered update is surfaced, including any left with no contents by the stripping above. An
|
||||
// update carries metadata beyond its contents — ConversationId, ContinuationToken, ResponseId,
|
||||
// MessageId, RawRepresentation and more — so dropping one would discard state the caller needs, and a
|
||||
// content-free update is unremarkable in a stream. This differs from the non-streaming path, where an
|
||||
// emptied message is removed because it would otherwise be persisted to history and resent to the
|
||||
// provider on the next turn.
|
||||
foreach (var update in tail)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the current <see cref="AgentSession"/> from the ambient run context. When no run
|
||||
/// context or session is available, logs a warning (once per instance) and returns <see langword="false"/>
|
||||
/// so the caller can pass the request through without applying bypassing.
|
||||
/// </summary>
|
||||
private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
|
||||
{
|
||||
session = AIAgent.CurrentRunContext?.Session;
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
if (!this._warnedNoSession)
|
||||
{
|
||||
this._warnedNoSession = true;
|
||||
LogBypassingSkipped(this._logger);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "InvocableFunctionBypassingChatClient was invoked without an active agent run context or session. Invocable function bypassing is skipped and all function calls are surfaced to the caller. Invoke the chat client through AIAgent.RunAsync or AIAgent.RunStreamingAsync to enable bypassing.")]
|
||||
private static partial void LogBypassingSkipped(ILogger logger);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the session for invocable function calls stored on a previous turn and injects them as
|
||||
/// a user message containing pre-approved <see cref="ToolApprovalResponseContent"/> items appended to
|
||||
/// the input messages, so that <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Pending calls are consumed exactly once: the session entry is removed here and is never put back, so a
|
||||
/// turn that fails or whose stream is abandoned drops them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is deliberate. The calls are injected as one batch and the service expects a
|
||||
/// <see cref="FunctionResultContent"/> for every <see cref="FunctionCallContent"/> in it.
|
||||
/// <see cref="FunctionInvokingChatClient"/> invokes approved approval responses before the main loop and
|
||||
/// before any downstream service call, so by the time a request fails part of the batch has usually
|
||||
/// already run — and because a failure surfaces as an exception rather than a response, the results of
|
||||
/// those invocations are unrecoverable. Re-injecting the remainder would therefore still leave the batch
|
||||
/// incomplete while invoking already-executed functions a second time. Dropping the calls avoids the
|
||||
/// duplicate invocation, and matches
|
||||
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, which likewise never restores its entry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="messages">The outgoing messages.</param>
|
||||
/// <param name="session">The session holding any calls bypassed on a previous turn.</param>
|
||||
private static IEnumerable<ChatMessage> InjectPendingBypassedCalls(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession session)
|
||||
{
|
||||
if (!session.StateBag.TryGetValue(
|
||||
StateBagKey,
|
||||
out List<FunctionCallContent>? pendingCalls,
|
||||
AgentJsonUtilities.DefaultOptions)
|
||||
|| pendingCalls is not { Count: > 0 })
|
||||
{
|
||||
return messages;
|
||||
}
|
||||
|
||||
session.StateBag.TryRemoveValue(StateBagKey);
|
||||
|
||||
List<AIContent> approvalResponses = [];
|
||||
foreach (var call in pendingCalls)
|
||||
{
|
||||
// FunctionInvokingChatClient reconstructs and executes the call from the approval response
|
||||
// itself; the request is synthetic and does not need to be present in the history.
|
||||
var request = new ToolApprovalRequestContent(ComposeApprovalRequestId(call.CallId), call);
|
||||
approvalResponses.Add(request.CreateResponse(approved: true));
|
||||
}
|
||||
|
||||
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
|
||||
return messages.Concat([userMessage]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the approval-request id for a bypassed call. The prefix deliberately differs from the
|
||||
/// <c>ficc_</c> prefix <see cref="FunctionInvokingChatClient"/> uses for its own approval requests, so
|
||||
/// that a synthetic id can never collide with a genuine one.
|
||||
/// </summary>
|
||||
private static string ComposeApprovalRequestId(string callId) => $"ifbcc_{callId}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the set of invocable (backend) tool names and the set of declaration-only (frontend) tool
|
||||
/// names from <see cref="ChatOptions.Tools"/> and <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
|
||||
/// </summary>
|
||||
private (HashSet<string> Invocable, HashSet<string> DeclarationOnly) GetToolNameSets(ChatOptions? options)
|
||||
{
|
||||
var ficc = this.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
|
||||
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
|
||||
|
||||
HashSet<string> invocable = new(StringComparer.Ordinal);
|
||||
HashSet<string> declarationOnly = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tool in allTools)
|
||||
{
|
||||
// AIFunction derives from AIFunctionDeclaration, so check the invocable type first.
|
||||
if (tool is AIFunction function)
|
||||
{
|
||||
invocable.Add(function.Name);
|
||||
}
|
||||
else if (tool is AIFunctionDeclaration declaration)
|
||||
{
|
||||
declarationOnly.Add(declaration.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return (invocable, declarationOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if the update contains a non-informational
|
||||
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
|
||||
/// <see cref="FunctionInvokingChatClient"/>).
|
||||
/// </summary>
|
||||
private static bool UpdateHasNonInformationalFunctionCall(ChatResponseUpdate update)
|
||||
=> update.Contents.Any(c => c is FunctionCallContent { InformationalOnly: false });
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if any content list contains a non-informational
|
||||
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
|
||||
/// <see cref="FunctionInvokingChatClient"/>).
|
||||
/// </summary>
|
||||
private static bool ContainsNonInformationalFunctionCall(IList<AIContent>[] contentLists)
|
||||
=> contentLists.Any(contents => contents.Any(c => c is FunctionCallContent { InformationalOnly: false }));
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if any buffered update still contains a non-informational
|
||||
/// <see cref="FunctionCallContent"/>. Once <see cref="FunctionInvokingChatClient"/> has invoked a call it
|
||||
/// flips <see cref="FunctionCallContent.InformationalOnly"/> to <see langword="true"/> in place, so a
|
||||
/// buffer for which this returns <see langword="false"/> holds nothing that can be bypassed.
|
||||
/// </summary>
|
||||
private static bool ContainsNonInformationalFunctionCall(List<ChatResponseUpdate> updates)
|
||||
=> updates.Any(UpdateHasNonInformationalFunctionCall);
|
||||
|
||||
/// <summary>
|
||||
/// When a response contains both an invocable (backend) <see cref="FunctionCallContent"/> and a
|
||||
/// declaration-only (frontend) <see cref="FunctionCallContent"/>, removes the invocable calls from the
|
||||
/// response and stores them in the session for re-injection and execution on the next request.
|
||||
/// </summary>
|
||||
private void RemoveAndStoreBypassableInvocableCalls(
|
||||
IList<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
AgentSession session)
|
||||
{
|
||||
var contentLists = new IList<AIContent>[messages.Count];
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
contentLists[i] = messages[i].Contents;
|
||||
}
|
||||
|
||||
var emptied = this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
|
||||
if (emptied is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove messages that were emptied by stripping bypassed content (high index first). Messages that
|
||||
// were already empty (for example metadata-only messages) are left untouched. Unlike a streaming
|
||||
// update, an emptied message is worth removing because it would otherwise be persisted to the
|
||||
// conversation history and resent to the provider on the next turn.
|
||||
for (int i = messages.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (emptied.Contains(i))
|
||||
{
|
||||
messages.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the both-kinds gate over the supplied content lists and, when both an invocable and a
|
||||
/// declaration-only <see cref="FunctionCallContent"/> are present, removes the invocable calls in place
|
||||
/// (in document order) and stores them in the session for re-injection on the next request.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The set of content-list indices that were emptied by the removal, or <see langword="null"/> when
|
||||
/// nothing was bypassed.
|
||||
/// </returns>
|
||||
private HashSet<int>? StripAndStoreBypassableInvocableCalls(
|
||||
IList<AIContent>[] contentLists,
|
||||
ChatOptions? options,
|
||||
AgentSession session)
|
||||
{
|
||||
// Cheap first pass: the common case is a text-only response with no function calls. Avoid allocating
|
||||
// the tool-name sets unless the response actually contains at least one non-informational call.
|
||||
if (!ContainsNonInformationalFunctionCall(contentLists))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (invocable, declarationOnly) = this.GetToolNameSets(options);
|
||||
|
||||
if (invocable.Count == 0 || declarationOnly.Count == 0)
|
||||
{
|
||||
// The mixed backend/frontend scenario is impossible without at least one of each kind of tool.
|
||||
return null;
|
||||
}
|
||||
|
||||
bool hasInvocableCall = false;
|
||||
bool hasDeclarationOnlyCall = false;
|
||||
|
||||
foreach (var contents in contentLists)
|
||||
{
|
||||
foreach (var content in contents)
|
||||
{
|
||||
if (content is FunctionCallContent { InformationalOnly: false } fcc)
|
||||
{
|
||||
if (declarationOnly.Contains(fcc.Name))
|
||||
{
|
||||
hasDeclarationOnlyCall = true;
|
||||
}
|
||||
else if (invocable.Contains(fcc.Name))
|
||||
{
|
||||
hasInvocableCall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only bypass when the two kinds coexist in the same response. An all-invocable response is already
|
||||
// handled by FunctionInvokingChatClient (never surfaced unexecuted), and an all-declaration-only
|
||||
// response is the normal frontend-tools flow that must pass through unchanged.
|
||||
if (!hasInvocableCall || !hasDeclarationOnlyCall)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<FunctionCallContent>? bypassed = null;
|
||||
HashSet<int>? emptied = null;
|
||||
|
||||
for (int i = 0; i < contentLists.Length; i++)
|
||||
{
|
||||
var contents = contentLists[i];
|
||||
bool removedFromList = false;
|
||||
|
||||
// Forward scan collects the bypassed calls in document order.
|
||||
for (int j = 0; j < contents.Count;)
|
||||
{
|
||||
if (contents[j] is FunctionCallContent { InformationalOnly: false } fcc
|
||||
&& invocable.Contains(fcc.Name)
|
||||
&& !declarationOnly.Contains(fcc.Name))
|
||||
{
|
||||
(bypassed ??= []).Add(fcc);
|
||||
contents.RemoveAt(j);
|
||||
removedFromList = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedFromList && contents.Count == 0)
|
||||
{
|
||||
(emptied ??= []).Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (bypassed is { Count: > 0 })
|
||||
{
|
||||
session.StateBag.SetValue(StateBagKey, bypassed, AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
return emptied;
|
||||
}
|
||||
}
|
||||
@@ -77,22 +77,28 @@ public sealed class MessageInjectingChatClient : DelegatingChatClient
|
||||
// are pending but new messages have been injected into the queue, we call the service again
|
||||
// so the model can process them. The loop exits when the response contains actionable
|
||||
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
|
||||
// Usage is accumulated across every iteration so the returned response reports the token cost
|
||||
// of all service calls made, not just the last one.
|
||||
UsageDetails? aggregatedUsage = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
|
||||
|
||||
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
|
||||
// loop will iterate — return immediately so it can process them.
|
||||
if (HasActionableFunctionCalls(response.Messages))
|
||||
{
|
||||
return response;
|
||||
return response.ApplyAggregatedUsage(aggregatedUsage);
|
||||
}
|
||||
|
||||
// No actionable function calls. If there are pending injected messages, loop again
|
||||
// to send them to the service. Otherwise, we're done.
|
||||
if (await this.IsQueueEmptyAsync(session, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return response;
|
||||
return response.ApplyAggregatedUsage(aggregatedUsage);
|
||||
}
|
||||
|
||||
// Propagate any ConversationId returned by the service so subsequent iterations
|
||||
|
||||
+48
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -17,6 +18,18 @@ namespace Microsoft.Agents.AI;
|
||||
/// </remarks>
|
||||
internal sealed class BackgroundAgentRuntimeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an object used to synchronize access to the runtime references held by this instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Background task registration happens on the agent's tool-invocation path, while
|
||||
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/> may be called concurrently by a host.
|
||||
/// All mutations of the dictionaries below, and of <see cref="IsReleased"/>, must be performed under this lock
|
||||
/// so that a task can never be registered into an already-released runtime.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public object SyncRoot { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
|
||||
/// </summary>
|
||||
@@ -29,4 +42,39 @@ internal sealed class BackgroundAgentRuntimeState
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Dictionary<int, AgentSession> BackgroundTaskSessions { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapping of task IDs to the <see cref="CancellationTokenSource"/> controlling their run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A source is created when a task is started or continued, and is disposed and removed when the task is
|
||||
/// finalized, cleared, or when the session is released via <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/>.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public Dictionary<int, CancellationTokenSource> TaskCancellations { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this runtime has been released via
|
||||
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once released, all in-flight tasks have been cancelled and awaited, and the runtime references have been
|
||||
/// dropped. Tools that would start new background work refuse to run against a released runtime.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public bool IsReleased { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the completion signalled once the release of this runtime has finished all of its cleanup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set under <see cref="SyncRoot"/> by the caller that first releases the runtime, and completed once that
|
||||
/// caller has finished waiting for the in-flight tasks and has dropped the runtime references. Callers that
|
||||
/// arrive while a release is already in progress await this instead of returning early, so that a completed
|
||||
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/> always means the cleanup is done. It is completed
|
||||
/// successfully even when the releasing caller fails, because a waiter should observe that cleanup finished
|
||||
/// rather than inherit another caller's failure.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public TaskCompletionSource<bool>? ReleaseCompletion { get; set; }
|
||||
}
|
||||
|
||||
+385
-30
@@ -35,6 +35,11 @@ namespace Microsoft.Agents.AI;
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Background tasks are tracked per session and keep running until they complete. When a host is finished with a
|
||||
/// session it should call <see cref="ReleaseSessionAsync"/> to cancel and await any in-flight tasks, so that
|
||||
/// abandoned work does not continue to invoke models and tools in the background.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> The agents passed to the constructor are delegated
|
||||
/// arbitrary work by the parent agent — the parent sends them text input (which may include content
|
||||
/// derived from the parent's own untrusted context) and receives back whatever text they produce. A
|
||||
@@ -60,7 +65,16 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
{background_agents}
|
||||
""";
|
||||
|
||||
private const string ReleasedRuntimeStartError =
|
||||
"Error: The background agents runtime for this session has been released. No new background tasks can be started.";
|
||||
|
||||
private const string ReleasedRuntimeContinueError =
|
||||
"Error: The background agents runtime for this session has been released. Background tasks can no longer be continued.";
|
||||
|
||||
private const string ReleasedTaskCanceledMessage = "Task was canceled because the session was released.";
|
||||
|
||||
private readonly Dictionary<string, AIAgent> _agents;
|
||||
private static readonly TimeSpan s_defaultReleaseTimeout = TimeSpan.FromSeconds(30);
|
||||
private readonly ProviderSessionState<BackgroundAgentState> _sessionState;
|
||||
private readonly ProviderSessionState<BackgroundAgentRuntimeState> _runtimeSessionState;
|
||||
private readonly string _instructions;
|
||||
@@ -148,6 +162,248 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
return incomplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases all runtime state held for the specified session, cancelling and awaiting any in-flight background tasks.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session whose background runtime should be released.</param>
|
||||
/// <param name="cancelRunning">
|
||||
/// <see langword="true"/> to cancel any background tasks that are still running; <see langword="false"/> to require that
|
||||
/// all background tasks have already completed.
|
||||
/// </param>
|
||||
/// <param name="timeout">
|
||||
/// The maximum amount of time to wait for cancelled tasks to finish. Defaults to 30 seconds when <see langword="null"/>.
|
||||
/// Use <see cref="Timeout.InfiniteTimeSpan"/> to wait indefinitely. If the timeout elapses, the remaining tasks are
|
||||
/// abandoned rather than blocking the caller.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests while waiting.</param>
|
||||
/// <returns>A task that represents the asynchronous release operation.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="timeout"/> is negative and is not <see cref="Timeout.InfiniteTimeSpan"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException"><paramref name="cancelRunning"/> is <see langword="false"/> and one or more background tasks are still running.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Background tasks continue to execute — invoking models and tools — even after a host stops using the session
|
||||
/// that started them. Hosts should call this method when a conversation ends, or from their own eviction policy,
|
||||
/// so that abandoned work is stopped instead of running to completion with results nobody will read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This method is idempotent: releasing an already-released session does nothing. Once released, the
|
||||
/// <c>background_agents_start_task</c> and <c>background_agents_continue_task</c> tools refuse to run for that
|
||||
/// session, and any tasks that were still running are recorded as
|
||||
/// <see cref="BackgroundTaskStatus.Failed"/> so a restored session does not report phantom running work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also safe to call concurrently. A caller that arrives while another release of the same session is
|
||||
/// still in progress waits for that release to finish rather than returning early, so a completed call always
|
||||
/// means the background tasks have been cancelled, awaited and cleaned up. Such a caller observes only its own
|
||||
/// <paramref name="cancellationToken"/>; it neither inherits the in-progress release's failure nor is held up
|
||||
/// by that caller's <paramref name="timeout"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ReleaseSessionAsync(
|
||||
AgentSession session,
|
||||
bool cancelRunning = true,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
TimeSpan effectiveTimeout = timeout ?? s_defaultReleaseTimeout;
|
||||
if (effectiveTimeout < TimeSpan.Zero && effectiveTimeout != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(timeout),
|
||||
effectiveTimeout,
|
||||
"The timeout must not be negative, unless it is Timeout.InfiniteTimeSpan.");
|
||||
}
|
||||
|
||||
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session);
|
||||
BackgroundAgentState state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
KeyValuePair<int, Task<AgentResponse>>[] trackedTasks = [];
|
||||
HashSet<int> pendingTaskIds = [];
|
||||
TaskCompletionSource<bool>? releaseCompletion = null;
|
||||
Task? releaseInProgress = null;
|
||||
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
if (runtimeState.IsReleased)
|
||||
{
|
||||
// A release is already in progress or has completed. Await it rather than returning early, so that
|
||||
// a completed call always means the in-flight tasks have been cancelled, awaited and cleaned up.
|
||||
releaseInProgress = runtimeState.ReleaseCompletion?.Task;
|
||||
}
|
||||
else
|
||||
{
|
||||
trackedTasks = runtimeState.InFlightTasks.ToArray();
|
||||
|
||||
// Snapshot which tasks were still pending before anything is cancelled. Tasks that had already
|
||||
// finished keep their real outcome; only these pending ones are reported as released.
|
||||
pendingTaskIds = [.. trackedTasks.Where(t => !t.Value.IsCompleted).Select(t => t.Key)];
|
||||
|
||||
if (!cancelRunning && pendingTaskIds.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them.");
|
||||
}
|
||||
|
||||
// Cancel before publishing the release. If cancelling throws, the runtime is left un-released so
|
||||
// that the caller can retry, rather than being flagged as released with tasks still running.
|
||||
foreach (int taskId in pendingTaskIds)
|
||||
{
|
||||
if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts))
|
||||
{
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The source was already disposed by a concurrent finalization; nothing to cancel.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continuations run asynchronously so that a waiting caller never resumes inline on the thread
|
||||
// that is completing the release.
|
||||
releaseCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
runtimeState.ReleaseCompletion = releaseCompletion;
|
||||
runtimeState.IsReleased = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseCompletion is null)
|
||||
{
|
||||
if (releaseInProgress is not null)
|
||||
{
|
||||
await AwaitReleaseInProgressAsync(releaseInProgress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForTasksAsync(trackedTasks.Select(t => t.Value), effectiveTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
// Finalize every tracked task that actually finished, so successful results and real failure
|
||||
// reasons are preserved rather than being overwritten with a release failure.
|
||||
foreach (var kvp in trackedTasks)
|
||||
{
|
||||
BackgroundTaskInfo? tracked = state.Tasks.FirstOrDefault(t => t.Id == kvp.Key);
|
||||
if (tracked is null || tracked.Status != BackgroundTaskStatus.Running || !kvp.Value.IsCompleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FinalizeTask(tracked, kvp.Value, runtimeState);
|
||||
|
||||
if (kvp.Value.IsCanceled && pendingTaskIds.Contains(kvp.Key))
|
||||
{
|
||||
// Report the actual reason rather than the generic cancellation message.
|
||||
tracked.ErrorText = ReleasedTaskCanceledMessage;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int taskId in runtimeState.TaskCancellations.Keys.ToArray())
|
||||
{
|
||||
DisposeTaskCancellation(runtimeState, taskId);
|
||||
}
|
||||
|
||||
runtimeState.InFlightTasks.Clear();
|
||||
runtimeState.BackgroundTaskSessions.Clear();
|
||||
|
||||
// Anything still running was abandoned (for example after the timeout elapsed).
|
||||
foreach (BackgroundTaskInfo task in state.Tasks)
|
||||
{
|
||||
if (task.Status == BackgroundTaskStatus.Running)
|
||||
{
|
||||
task.Status = BackgroundTaskStatus.Failed;
|
||||
task.ErrorText = ReleasedTaskCanceledMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
this._runtimeSessionState.SaveState(session, runtimeState);
|
||||
|
||||
// Signalled last so that any caller awaiting this release observes fully cleaned-up state. Completed
|
||||
// successfully even when this caller failed, because the cleanup above always runs.
|
||||
releaseCompletion.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a release that another caller started to finish its cleanup, giving up if the caller's own
|
||||
/// <paramref name="cancellationToken"/> is signalled.
|
||||
/// </summary>
|
||||
private static async Task AwaitReleaseInProgressAsync(Task releaseInProgress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (releaseInProgress.IsCompleted || !cancellationToken.CanBeCanceled)
|
||||
{
|
||||
// The release completion is never faulted or cancelled, so awaiting it cannot throw.
|
||||
await releaseInProgress.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not let this caller be held up by the releasing caller's timeout.
|
||||
var cancellation = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellation))
|
||||
{
|
||||
await Task.WhenAny(releaseInProgress, cancellation.Task).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!releaseInProgress.IsCompleted)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the specified tasks to finish, observing their exceptions and giving up once the timeout elapses.
|
||||
/// </summary>
|
||||
private static async Task WaitForTasksAsync(IEnumerable<Task> tasks, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
// Attach an observer to every task, including those that already completed, so that a fault is always
|
||||
// observed. Otherwise clearing the last reference to a faulted task can surface an UnobservedTaskException.
|
||||
Task[] observers = tasks.Select(t => t.ContinueWith(
|
||||
static antecedent => _ = antecedent.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default)).ToArray();
|
||||
|
||||
// Observers never fault, so awaiting them cannot throw.
|
||||
Task[] pending = observers.Where(o => !o.IsCompleted).ToArray();
|
||||
if (pending.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Task all = Task.WhenAll(pending);
|
||||
|
||||
if (timeout == Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled)
|
||||
{
|
||||
await all.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
Task delay = Task.Delay(timeout, delayCts.Token);
|
||||
|
||||
Task winner = await Task.WhenAny(all, delay).ConfigureAwait(false);
|
||||
delayCts.Cancel();
|
||||
|
||||
if (winner != all)
|
||||
{
|
||||
// The wait was abandoned; the remaining tasks are left to finish on their own.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the agent collection and builds a case-insensitive name dictionary.
|
||||
/// </summary>
|
||||
@@ -204,25 +460,28 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (BackgroundTaskInfo task in state.Tasks)
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
if (task.Status != BackgroundTaskStatus.Running)
|
||||
foreach (BackgroundTaskInfo task in state.Tasks)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (task.Status != BackgroundTaskStatus.Running)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
|
||||
{
|
||||
// In-flight reference lost (e.g., after restart/deserialization).
|
||||
task.Status = BackgroundTaskStatus.Lost;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
|
||||
{
|
||||
// In-flight reference lost (e.g., after restart/deserialization).
|
||||
task.Status = BackgroundTaskStatus.Lost;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFlight.IsCompleted)
|
||||
{
|
||||
FinalizeTask(task, inFlight, runtimeState);
|
||||
changed = true;
|
||||
if (inFlight.IsCompleted)
|
||||
{
|
||||
FinalizeTask(task, inFlight, runtimeState);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +494,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
/// <summary>
|
||||
/// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo.
|
||||
/// </summary>
|
||||
/// <remarks>Callers must hold <see cref="BackgroundAgentRuntimeState.SyncRoot"/>.</remarks>
|
||||
private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task<AgentResponse> completedTask, BackgroundAgentRuntimeState runtimeState)
|
||||
{
|
||||
if (completedTask.Status == TaskStatus.RanToCompletion)
|
||||
@@ -256,6 +516,65 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
}
|
||||
|
||||
runtimeState.InFlightTasks.Remove(taskInfo.Id);
|
||||
DisposeTaskCancellation(runtimeState, taskInfo.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and disposes the <see cref="CancellationTokenSource"/> tracked for the specified task, if any.
|
||||
/// </summary>
|
||||
/// <remarks>Callers must hold <see cref="BackgroundAgentRuntimeState.SyncRoot"/>.</remarks>
|
||||
private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeState, int taskId)
|
||||
{
|
||||
if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts))
|
||||
{
|
||||
runtimeState.TaskCancellations.Remove(taskId);
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a background run for the specified task, tracking the resulting task, the background agent session,
|
||||
/// and a <see cref="CancellationTokenSource"/> that allows the run to be cancelled when the session is released.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the run was started and tracked; <see langword="false"/> if the session was
|
||||
/// released before the run could be registered, in which case nothing is started.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// All references for the task are registered under a single acquisition of
|
||||
/// <see cref="BackgroundAgentRuntimeState.SyncRoot"/>, so a concurrent release can never observe a partially
|
||||
/// registered task, nor can a caller re-add references to a runtime that has already been released.
|
||||
/// </remarks>
|
||||
private static bool StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession)
|
||||
{
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
// Re-check under the lock: the session may have been released while the caller awaited session creation.
|
||||
// Starting here would produce a task that is never tracked and therefore never cancelled.
|
||||
if (runtimeState.IsReleased)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Replace any cancellation source left over from a previous run of the same task.
|
||||
DisposeTaskCancellation(runtimeState, taskId);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
runtimeState.TaskCancellations[taskId] = cts;
|
||||
|
||||
// Registered here rather than by the caller so that the session reference cannot be re-added to a
|
||||
// runtime that a concurrent release has already cleared. For a continued task this simply re-assigns
|
||||
// the session the caller read from this same dictionary.
|
||||
runtimeState.BackgroundTaskSessions[taskId] = subSession;
|
||||
|
||||
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
|
||||
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
|
||||
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
|
||||
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
|
||||
// same FICC batch.
|
||||
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession, cancellationToken: cts.Token), cts.Token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
|
||||
@@ -270,6 +589,11 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
[Description("The request to pass to the background agent.")] string input,
|
||||
[Description("A description of the task used to identify the task later.")] string description) =>
|
||||
{
|
||||
if (runtimeState.IsReleased)
|
||||
{
|
||||
return ReleasedRuntimeStartError;
|
||||
}
|
||||
|
||||
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
|
||||
{
|
||||
return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
|
||||
@@ -288,13 +612,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
// Create a dedicated session for this background task so it can be continued later.
|
||||
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
|
||||
|
||||
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
|
||||
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
|
||||
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
|
||||
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
|
||||
// same FICC batch.
|
||||
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
|
||||
runtimeState.BackgroundTaskSessions[taskId] = subSession;
|
||||
if (!StartTrackedRun(runtimeState, taskId, agent, input, subSession))
|
||||
{
|
||||
// The session was released while the background session was being created.
|
||||
state.Tasks.Remove(taskInfo);
|
||||
this._sessionState.SaveState(session, state);
|
||||
return ReleasedRuntimeStartError;
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return $"Background task {taskId} started on agent '{agentName}'.";
|
||||
@@ -317,11 +641,14 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
// Collect in-flight tasks matching the requested IDs (including already-completed ones,
|
||||
// since Task.WhenAny returns immediately for completed tasks).
|
||||
var waitableTasks = new List<(int Id, Task<AgentResponse> Task)>();
|
||||
foreach (int id in taskIds)
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
|
||||
foreach (int id in taskIds)
|
||||
{
|
||||
waitableTasks.Add((id, inFlight));
|
||||
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
|
||||
{
|
||||
waitableTasks.Add((id, inFlight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +678,14 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
|
||||
if (taskInfo is not null)
|
||||
{
|
||||
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
if (taskInfo.Status == BackgroundTaskStatus.Running)
|
||||
{
|
||||
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
|
||||
}
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
}
|
||||
|
||||
@@ -420,6 +754,11 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
AIFunctionFactory.Create(
|
||||
(int taskId, string text) =>
|
||||
{
|
||||
if (runtimeState.IsReleased)
|
||||
{
|
||||
return ReleasedRuntimeContinueError;
|
||||
}
|
||||
|
||||
this.TryRefreshTaskState(state, runtimeState, session);
|
||||
|
||||
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
@@ -443,7 +782,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
|
||||
}
|
||||
|
||||
if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
|
||||
AgentSession? subSession;
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
_ = runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out subSession);
|
||||
}
|
||||
|
||||
if (subSession is null)
|
||||
{
|
||||
return $"Error: Session for task {taskId} is no longer available.";
|
||||
}
|
||||
@@ -454,7 +799,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
taskInfo.ErrorText = null;
|
||||
|
||||
// Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment).
|
||||
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
|
||||
if (!StartTrackedRun(runtimeState, taskId, agent, text, subSession))
|
||||
{
|
||||
taskInfo.Status = BackgroundTaskStatus.Failed;
|
||||
taskInfo.ErrorText = ReleasedTaskCanceledMessage;
|
||||
this._sessionState.SaveState(session, state);
|
||||
return ReleasedRuntimeContinueError;
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return $"Task {taskId} continued with new input.";
|
||||
@@ -486,8 +837,12 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
state.Tasks.Remove(taskInfo);
|
||||
|
||||
// Clean up runtime references.
|
||||
runtimeState.InFlightTasks.Remove(taskId);
|
||||
runtimeState.BackgroundTaskSessions.Remove(taskId);
|
||||
lock (runtimeState.SyncRoot)
|
||||
{
|
||||
runtimeState.InFlightTasks.Remove(taskId);
|
||||
runtimeState.BackgroundTaskSessions.Remove(taskId);
|
||||
DisposeTaskCancellation(runtimeState, taskId);
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return $"Task {taskId} cleared.";
|
||||
|
||||
@@ -163,6 +163,10 @@ public sealed class LoopAgent : DelegatingAIAgent
|
||||
// followed by that iteration's response messages. Unused when only the final response is returned.
|
||||
List<ChatMessage> transcript = [];
|
||||
|
||||
// Accumulates usage across every inner invocation so the returned response reports the token cost of the
|
||||
// whole run rather than only its final iteration. Aggregated even when only the last response is returned.
|
||||
UsageDetails? aggregatedUsage = null;
|
||||
|
||||
// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
|
||||
IReadOnlyList<ChatMessage> currentSurfaced = [];
|
||||
|
||||
@@ -174,6 +178,8 @@ public sealed class LoopAgent : DelegatingAIAgent
|
||||
AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false);
|
||||
iteration++;
|
||||
|
||||
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
|
||||
|
||||
// Record this iteration's on-behalf-of input (before the response it elicited) and the response itself.
|
||||
transcript.AddRange(currentSurfaced);
|
||||
transcript.AddRange(response.Messages);
|
||||
@@ -189,21 +195,21 @@ public sealed class LoopAgent : DelegatingAIAgent
|
||||
// Stop and surface the response when the agent is waiting for a tool approval.
|
||||
if (HasPendingApprovalRequests(response))
|
||||
{
|
||||
return this.BuildResult(response, transcript);
|
||||
return this.BuildResult(response, transcript, aggregatedUsage);
|
||||
}
|
||||
|
||||
// Enforce the global safety cap regardless of what the evaluators want.
|
||||
if (iteration >= this._maxIterations)
|
||||
{
|
||||
this.LogMaxIterationsReached(iteration);
|
||||
return this.BuildResult(response, transcript);
|
||||
return this.BuildResult(response, transcript, aggregatedUsage);
|
||||
}
|
||||
|
||||
// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
|
||||
LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
|
||||
if (!step.ShouldContinue)
|
||||
{
|
||||
return this.BuildResult(response, transcript);
|
||||
return this.BuildResult(response, transcript, aggregatedUsage);
|
||||
}
|
||||
|
||||
currentMessages = step.Messages;
|
||||
@@ -446,27 +452,14 @@ public sealed class LoopAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces the non-streaming run result: either the final iteration's response (when configured) or an
|
||||
/// aggregated response carrying the full transcript with the final response's metadata.
|
||||
/// Produces the non-streaming run result from the final iteration's response, which carries either its own
|
||||
/// messages (when configured) or the full transcript of the run. In both cases the usage reported is
|
||||
/// <paramref name="aggregatedUsage"/>, covering every iteration of the run.
|
||||
/// </summary>
|
||||
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript)
|
||||
{
|
||||
if (this._nonStreamingReturnsLastResponseOnly)
|
||||
{
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
return new AgentResponse(transcript)
|
||||
{
|
||||
AgentId = lastResponse.AgentId,
|
||||
ResponseId = lastResponse.ResponseId,
|
||||
CreatedAt = lastResponse.CreatedAt,
|
||||
FinishReason = lastResponse.FinishReason,
|
||||
Usage = lastResponse.Usage,
|
||||
AdditionalProperties = lastResponse.AdditionalProperties,
|
||||
ContinuationToken = lastResponse.ContinuationToken,
|
||||
};
|
||||
}
|
||||
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript, UsageDetails? aggregatedUsage)
|
||||
=> this._nonStreamingReturnsLastResponseOnly
|
||||
? lastResponse.ApplyAggregatedUsage(aggregatedUsage)
|
||||
: lastResponse.ApplyAggregatedUsage(aggregatedUsage, transcript);
|
||||
|
||||
private static bool HasPendingApprovalRequests(AgentResponse response)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -28,6 +29,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated
|
||||
/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time.
|
||||
/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again.
|
||||
/// This one-at-a-time behavior no longer applies once the auto-approval cap
|
||||
/// (<see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/>) is reached: the final inner turn is returned
|
||||
/// as-is, so more than one approval request may be surfaced to the caller at once.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Inbound (caller to agent):</b> When the caller sends an <see cref="AlwaysApproveToolApprovalResponseContent"/>,
|
||||
@@ -47,9 +51,13 @@ namespace Microsoft.Agents.AI;
|
||||
/// </remarks>
|
||||
public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>The default value used for <see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/> when none is specified.</summary>
|
||||
public const int DefaultMaxAutoApprovalIterations = 40;
|
||||
|
||||
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private readonly Func<ToolAutoApprovalRuleContext, ValueTask<bool>>[]? _autoApprovalRules;
|
||||
private readonly int _maxAutoApprovalIterations;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
|
||||
@@ -65,6 +73,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
|
||||
this._maxAutoApprovalIterations = Throw.IfLessThan(
|
||||
options?.MaxAutoApprovalIterations ?? DefaultMaxAutoApprovalIterations, 1);
|
||||
this._sessionState = new ProviderSessionState<ToolApprovalState>(
|
||||
_ => new ToolApprovalState(),
|
||||
"toolApprovalState",
|
||||
@@ -125,20 +135,48 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
|
||||
// that are ALL auto-approved by standing rules, we immediately re-call with the
|
||||
// collected approval responses injected. This avoids returning empty responses.
|
||||
while (true)
|
||||
//
|
||||
// The loop is bounded by _maxAutoApprovalIterations. Each pass is a fresh inner
|
||||
// invocation, so a per-request cap (FunctionInvokingChatClient.MaximumIterationsPerRequest)
|
||||
// restarts every time and cannot bound it; without a cap here a model that keeps
|
||||
// requesting an auto-approved tool bills indefinitely.
|
||||
//
|
||||
// Usage is accumulated across every re-invocation so the caller sees the token cost
|
||||
// of the whole run, not just its final inner call.
|
||||
UsageDetails? aggregatedUsage = null;
|
||||
|
||||
for (int iteration = 0; ; iteration++)
|
||||
{
|
||||
// Inject any collected approval responses as a user message ahead of the caller's messages.
|
||||
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
|
||||
|
||||
if (iteration >= this._maxAutoApprovalIterations)
|
||||
{
|
||||
// Cap reached: take one final turn without auto-approving again, so any approval
|
||||
// request it surfaces goes to the caller to decide rather than continuing the chain.
|
||||
// Returning here without this call would hand back a response whose approval requests
|
||||
// were already stripped — the empty response the loop exists to avoid.
|
||||
var cappedResponse = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// This turn is still part of the same run, so its usage joins the aggregate rather
|
||||
// than replacing it; otherwise hitting the cap would discard every prior turn's cost.
|
||||
UsageAggregator.Accumulate(ref aggregatedUsage, cappedResponse.Usage);
|
||||
|
||||
return cappedResponse.ApplyAggregatedUsage(aggregatedUsage);
|
||||
}
|
||||
|
||||
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
|
||||
|
||||
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
|
||||
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session, options, requestMessages).ConfigureAwait(false);
|
||||
|
||||
if (!allAutoApproved)
|
||||
{
|
||||
// Response has real content or an unapproved approval request — return to caller.
|
||||
return response;
|
||||
// Response has real content or an unapproved approval request — return to caller,
|
||||
// reporting the usage accumulated across every turn of the run.
|
||||
return response.ApplyAggregatedUsage(aggregatedUsage);
|
||||
}
|
||||
|
||||
// All approval requests were auto-approved. Loop to re-invoke with them injected.
|
||||
@@ -173,11 +211,26 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
|
||||
// are auto-approved by standing rules, we immediately re-stream with the collected
|
||||
// approval responses injected. This avoids returning empty streams.
|
||||
while (true)
|
||||
//
|
||||
// Bounded by _maxAutoApprovalIterations for the same reason as the non-streaming path:
|
||||
// every pass is a fresh inner invocation, so no per-request cap can bound it.
|
||||
for (int iteration = 0; ; iteration++)
|
||||
{
|
||||
// Inject any collected approval responses as a user message ahead of the caller's messages.
|
||||
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
|
||||
|
||||
if (iteration >= this._maxAutoApprovalIterations)
|
||||
{
|
||||
// Cap reached: take one final turn without auto-approving again. Updates are yielded
|
||||
// as-is, so any approval request reaches the caller to decide instead of continuing.
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Stream from the inner agent. Non-approval content is yielded immediately.
|
||||
// Approval requests are collected (not yielded) so we can classify the full batch.
|
||||
List<ToolApprovalRequestContent> streamedApprovalRequests = [];
|
||||
|
||||
@@ -46,4 +46,24 @@ public class ToolApprovalAgentOptions
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>>? AutoApprovalRules { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the safety cap on how many times the inner agent is re-invoked within a single run
|
||||
/// because every surfaced approval request was auto-approved, or <see langword="null"/> to use
|
||||
/// <see cref="ToolApprovalAgent.DefaultMaxAutoApprovalIterations"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each re-invocation is a fresh call to the inner agent, so a per-request cap such as
|
||||
/// <c>FunctionInvokingChatClient.MaximumIterationsPerRequest</c> restarts every time and cannot bound this
|
||||
/// loop. Without this cap a model that keeps requesting an auto-approved tool, drives an unbounded sequence of
|
||||
/// billable model calls.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On reaching the cap the agent performs one final inner invocation without auto-approving again, so any
|
||||
/// remaining approval request is surfaced to the caller to decide instead of being approved automatically.
|
||||
/// Raise this value if you intend to allow longer auto-approval chains.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? MaxAutoApprovalIterations { get; set; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user