diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml
index 3736348579..2920aaa5bd 100644
--- a/.github/actions/sample-validation-setup/action.yml
+++ b/.github/actions/sample-validation-setup/action.yml
@@ -24,7 +24,9 @@ runs:
using: "composite"
steps:
- name: Set up Node.js environment
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
- name: Install Copilot CLI
shell: bash
diff --git a/.github/actions/setup-local-mcp-server/action.yml b/.github/actions/setup-local-mcp-server/action.yml
new file mode 100644
index 0000000000..7bb9ca652a
--- /dev/null
+++ b/.github/actions/setup-local-mcp-server/action.yml
@@ -0,0 +1,166 @@
+name: Setup Local MCP Server
+description: Start and validate a local streamable HTTP MCP server for integration tests
+
+inputs:
+ fallback_url:
+ description: Existing LOCAL_MCP_URL value to keep as a fallback if local startup fails
+ required: false
+ default: ''
+ host:
+ description: Host interface to bind the local MCP server
+ required: false
+ default: '127.0.0.1'
+ port:
+ description: Port to bind the local MCP server
+ required: false
+ default: '8011'
+ mount_path:
+ description: Mount path for the local streamable HTTP MCP endpoint
+ required: false
+ default: '/mcp'
+
+outputs:
+ effective_url:
+ description: Local MCP URL when startup succeeds, otherwise the provided fallback URL
+ value: ${{ steps.start.outputs.effective_url }}
+ local_url:
+ description: URL of the local MCP server
+ value: ${{ steps.start.outputs.local_url }}
+ started:
+ description: Whether the local MCP server started and passed validation
+ value: ${{ steps.start.outputs.started }}
+ pid:
+ description: PID of the local MCP server process when startup succeeded
+ value: ${{ steps.start.outputs.pid }}
+
+runs:
+ using: composite
+ steps:
+ - name: Start and validate local MCP server
+ id: start
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ host="${{ inputs.host }}"
+ port="${{ inputs.port }}"
+ mount_path="${{ inputs.mount_path }}"
+ fallback_url="${{ inputs.fallback_url }}"
+
+ if [[ ! "$mount_path" =~ ^/ ]]; then
+ mount_path="/$mount_path"
+ fi
+
+ local_url="http://${host}:${port}${mount_path}"
+ health_url="http://${host}:${port}/healthz"
+ log_file="$RUNNER_TEMP/local-mcp-server.log"
+ pid_file="$RUNNER_TEMP/local-mcp-server.pid"
+ rm -f "$log_file" "$pid_file"
+
+ server_pid="$(
+ python3 - "$GITHUB_WORKSPACE/python" "$log_file" "$host" "$port" "$mount_path" <<'PY'
+ from __future__ import annotations
+
+ import subprocess
+ import sys
+
+ workspace, log_file, host, port, mount_path = sys.argv[1:]
+
+ with open(log_file, "w", encoding="utf-8") as log:
+ process = subprocess.Popen(
+ [
+ "uv",
+ "run",
+ "python",
+ "scripts/local_mcp_streamable_http_server.py",
+ "--host",
+ host,
+ "--port",
+ port,
+ "--mount-path",
+ mount_path,
+ ],
+ cwd=workspace,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+
+ print(process.pid)
+ PY
+ )"
+ echo "$server_pid" > "$pid_file"
+
+ started=false
+ for _ in $(seq 1 30); do
+ if curl --silent --fail "$health_url" >/dev/null; then
+ started=true
+ break
+ fi
+ if ! kill -0 "$server_pid" 2>/dev/null; then
+ break
+ fi
+ sleep 1
+ done
+
+ if [[ "$started" == "true" ]]; then
+ if ! (
+ cd "$GITHUB_WORKSPACE/python"
+ LOCAL_MCP_URL="$local_url" uv run python - <<'PY'
+ from __future__ import annotations
+
+ import asyncio
+ import os
+
+ from agent_framework import Content, MCPStreamableHTTPTool
+
+
+ def result_to_text(result: str | list[Content]) -> str:
+ if isinstance(result, str):
+ return result
+ return "\n".join(content.text for content in result if content.type == "text" and content.text)
+
+
+ async def main() -> None:
+ tool = MCPStreamableHTTPTool(
+ name="local_ci_mcp",
+ url=os.environ["LOCAL_MCP_URL"],
+ approval_mode="never_require",
+ )
+
+ async with tool:
+ assert tool.functions, "Local MCP server did not expose any tools."
+ result = result_to_text(await tool.functions[0].invoke(query="What is Agent Framework?"))
+ assert result, "Local MCP server returned an empty response."
+
+
+ asyncio.run(main())
+ PY
+ ); then
+ started=false
+ fi
+ fi
+
+ effective_url="$local_url"
+ pid="$server_pid"
+
+ if [[ "$started" != "true" ]]; then
+ effective_url="$fallback_url"
+ pid=""
+ if kill -0 "$server_pid" 2>/dev/null; then
+ kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" || true
+ sleep 1
+ kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" || true
+ fi
+ echo "Local MCP server was unavailable; continuing with fallback LOCAL_MCP_URL."
+ if [[ -f "$log_file" ]]; then
+ tail -n 100 "$log_file" || true
+ fi
+ else
+ echo "Using local MCP server at $local_url"
+ fi
+
+ echo "started=$started" >> "$GITHUB_OUTPUT"
+ echo "local_url=$local_url" >> "$GITHUB_OUTPUT"
+ echo "effective_url=$effective_url" >> "$GITHUB_OUTPUT"
+ echo "pid=$pid" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/python-check-coverage.py b/.github/workflows/python-check-coverage.py
index 84cd500b94..af6d38ffea 100644
--- a/.github/workflows/python-check-coverage.py
+++ b/.github/workflows/python-check-coverage.py
@@ -41,8 +41,7 @@ ENFORCED_TARGETS: set[str] = {
"packages.purview.agent_framework_purview",
"packages.anthropic.agent_framework_anthropic",
"packages.azure-ai-search.agent_framework_azure_ai_search",
- "packages.core.agent_framework.azure",
- "packages.core.agent_framework.openai",
+ "packages.openai.agent_framework_openai",
# Individual files (if you want to enforce specific files instead of whole packages)
"packages/core/agent_framework/observability.py",
# Add more targets here as coverage improves
diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml
index 8f17137569..1b1c8066c6 100644
--- a/.github/workflows/python-integration-tests.yml
+++ b/.github/workflows/python-integration-tests.yml
@@ -63,6 +63,8 @@ jobs:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
defaults:
run:
@@ -81,8 +83,8 @@ jobs:
- name: Test with pytest (OpenAI integration)
run: >
uv run pytest --import-mode=importlib
- packages/core/tests/openai
- -m integration
+ packages/openai/tests
+ -m "integration and not azure"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
@@ -94,8 +96,9 @@ jobs:
environment: integration
timeout-minutes: 60
env:
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
+ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
defaults:
@@ -121,7 +124,9 @@ jobs:
- name: Test with pytest (Azure OpenAI integration)
run: >
uv run pytest --import-mode=importlib
- packages/core/tests/azure
+ packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
+ packages/openai/tests/openai/test_openai_chat_client_azure.py
+ packages/azure-ai/tests/azure_openai
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -151,6 +156,13 @@ jobs:
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
+ - name: Start local MCP server
+ id: local-mcp
+ uses: ./.github/actions/setup-local-mcp-server
+ with:
+ fallback_url: ${{ env.LOCAL_MCP_URL }}
+ - name: Prefer local MCP URL when available
+ run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
- name: Test with pytest (Anthropic, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
@@ -161,6 +173,26 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
+ - name: Stop local MCP server
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ server_pid="${{ steps.local-mcp.outputs.pid }}"
+ if [[ -z "$server_pid" ]]; then
+ exit 0
+ fi
+ if ! kill -0 "$server_pid" 2>/dev/null; then
+ exit 0
+ fi
+ kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" 2>/dev/null || true
+ for _ in $(seq 1 10); do
+ if ! kill -0 "$server_pid" 2>/dev/null; then
+ exit 0
+ fi
+ sleep 1
+ done
+ kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
# Azure Functions + Durable Task integration tests
python-tests-functions:
@@ -172,10 +204,13 @@ jobs:
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
+ AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
+ FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
@@ -209,7 +244,8 @@ jobs:
packages/durabletask/tests/integration_tests
-m integration
-n logical --dist worksteal
- --timeout=120 --session-timeout=900 --timeout_method thread
+ -x
+ --timeout=360 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# Azure AI integration tests
@@ -221,6 +257,8 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
+ FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
+ FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
@@ -244,7 +282,9 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest
timeout-minutes: 15
- run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
+ run: |
+ uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
+ uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
# Azure Cosmos integration tests
python-tests-cosmos:
diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml
index bcf545beac..a46beb40cb 100644
--- a/.github/workflows/python-merge-tests.yml
+++ b/.github/workflows/python-merge-tests.yml
@@ -47,6 +47,9 @@ jobs:
filters: |
python:
- 'python/**'
+ - '.github/actions/setup-local-mcp-server/**'
+ - '.github/workflows/python-merge-tests.yml'
+ - '.github/workflows/python-integration-tests.yml'
core:
- 'python/packages/core/agent_framework/_*.py'
- 'python/packages/core/agent_framework/_workflows/**'
@@ -54,20 +57,30 @@ jobs:
- 'python/packages/core/agent_framework/observability.py'
openai:
- 'python/packages/core/agent_framework/openai/**'
- - 'python/packages/core/tests/openai/**'
+ - 'python/packages/openai/**'
+ - 'python/samples/**/providers/openai/**'
azure:
+ - 'python/packages/openai/**'
- 'python/packages/core/agent_framework/azure/**'
- - 'python/packages/core/tests/azure/**'
+ - 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py'
+ - 'python/packages/azure-ai/tests/azure_openai/**'
+ - 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py'
misc:
- 'python/packages/anthropic/**'
- 'python/packages/ollama/**'
- 'python/packages/core/agent_framework/_mcp.py'
- 'python/packages/core/tests/core/test_mcp.py'
+ - 'python/scripts/local_mcp_streamable_http_server.py'
+ - '.github/actions/setup-local-mcp-server/**'
+ - '.github/workflows/python-merge-tests.yml'
+ - '.github/workflows/python-integration-tests.yml'
functions:
- 'python/packages/azurefunctions/**'
- 'python/packages/durabletask/**'
azure-ai:
- 'python/packages/azure-ai/**'
+ - 'python/packages/foundry/**'
+ - 'python/samples/**/providers/foundry/**'
cosmos:
- 'python/packages/azure-cosmos/**'
# run only if 'python' files were changed
@@ -131,6 +144,8 @@ jobs:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
defaults:
run:
@@ -146,8 +161,8 @@ jobs:
- name: Test with pytest (OpenAI integration)
run: >
uv run pytest --import-mode=importlib
- packages/core/tests/openai
- -m integration
+ packages/openai/tests
+ -m "integration and not azure"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
@@ -180,8 +195,9 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
+ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
defaults:
@@ -205,7 +221,9 @@ jobs:
- name: Test with pytest (Azure OpenAI integration)
run: >
uv run pytest --import-mode=importlib
- packages/core/tests/azure
+ packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
+ packages/openai/tests/openai/test_openai_chat_client_azure.py
+ packages/azure-ai/tests/azure_openai
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -253,6 +271,13 @@ jobs:
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
+ - name: Start local MCP server
+ id: local-mcp
+ uses: ./.github/actions/setup-local-mcp-server
+ with:
+ fallback_url: ${{ env.LOCAL_MCP_URL }}
+ - name: Prefer local MCP URL when available
+ run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
- name: Test with pytest (Anthropic, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
@@ -264,6 +289,26 @@ jobs:
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
+ - name: Stop local MCP server
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ server_pid="${{ steps.local-mcp.outputs.pid }}"
+ if [[ -z "$server_pid" ]]; then
+ exit 0
+ fi
+ if ! kill -0 "$server_pid" 2>/dev/null; then
+ exit 0
+ fi
+ kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" 2>/dev/null || true
+ for _ in $(seq 1 10); do
+ if ! kill -0 "$server_pid" 2>/dev/null; then
+ exit 0
+ fi
+ sleep 1
+ done
+ kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
@@ -290,10 +335,13 @@ jobs:
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
+ AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
+ FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
@@ -325,7 +373,8 @@ jobs:
packages/durabletask/tests/integration_tests
-m integration
-n logical --dist worksteal
- --timeout=120 --session-timeout=900 --timeout_method thread
+ -x
+ --timeout=360 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Surface failing tests
@@ -352,6 +401,8 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
+ FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
+ FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
@@ -373,7 +424,9 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest
timeout-minutes: 15
- run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
+ run: |
+ uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
+ uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
working-directory: ./python
- name: Test Azure AI samples
timeout-minutes: 10
diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml
index 4a14e6b41b..63f95a78c3 100644
--- a/.github/workflows/python-sample-validation.yml
+++ b/.github/workflows/python-sample-validation.yml
@@ -41,6 +41,13 @@ jobs:
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
@@ -50,7 +57,7 @@ jobs:
if: always()
with:
name: validation-report-01-get-started
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
validate-02-agents:
name: Validate 02-agents
@@ -64,10 +71,14 @@ jobs:
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ # GitHub MCP
+ GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
# Observability
ENABLE_INSTRUMENTATION: "true"
defaults:
@@ -84,16 +95,420 @@ jobs:
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env
+ echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
+ echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
+ echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
+ echo "GITHUB_PAT=$GITHUB_PAT" >> .env
+
- name: Run sample validation
run: |
- cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents
+ cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-openai:
+ name: Validate 02-agents/providers/openai
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
+ OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
+ OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
+ echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
+ echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-openai
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-azure-openai:
+ name: Validate 02-agents/providers/azure_openai
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
+ AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
+ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
+ AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_openai --save-report --report-name 02-agents-azure-openai
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-azure-openai
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-azure-ai:
+ name: Validate 02-agents/providers/azure_ai
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
+ AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
+ AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
+ BING_CONNECTION_ID: ${{ secrets.BING_CONNECTION_ID }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME=$AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME=$AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "BING_CONNECTION_ID=$BING_CONNECTION_ID" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_ai --save-report --report-name 02-agents-azure-ai
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-azure-ai
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-azure-ai-agent:
+ name: Validate 02-agents/providers/azure_ai_agent
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
+ AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_ai_agent --save-report --report-name 02-agents-azure-ai-agent
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-azure-ai-agent
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-anthropic:
+ name: Validate 02-agents/providers/anthropic
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> .env
+ echo "ANTHROPIC_CHAT_MODEL_ID=$ANTHROPIC_CHAT_MODEL_ID" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-anthropic
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-github-copilot:
+ name: Validate 02-agents/providers/github_copilot
+ runs-on: ubuntu-latest
+ environment: integration
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-github-copilot
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-amazon:
+ name: Validate 02-agents/providers/amazon
+ if: false # Temporarily disabled - requires AWS credentials
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ BEDROCK_CHAT_MODEL_ID: ${{ vars.BEDROCK__CHATMODELID }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-amazon
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-ollama:
+ name: Validate 02-agents/providers/ollama
+ if: false # Temporarily disabled - requires local Ollama server
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ OLLAMA_MODEL: ${{ vars.OLLAMA__MODEL }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-ollama
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-foundry-local:
+ name: Validate 02-agents/providers/foundry_local
+ if: false # Temporarily disabled - requires local Foundry setup
+ runs-on: ubuntu-latest
+ environment: integration
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry_local --save-report --report-name 02-agents-foundry-local
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-foundry-local
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-copilotstudio:
+ name: Validate 02-agents/providers/copilotstudio
+ if: false # Temporarily disabled - requires Copilot Studio setup
+ runs-on: ubuntu-latest
+ environment: integration
+ env:
+ COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
+ COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
+ COPILOTSTUDIOAGENT__TENANTID: ${{ secrets.COPILOTSTUDIOAGENT__TENANTID }}
+ COPILOTSTUDIOAGENT__AGENTAPPID: ${{ secrets.COPILOTSTUDIOAGENT__AGENTAPPID }}
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Create .env for samples
+ run: |
+ echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env
+ echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env
+ echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env
+ echo "COPILOTSTUDIOAGENT__AGENTAPPID=$COPILOTSTUDIOAGENT__AGENTAPPID" >> .env
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-copilotstudio
+ path: python/samples/sample_validation/reports/
+
+ validate-02-agents-custom:
+ name: Validate 02-agents/providers/custom
+ runs-on: ubuntu-latest
+ environment: integration
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup environment
+ uses: ./.github/actions/sample-validation-setup
+ with:
+ azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ os: ${{ runner.os }}
+
+ - name: Run sample validation
+ run: |
+ cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
+
+ - name: Upload validation report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-report-02-agents-custom
+ path: python/samples/sample_validation/reports/
validate-03-workflows:
name: Validate 03-workflows
@@ -121,6 +536,14 @@ jobs:
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
+
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
@@ -130,11 +553,11 @@ jobs:
if: always()
with:
name: validation-report-03-workflows
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
validate-04-hosting:
name: Validate 04-hosting
- if: false # Temporarily disabled because of sample complexity
+ if: false # Temporarily disabled because of sample complexity
runs-on: ubuntu-latest
environment: integration
env:
@@ -169,11 +592,11 @@ jobs:
if: always()
with:
name: validation-report-04-hosting
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
validate-05-end-to-end:
name: Validate 05-end-to-end
- if: false # Temporarily disabled because of sample complexity
+ if: false # Temporarily disabled because of sample complexity
runs-on: ubuntu-latest
environment: integration
env:
@@ -213,7 +636,7 @@ jobs:
if: always()
with:
name: validation-report-05-end-to-end
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
validate-autogen-migration:
name: Validate autogen-migration
@@ -230,6 +653,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
defaults:
run:
working-directory: python
@@ -244,6 +668,16 @@ jobs:
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+ echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
+ echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
+ echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
+
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
@@ -253,7 +687,7 @@ jobs:
if: always()
with:
name: validation-report-autogen-migration
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
validate-semantic-kernel-migration:
name: Validate semantic-kernel-migration
@@ -271,6 +705,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
+ OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
# Copilot Studio
COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
@@ -290,6 +725,21 @@ jobs:
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
os: ${{ runner.os }}
+ - name: Create .env for samples
+ run: |
+ echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
+ echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
+ echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
+ echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
+ echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
+ echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
+ echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
+ echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env
+ echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env
+ echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env
+ echo "COPILOTSTUDIOAGENT__AGENTAPPID=$COPILOTSTUDIOAGENT__AGENTAPPID" >> .env
+
- name: Run sample validation
run: |
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
@@ -299,4 +749,69 @@ jobs:
if: always()
with:
name: validation-report-semantic-kernel-migration
- path: python/scripts/sample_validation/reports/
+ path: python/samples/sample_validation/reports/
+
+ aggregate-results:
+ name: Aggregate Results
+ runs-on: ubuntu-latest
+ if: always()
+ needs:
+ - validate-01-get-started
+ - validate-02-agents
+ - validate-02-agents-openai
+ - validate-02-agents-azure-openai
+ - validate-02-agents-azure-ai
+ - validate-02-agents-azure-ai-agent
+ - validate-02-agents-anthropic
+ - validate-02-agents-github-copilot
+ - validate-02-agents-amazon
+ - validate-02-agents-ollama
+ - validate-02-agents-foundry-local
+ - validate-02-agents-copilotstudio
+ - validate-02-agents-custom
+ - validate-03-workflows
+ - validate-04-hosting
+ - validate-05-end-to-end
+ - validate-autogen-migration
+ - validate-semantic-kernel-migration
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Download all validation reports
+ uses: actions/download-artifact@v7
+ with:
+ pattern: validation-report-*
+ path: reports/
+ merge-multiple: true
+
+ - name: Restore validation history
+ id: cache-restore
+ uses: actions/cache/restore@v4
+ with:
+ path: validation-history/
+ key: validation-history-${{ github.run_id }}
+ restore-keys: |
+ validation-history-
+
+ - name: Aggregate results and generate trend report
+ run: |
+ python3 python/scripts/sample_validation/aggregate.py \
+ reports/ \
+ validation-history/history.json \
+ trend-report.md
+
+ - name: Write trend report to job summary
+ run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Save validation history
+ uses: actions/cache/save@v4
+ with:
+ path: validation-history/
+ key: validation-history-${{ github.run_id }}
+
+ - name: Upload trend report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: validation-trend-report
+ path: trend-report.md
diff --git a/docs/decisions/0021-provider-leading-clients.md b/docs/decisions/0021-provider-leading-clients.md
new file mode 100644
index 0000000000..1dcc334209
--- /dev/null
+++ b/docs/decisions/0021-provider-leading-clients.md
@@ -0,0 +1,72 @@
+---
+status: accepted
+contact: eavanvalkenburg
+date: 2026-03-20
+deciders: eavanvalkenburg, sphenry, chetantoshnival
+consulted: taochenosu, moonbox3, dmytrostruk, giles17, alliscode
+---
+
+# Provider-Leading Client Design & OpenAI Package Extraction
+
+## Context and Problem Statement
+
+The `agent-framework-core` package currently bundles OpenAI and Azure OpenAI client implementations along with their dependencies (`openai`, `azure-identity`, `azure-ai-projects`, `packaging`). This makes core heavier than necessary for users who don't use OpenAI, and it conflates the core abstractions with a specific provider implementation. Additionally, the current class naming (`OpenAIResponsesClient`, `OpenAIChatClient`) is based on the underlying OpenAI API names rather than what users actually want to do, making discoverability harder for newcomers.
+
+## Decision Drivers
+
+- **Lightweight core**: Core should only contain abstractions, middleware infrastructure, and telemetry — no provider-specific code or dependencies.
+- **Discoverability-first**: Import namespaces should guide users to the right client. `from agent_framework.openai import ...` should surface all OpenAI-related clients; `from agent_framework.azure import ...` should surface Foundry, Azure AI, and other Azure-specific classes.
+- **Provider-leading naming**: The primary client name should reflect the provider, not the underlying API. The Responses API is now the recommended default for OpenAI, so its client should be called `OpenAIChatClient` (not `OpenAIResponsesClient`).
+- **Clean separation of concerns**: Azure-specific deprecated wrappers belong in the azure-ai package, not in the OpenAI package.
+
+## Considered Options
+
+- **Keep OpenAI in core**: Simpler but keeps core heavy; doesn't help discoverability.
+- **Extract OpenAI with Azure wrappers in the OpenAI package**: Keeps Azure OpenAI wrappers alongside OpenAI code, but pollutes the OpenAI package with Azure concerns.
+- **Extract OpenAI, place Azure wrappers in azure-ai**: Clean separation; the OpenAI package has zero Azure dependencies; deprecated Azure wrappers live in a single file in azure-ai for easy future deletion.
+
+## Decision Outcome
+
+Chosen option: "Extract OpenAI, place Azure wrappers in azure-ai", because it achieves the lightest core, cleanest OpenAI package, and the most maintainable deprecation path.
+
+Key changes:
+
+1. **New `agent-framework-openai` package** with dependencies on `agent-framework-core`, `openai`, and `packaging` only.
+2. **Class renames**: `OpenAIResponsesClient` → `OpenAIChatClient` (Responses API), `OpenAIChatClient` → `OpenAIChatCompletionClient` (Chat Completions API). Old names remain as deprecated aliases.
+3. **Deprecated classes**: `OpenAIAssistantsClient`, all `AzureOpenAI*Client` classes, `AzureAIClient`, `AzureAIAgentClient`, and `AzureAIProjectAgentProvider` are marked deprecated.
+4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
+5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion.
+6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies.
+7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_RESPONSES_MODEL_ID` / `OPENAI_CHAT_MODEL_ID`).
+8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
+
+### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent`
+
+The existing `AzureAIClient` combines two concerns: CRUD lifecycle management (creating/deleting agents on the service) and runtime communication (sending messages via the Responses API). The new design removes CRUD entirely — users connect to agents that already exist in Foundry.
+
+**Two approaches were considered:**
+
+**Option A — `FoundryAgentClient` only (public ChatClient):**
+Users compose `Agent(client=FoundryAgentClient(...), tools=[...])`. This follows the universal `Agent(client=X)` pattern used by every other provider. However, a "client" that wraps a named remote agent (with `agent_name` as a constructor param) is semantically odd — clients typically wrap a model endpoint, not a specific agent.
+
+**Option B — `FoundryAgent` (Agent subclass) + private `_FoundryAgentChatClient` and public `RawFoundryAgentChatClient`:**
+Users write `FoundryAgent(agent_name="my-agent", ...)` for the common case. Internally, `FoundryAgent` creates a `_FoundryAgentChatClient` and passes it to the standard `Agent` base class. For advanced customization, users pass `client_type=RawFoundryAgentChatClient` (or a custom subclass) to control the client middleware layers. The `Agent(client=RawFoundryAgentChatClient(...))` composition pattern still works for users who prefer it.
+
+**Chosen option: Option B**, because:
+- The common case (`FoundryAgent(...)`) is a single object with no boilerplate.
+- `client_type=` gives full control over client middleware without parameter duplication — the agent forwards connection params to the client internally.
+- `RawFoundryAgent(RawAgent)` and `FoundryAgent(Agent)` mirror the established `RawAgent`/`Agent` pattern.
+- Runtime validation (only `FunctionTool` allowed) lives in `RawFoundryAgentChatClient._prepare_options`, ensuring it applies regardless of how the client is used — through `FoundryAgent`, `Agent(client=...)`, or any custom composition.
+
+**Public classes:**
+- `RawFoundryAgentChatClient(RawOpenAIChatClient)` — Responses API client that injects agent reference and validates tools. Extension point for custom client middleware.
+- `RawFoundryAgent(RawAgent)` — Agent without agent-level middleware/telemetry.
+- `FoundryAgent(AgentTelemetryLayer, AgentMiddlewareLayer, RawFoundryAgent)` — Recommended production agent.
+
+**Internal (private):**
+- `_FoundryAgentChatClient` — Full client with function invocation, chat middleware, and telemetry layers. Created automatically by `FoundryAgent`; users customize via `client_type=RawFoundryAgentChatClient` or a custom subclass.
+
+**Deprecated:**
+- `AzureAIClient` — replaced by `FoundryAgent` (which uses `FoundryAgentClient` internally).
+- `AzureAIAgentClient` — refers to V1 Agents Service API, no direct replacement.
+- `AzureAIProjectAgentProvider` — replaced by `FoundryAgent`.
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 1d923076ee..a4ffe13958 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -57,6 +57,7 @@
+
@@ -76,6 +77,8 @@
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj
new file mode 100644
index 0000000000..41aafe3437
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
new file mode 100644
index 0000000000..07382e6417
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
@@ -0,0 +1,226 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how the ChatClientAgent persists chat history after each individual
+// call to the AI service.
+// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
+// (service call → tool execution → service call), and intermediate messages (tool calls and
+// results) are persisted after each service call. This allows you to inspect or recover them
+// even if the process is interrupted mid-loop, but may also result in chat history that is not
+// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
+//
+// To opt into end-of-run persistence instead (atomic run semantics), set
+// PersistChatHistoryAtEndOfRun = true on ChatClientAgentOptions.
+//
+// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
+// using streaming (RunStreamingAsync), to demonstrate correct behavior in both modes.
+
+using System.ComponentModel;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+var store = Environment.GetEnvironmentVariable("AZURE_OPENAI_RESPONSES_STORE") ?? "false";
+
+// 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.
+AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
+
+// Define multiple tools so the model makes several tool calls in a single run.
+[Description("Get the current weather for a city.")]
+static string GetWeather([Description("The city name.")] string city) =>
+ city.ToUpperInvariant() switch
+ {
+ "SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
+ "NEW YORK" => "New York: 72°F, sunny and warm.",
+ "LONDON" => "London: 48°F, overcast with fog.",
+ "DUBLIN" => "Dublin: 43°F, overcast with fog.",
+ _ => $"{city}: weather data not available."
+ };
+
+[Description("Get the current time in a city.")]
+static string GetTime([Description("The city name.")] string city) =>
+ city.ToUpperInvariant() switch
+ {
+ "SEATTLE" => "Seattle: 9:00 AM PST",
+ "NEW YORK" => "New York: 12:00 PM EST",
+ "LONDON" => "London: 5:00 PM GMT",
+ "DUBLIN" => "Dublin: 5:00 PM GMT",
+ _ => $"{city}: time data not available."
+ };
+
+// Create the agent — per-service-call persistence is the default behavior.
+// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
+// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
+IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
+ openAIClient.GetResponsesClient().AsIChatClient(deploymentName) :
+ openAIClient.GetResponsesClient().AsIChatClientWithStoredOutputDisabled(deploymentName);
+AIAgent agent = chatClient.AsAIAgent(
+ new ChatClientAgentOptions
+ {
+ Name = "WeatherAssistant",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
+ Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
+ },
+ });
+
+await RunNonStreamingAsync();
+await RunStreamingAsync();
+
+async Task RunNonStreamingAsync()
+{
+ int lastChatHistorySize = 0;
+ string lastConversationId = string.Empty;
+
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("\n=== Non-Streaming Mode ===");
+ Console.ResetColor();
+
+ AgentSession session = await agent.CreateSessionAsync();
+
+ // First turn — ask about multiple cities so the model calls tools.
+ const string Prompt = "What's the weather and time in Seattle, New York, and London?";
+ PrintUserMessage(Prompt);
+
+ var response = await agent.RunAsync(Prompt, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Second turn — follow-up to verify chat history is correct.
+ const string FollowUp1 = "And Dublin?";
+ PrintUserMessage(FollowUp1);
+
+ response = await agent.RunAsync(FollowUp1, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Third turn — follow-up to verify chat history is correct.
+ const string FollowUp2 = "Which city is the warmest?";
+ PrintUserMessage(FollowUp2);
+
+ response = await agent.RunAsync(FollowUp2, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
+}
+
+async Task RunStreamingAsync()
+{
+ int lastChatHistorySize = 0;
+ string lastConversationId = string.Empty;
+
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("\n=== Streaming Mode ===");
+ Console.ResetColor();
+
+ AgentSession session = await agent.CreateSessionAsync();
+
+ // First turn — ask about multiple cities so the model calls tools.
+ const string Prompt = "What's the weather and time in Seattle, New York, and London?";
+ PrintUserMessage(Prompt);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(Prompt, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During run", ref lastChatHistorySize, ref lastConversationId);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Second turn — follow-up to verify chat history is correct.
+ const string FollowUp1 = "And Dublin?";
+ PrintUserMessage(FollowUp1);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(FollowUp1, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During second run", ref lastChatHistorySize, ref lastConversationId);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId);
+
+ // Third turn — follow-up to verify chat history is correct.
+ const string FollowUp2 = "Which city is the warmest?";
+ PrintUserMessage(FollowUp2);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(FollowUp2, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During third run", ref lastChatHistorySize, ref lastConversationId);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
+}
+
+void PrintUserMessage(string message)
+{
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[User] ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+}
+
+void PrintAgentResponse(string? text)
+{
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+ Console.WriteLine(text);
+}
+
+// Helper to print the current chat history from the session.
+void PrintChatHistory(AgentSession session, string label, ref int lastChatHistorySize, ref string lastConversationId)
+{
+ if (session.TryGetInMemoryChatHistory(out var history) && history.Count != lastChatHistorySize)
+ {
+ Console.ForegroundColor = ConsoleColor.DarkGray;
+ Console.WriteLine($"\n [{label} — Chat history: {history.Count} message(s)]");
+ foreach (var msg in history)
+ {
+ var preview = msg.Text?.Length > 80 ? msg.Text[..80] + "…" : msg.Text;
+ var contentTypes = string.Join(", ", msg.Contents.Select(c => c.GetType().Name));
+ Console.WriteLine($" {msg.Role,-12} | {(string.IsNullOrWhiteSpace(preview) ? $"[{contentTypes}]" : preview)}");
+ }
+
+ Console.ResetColor();
+
+ lastChatHistorySize = history.Count;
+ }
+
+ if (session is ChatClientAgentSession ccaSession && ccaSession.ConversationId is not null && ccaSession.ConversationId != lastConversationId)
+ {
+ Console.ForegroundColor = ConsoleColor.DarkGray;
+ Console.WriteLine($" [{label} — Conversation ID: {ccaSession.ConversationId}]");
+ Console.ResetColor();
+ lastConversationId = ccaSession.ConversationId;
+ }
+}
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md
new file mode 100644
index 0000000000..d6157586f0
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md
@@ -0,0 +1,63 @@
+# In-Function-Loop Checkpointing
+
+This sample demonstrates how `ChatClientAgent` persists chat history after each individual call to the AI service by default. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
+
+## What This Sample Shows
+
+When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By default, chat history is persisted after each service call via the `ChatHistoryPersistingChatClient` decorator:
+
+- A `ChatHistoryPersistingChatClient` decorator is automatically inserted into the chat client pipeline
+- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
+- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
+
+To opt into end-of-run persistence instead (atomic run semantics), set `PersistChatHistoryAtEndOfRun = true` on `ChatClientAgentOptions`. In that mode, the decorator marks messages with metadata rather than persisting them immediately, and `ChatClientAgent` persists only the marked messages at the end of the run.
+
+Per-service-call persistence is useful for:
+- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
+- **Observability** — you can inspect the chat history while the agent is still running (e.g., during streaming)
+- **Long-running tool loops** — agents with many sequential tool calls benefit from incremental persistence
+
+## How It Works
+
+The sample asks the agent about the weather and time in three cities. The model calls the `GetWeather` and `GetTime` tools for each city, resulting in multiple service calls within a single `RunStreamingAsync` invocation. After the run completes, the sample prints the full chat history to show all the intermediate messages that were persisted along the way.
+
+### Pipeline Architecture
+
+```
+ChatClientAgent
+ └─ FunctionInvokingChatClient (handles tool call loop)
+ └─ ChatHistoryPersistingChatClient (persists after each service call)
+ └─ Leaf IChatClient (Azure OpenAI)
+```
+
+## Prerequisites
+
+- .NET 10 SDK or later
+- Azure OpenAI service endpoint and model deployment
+- Azure CLI installed and authenticated
+
+**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
+
+## Environment Variables
+
+```powershell
+$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
+$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
+```
+
+## Running the Sample
+
+```powershell
+cd dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing
+dotnet run
+```
+
+## Expected Behavior
+
+The sample runs two conversation turns:
+
+1. **First turn** — asks about weather and time in three cities. The model calls `GetWeather` and `GetTime` tools (potentially in parallel or sequentially), then provides a summary. The chat history dump after the run shows all the intermediate tool call and result messages.
+
+2. **Second turn** — asks a follow-up question ("Which city is the warmest?") that uses the persisted conversation context. The chat history dump shows the full accumulated conversation.
+
+The chat history printout uses `session.TryGetInMemoryChatHistory()` to inspect the in-memory storage.
diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md
index 4ac53ba246..c5258ba9f4 100644
--- a/dotnet/samples/02-agents/Agents/README.md
+++ b/dotnet/samples/02-agents/Agents/README.md
@@ -45,6 +45,7 @@ Before you begin, ensure you have the following prerequisites:
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
+|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
## Running the samples from the console
diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs
index 07ba96989a..bc9faff3b0 100644
--- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs
+++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs
@@ -9,7 +9,7 @@ using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentSample;
///
-/// This sample introduces the concepts workflows as agents, where a workflow can be
+/// This sample introduces the concept of workflows as agents, where a workflow can be
/// treated as an . This allows you to interact with a workflow
/// as if it were a single agent.
///
@@ -18,6 +18,14 @@ namespace WorkflowAsAnAgentSample;
///
/// You will interact with the workflow in an interactive loop, sending messages and receiving
/// streaming responses from the workflow as if it were an agent who responds in both languages.
+///
+/// This sample also demonstrates , which is required
+/// for stateful executors that are shared across multiple workflow runs. Each iteration
+/// of the interactive loop triggers a new workflow run against the same workflow instance.
+/// Between runs, the framework automatically calls
+/// on shared executors so that accumulated state (e.g., collected messages) is cleared
+/// before the next run begins. See WorkflowFactory.ConcurrentAggregationExecutor
+/// for the implementation.
///
///
/// Pre-requisites:
@@ -39,7 +47,10 @@ public static class Program
var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent");
var session = await agent.CreateSessionAsync();
- // Start an interactive loop to interact with the workflow as if it were an agent
+ // Start an interactive loop to interact with the workflow as if it were an agent.
+ // Each iteration runs the workflow again on the same workflow instance. Between runs,
+ // the framework calls IResettableExecutor.ResetAsync() on shared stateful executors
+ // (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run.
while (true)
{
Console.WriteLine();
diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs
index 2fdfe703bf..bcac8894ab 100644
--- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs
+++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs
@@ -10,6 +10,14 @@ internal static class WorkflowFactory
{
///
/// Creates a workflow that uses two language agents to process input concurrently.
+ ///
+ /// In this workflow, the Start and the
+ /// are provided as shared instances, meaning
+ /// the same executor objects are reused across multiple workflow runs. The language agents
+ /// (French and English) are created via a factory and instantiated per workflow run.
+ /// Stateful shared executors must implement so the
+ /// framework can clear their state between runs. Framework-provided executors like
+ /// already implement this interface.
///
/// The chat client to use for the agents
/// A workflow that processes input using two language agents
@@ -40,6 +48,16 @@ internal static class WorkflowFactory
///
/// Executor that aggregates the results from the concurrent agents.
+ ///
+ /// This executor is stateful — it accumulates messages in
+ /// as they arrive from each agent. Because it is provided as a shared instance
+ /// (not via a factory), the same object is reused across workflow runs. Implementing
+ /// allows the framework to call
+ /// between runs, clearing accumulated state so each run starts fresh.
+ ///
+ /// Without , attempting to reuse a workflow containing
+ /// shared executor instances that do not implement this interface would throw an
+ /// .
///
[YieldsOutput(typeof(string))]
private sealed class ConcurrentAggregationExecutor() :
@@ -65,7 +83,11 @@ internal static class WorkflowFactory
}
}
- ///
+ ///
+ /// Resets the executor state between workflow runs by clearing accumulated messages.
+ /// The framework calls this automatically when a workflow run completes, before the
+ /// workflow can be used for another run.
+ ///
public ValueTask ResetAsync()
{
this._messages.Clear();
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj
new file mode 100644
index 0000000000..68f9ccb801
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj
@@ -0,0 +1,35 @@
+
+
+ net10.0
+ v4
+ Exe
+ enable
+ enable
+
+ WorkflowMcpTool
+ WorkflowMcpTool
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs
new file mode 100644
index 0000000000..0621680e33
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Workflows;
+
+namespace WorkflowMcpTool;
+
+internal sealed class TranslateText() : Executor("TranslateText")
+{
+ public override ValueTask HandleAsync(
+ string message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($"[Activity] TranslateText: '{message}'");
+ return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
+ }
+}
+
+internal sealed class FormatOutput() : Executor("FormatOutput")
+{
+ public override ValueTask HandleAsync(
+ TranslationResult message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine("[Activity] FormatOutput: Formatting result");
+ return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
+ }
+}
+
+internal sealed class LookupOrder() : Executor("LookupOrder")
+{
+ public override ValueTask HandleAsync(
+ string message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($"[Activity] LookupOrder: '{message}'");
+ return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m));
+ }
+}
+
+internal sealed class EnrichOrder() : Executor("EnrichOrder")
+{
+ public override ValueTask HandleAsync(
+ OrderInfo message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'");
+ return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed"));
+ }
+}
+
+internal sealed record TranslationResult(string Original, string Translated);
+
+internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice);
+
+internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status);
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs
new file mode 100644
index 0000000000..0970ca16b8
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool.
+// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically
+// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific
+// tool name. MCP-compatible clients can then invoke the workflow as a tool.
+
+using Microsoft.Agents.AI.Hosting.AzureFunctions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Azure.Functions.Worker.Builder;
+using Microsoft.Extensions.Hosting;
+using WorkflowMcpTool;
+
+// Define executors
+TranslateText translateText = new();
+FormatOutput formatOutput = new();
+LookupOrder lookupOrder = new();
+EnrichOrder enrichOrder = new();
+
+// Build a simple workflow: TranslateText -> FormatOutput
+Workflow translateWorkflow = new WorkflowBuilder(translateText)
+ .WithName("Translate")
+ .WithDescription("Translate text to uppercase and format the result")
+ .AddEdge(translateText, formatOutput)
+ .Build();
+
+// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder
+Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder)
+ .WithName("OrderLookup")
+ .WithDescription("Look up an order by ID and return enriched order details")
+ .AddEdge(lookupOrder, enrichOrder)
+ .Build();
+
+using IHost app = FunctionsApplication
+ .CreateBuilder(args)
+ .ConfigureFunctionsWebApplication()
+ .ConfigureDurableWorkflows(workflows =>
+ {
+ // Expose both workflows as MCP tool triggers.
+ workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
+ workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
+ })
+ .Build();
+app.Run();
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md
new file mode 100644
index 0000000000..a5411bf375
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md
@@ -0,0 +1,81 @@
+# Workflow as MCP Tool Sample
+
+This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly.
+
+## Key Concepts Demonstrated
+
+- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true`
+- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp`
+- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects
+
+## Sample Architecture
+
+The sample creates two workflows exposed as MCP tools:
+
+### Translate Workflow (returns a string)
+
+| Executor | Input | Output | Description |
+|----------|-------|--------|-------------|
+| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
+| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
+
+### OrderLookup Workflow (returns a POCO)
+
+| Executor | Input | Output | Description |
+|----------|-------|--------|-------------|
+| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID |
+| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) |
+
+## Environment Setup
+
+See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
+
+- Prerequisites installation
+- Durable Task Scheduler setup
+- Storage emulator configuration
+
+For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
+
+## Running the Sample
+
+1. **Start the Function App**:
+
+ ```bash
+ cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
+ func start
+ ```
+
+2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output:
+
+ ```text
+ MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
+ ```
+
+## Invoking Workflows via MCP Inspector
+
+1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
+
+ ```bash
+ npx @modelcontextprotocol/inspector
+ ```
+
+2. Connect to the MCP server endpoint:
+ - For **Transport Type**, select **"Streamable HTTP"**
+ - For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp`
+ - Click the **Connect** button
+
+3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`.
+
+4. Test the **Translate** tool (returns a plain string):
+ - Select the `Translate` tool
+ - Set `hello world` as the `input` parameter
+ - Click **Run Tool**
+ - Expected result: `Original: hello world => Translated: HELLO WORLD`
+
+5. Test the **OrderLookup** tool (returns a JSON object):
+ - Select the `OrderLookup` tool
+ - Set `ORD-2025-42` as the `input` parameter
+ - Click **Run Tool**
+ - Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status`
+
+You'll see the workflow executor activities logged in the terminal where you ran `func start`.
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json
new file mode 100644
index 0000000000..9384a0a583
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json
@@ -0,0 +1,20 @@
+{
+ "version": "2.0",
+ "logging": {
+ "logLevel": {
+ "Microsoft.Agents.AI.DurableTask": "Information",
+ "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
+ "DurableTask": "Information",
+ "Microsoft.DurableTask": "Information"
+ }
+ },
+ "extensions": {
+ "durableTask": {
+ "hubName": "default",
+ "storageProvider": {
+ "type": "AzureManaged",
+ "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json
new file mode 100644
index 0000000000..fcb6658e92
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json
@@ -0,0 +1,8 @@
+{
+ "IsEncrypted": false,
+ "Values": {
+ "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
+ }
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj
new file mode 100644
index 0000000000..517dd323a7
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj
@@ -0,0 +1,42 @@
+
+
+ net10.0
+ v4
+ Exe
+ enable
+ enable
+
+ WorkflowAndAgents
+ WorkflowAndAgents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs
new file mode 100644
index 0000000000..727379b482
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Workflows;
+
+namespace WorkflowAndAgents;
+
+internal sealed class TranslateText() : Executor("TranslateText")
+{
+ public override ValueTask HandleAsync(
+ string message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($"[Activity] TranslateText: '{message}'");
+ return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
+ }
+}
+
+internal sealed class FormatOutput() : Executor("FormatOutput")
+{
+ public override ValueTask HandleAsync(
+ TranslationResult message,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine("[Activity] FormatOutput: Formatting result");
+ return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
+ }
+}
+
+internal sealed record TranslationResult(string Original, string Translated);
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs
new file mode 100644
index 0000000000..51b9fb4d7f
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows
+// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent
+// accessible via HTTP and MCP tool triggers.
+
+#pragma warning disable IDE0002 // Simplify Member Access
+
+using Azure;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AzureFunctions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Azure.Functions.Worker.Builder;
+using Microsoft.Extensions.Hosting;
+using OpenAI.Chat;
+using WorkflowAndAgents;
+
+// Get the Azure OpenAI endpoint and deployment name from environment variables.
+string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
+string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
+AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
+ ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
+ : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential());
+
+ChatClient chatClient = client.GetChatClient(deploymentName);
+
+// Define a standalone AI agent
+AIAgent assistant = chatClient.AsAIAgent(
+ "You are a helpful assistant. Answer questions clearly and concisely.",
+ "Assistant",
+ description: "A general-purpose helpful assistant.");
+
+// Define workflow executors
+TranslateText translateText = new();
+FormatOutput formatOutput = new();
+
+// Build a workflow: TranslateText -> FormatOutput
+Workflow translateWorkflow = new WorkflowBuilder(translateText)
+ .WithName("Translate")
+ .WithDescription("Translate text to uppercase and format the result")
+ .AddEdge(translateText, formatOutput)
+ .Build();
+
+// Use ConfigureDurableOptions to register both agents and workflows together
+using IHost app = FunctionsApplication
+ .CreateBuilder(args)
+ .ConfigureFunctionsWebApplication()
+ .ConfigureDurableOptions(options =>
+ {
+ // Register the standalone agent with HTTP and MCP tool triggers
+ options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true);
+
+ // Register the workflow with an HTTP endpoint and MCP tool trigger
+ options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
+ })
+ .Build();
+app.Run();
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md
new file mode 100644
index 0000000000..37841777cc
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md
@@ -0,0 +1,76 @@
+# Workflow and Agents Sample
+
+This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows.
+
+## Key Concepts Demonstrated
+
+- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together
+- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers
+- **Workflow**: A simple text translation workflow also exposed as an MCP tool
+- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host
+
+## Sample Architecture
+
+### Standalone Agent
+
+| Agent | Description |
+|-------|-------------|
+| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool |
+
+### Translate Workflow
+
+| Executor | Input | Output | Description |
+|----------|-------|--------|-------------|
+| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
+| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
+
+## Environment Setup
+
+See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
+
+- Prerequisites installation
+- Durable Task Scheduler setup
+- Storage emulator configuration
+
+This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`:
+
+- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL
+- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name
+- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used
+
+## Running the Sample
+
+1. **Start the Function App**:
+
+ ```bash
+ cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents
+ func start
+ ```
+
+2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow:
+
+ - `dafx-Assistant` (entity trigger for the agent)
+ - `http-Assistant` (HTTP trigger for the agent)
+ - `mcptool-Assistant` (MCP tool trigger for the agent)
+ - `wf-Translate` (orchestration trigger for the workflow)
+ - `mcptool-wf-Translate` (MCP tool trigger for the workflow)
+
+## Invoking the Agent via HTTP
+
+```bash
+curl -X POST http://localhost:7071/agents/Assistant/run \
+ -H "Content-Type: application/json" \
+ -d '{"query": "What is the capital of France?"}'
+```
+
+## Invoking via MCP Inspector
+
+1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
+
+ ```bash
+ npx @modelcontextprotocol/inspector
+ ```
+
+2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport.
+
+3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool.
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json
new file mode 100644
index 0000000000..9384a0a583
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json
@@ -0,0 +1,20 @@
+{
+ "version": "2.0",
+ "logging": {
+ "logLevel": {
+ "Microsoft.Agents.AI.DurableTask": "Information",
+ "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
+ "DurableTask": "Information",
+ "Microsoft.DurableTask": "Information"
+ }
+ },
+ "extensions": {
+ "durableTask": {
+ "hubName": "default",
+ "storageProvider": {
+ "type": "AzureManaged",
+ "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json
new file mode 100644
index 0000000000..5f6d7d3340
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json
@@ -0,0 +1,10 @@
+{
+ "IsEncrypted": false,
+ "Values": {
+ "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
+ "AZURE_OPENAI_ENDPOINT": "",
+ "AZURE_OPENAI_DEPLOYMENT_NAME": ""
+ }
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/README.md
index 2b7103de50..f2386380d7 100644
--- a/dotnet/samples/04-hosting/DurableWorkflows/README.md
+++ b/dotnet/samples/04-hosting/DurableWorkflows/README.md
@@ -48,3 +48,4 @@ $env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhos
| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions |
| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions |
| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions |
+| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool |
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs
index 8239ff17cc..64ec846eb8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs
@@ -167,6 +167,20 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
return;
}
+ if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
+ {
+ if (mcpToolInvocationContext is null)
+ {
+ throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
+ }
+
+ context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(
+ mcpToolInvocationContext,
+ durableTaskClient,
+ context);
+ return;
+ }
+
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
index 6dc1ab2244..e6c94347a1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
@@ -29,6 +29,7 @@ internal static class BuiltInFunctions
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
+ internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
@@ -378,6 +379,55 @@ internal static class BuiltInFunctions
return agentResponse.Text;
}
+ ///
+ /// Runs a workflow via MCP tool trigger.
+ /// Extracts the input argument, schedules a new orchestration, waits for completion, and returns the output.
+ ///
+ public static async Task RunWorkflowMcpToolAsync(
+ [McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
+ [DurableClient] DurableTaskClient client,
+ FunctionContext functionContext)
+ {
+ if (context.Arguments is null)
+ {
+ throw new ArgumentException("MCP Tool invocation is missing required arguments.");
+ }
+
+ if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
+ {
+ throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
+ }
+
+ string workflowName = context.Name;
+ string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
+
+ DurableWorkflowInput orchestrationInput = new() { Input = input };
+ string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput);
+
+ OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
+ instanceId,
+ getInputsAndOutputs: true,
+ cancellation: functionContext.CancellationToken);
+
+ if (metadata is null)
+ {
+ throw new InvalidOperationException($"Workflow orchestration '{instanceId}' returned no metadata.");
+ }
+
+ if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
+ {
+ string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
+ throw new InvalidOperationException($"Workflow orchestration '{instanceId}' failed: {errorMessage}");
+ }
+
+ if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
+ {
+ throw new InvalidOperationException($"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.");
+ }
+
+ return metadata.ReadOutputAs()?.Result;
+ }
+
///
/// Creates an error response with the specified status code and error message.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md
index 93c90bba9c..2c188757d5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
+- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.251219.1
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs
index 1039fb5aec..4debb5facf 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs
@@ -6,7 +6,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
///
/// Provides access to agent-specific options for functions agents by name.
-/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured.
+/// Returns when no explicit options have been configured for an agent,
+/// which distinguishes standalone agents from those auto-registered by workflows.
///
internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions)
: IFunctionsAgentOptionsProvider
@@ -14,32 +15,19 @@ internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary _functionsAgentOptions =
functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions));
- // Default options. HTTP trigger enabled, MCP tool disabled.
- private static readonly FunctionsAgentOptions s_defaultOptions = new()
- {
- HttpTrigger = { IsEnabled = true },
- McpToolTrigger = { IsEnabled = false }
- };
-
///
/// Attempts to retrieve the options associated with the specified agent name.
- /// If not found, a default options instance (with HTTP trigger enabled) is returned.
+ /// Returns when no options have been explicitly configured for the agent.
///
/// The name of the agent whose options are to be retrieved. Cannot be null or empty.
- /// The options for the specified agent. Will never be null.
- /// Always true. Returns configured options if present; otherwise default fallback options.
+ ///
+ /// When this method returns , contains the options for the specified agent;
+ /// otherwise, .
+ ///
+ /// if options were found for the agent; otherwise, .
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
{
ArgumentException.ThrowIfNullOrEmpty(agentName);
-
- if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing))
- {
- options = existing;
- return true;
- }
-
- // If not defined, return default options.
- options = s_defaultOptions;
- return true;
+ return this._functionsAgentOptions.TryGetValue(agentName, out options);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs
index 65578a7383..fe20eeb6f9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs
@@ -6,9 +6,13 @@ using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
///
-/// Transforms function metadata by registering durable agent functions for each configured agent.
+/// Transforms function metadata by registering durable agent functions for each explicitly configured agent.
///
-/// This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application.
+///
+/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have
+/// explicit . Agents auto-registered by workflows
+/// (which lack explicit options) are handled by .
+///
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger _logger;
@@ -38,24 +42,27 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
{
string agentName = kvp.Key;
- this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
+ // Only generate triggers for agents with explicit Functions agent options.
+ // Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer.
+ if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
+ {
+ continue;
+ }
+ this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName));
- if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
+ if (agentTriggerOptions.HttpTrigger.IsEnabled)
{
- if (agentTriggerOptions.HttpTrigger.IsEnabled)
- {
- this._logger.LogRegisteringTriggerForAgent(agentName, "http");
- original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
- }
+ this._logger.LogRegisteringTriggerForAgent(agentName, "http");
+ original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
+ }
- if (agentTriggerOptions.McpToolTrigger.IsEnabled)
- {
- AIAgent agent = kvp.Value(this._serviceProvider);
- this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
- original.Add(CreateMcpToolTrigger(agentName, agent.Description));
- }
+ if (agentTriggerOptions.McpToolTrigger.IsEnabled)
+ {
+ AIAgent agent = kvp.Value(this._serviceProvider);
+ this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
+ original.Add(CreateMcpToolTrigger(agentName, agent.Description));
}
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs
index ad21d8f4e1..8d161710ae 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs
@@ -134,4 +134,17 @@ public static class DurableAgentsOptionsExtensions
{
return new Dictionary(s_agentOptions, StringComparer.OrdinalIgnoreCase);
}
+
+ ///
+ /// Ensures every agent in has an entry in the
+ /// options registry. Agents that already have explicit options are left untouched.
+ /// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled).
+ ///
+ internal static void EnsureDefaultOptionsForAll(IEnumerable agentNames)
+ {
+ foreach (string name in agentNames)
+ {
+ s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } });
+ }
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs
index d88cd939d9..46053507b1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Text.Json.Nodes;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
@@ -98,4 +99,65 @@ internal static class FunctionMetadataFactory
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
+
+ ///
+ /// Creates function metadata for an MCP tool trigger function that starts a workflow.
+ ///
+ /// The name of the workflow to expose as an MCP tool.
+ /// An optional description for the MCP tool. If null, a default description is generated.
+ /// A configured for an MCP tool trigger.
+ internal static DefaultFunctionMetadata CreateWorkflowMcpToolTrigger(
+ string workflowName,
+ string? description)
+ {
+ var functionName = $"{BuiltInFunctions.McpToolPrefix}{workflowName}";
+ var toolDescription = description ?? $"Run the {workflowName} workflow";
+
+ var toolProperties = new JsonArray(new JsonObject
+ {
+ ["propertyName"] = "input",
+ ["propertyType"] = "string",
+ ["description"] = "The input to the workflow.",
+ ["isRequired"] = true,
+ ["isArray"] = false,
+ });
+
+ var triggerBinding = new JsonObject
+ {
+ ["name"] = "context",
+ ["type"] = "mcpToolTrigger",
+ ["direction"] = "In",
+ ["toolName"] = workflowName,
+ ["description"] = toolDescription,
+ ["toolProperties"] = toolProperties.ToJsonString(),
+ };
+
+ var inputBinding = new JsonObject
+ {
+ ["name"] = "input",
+ ["type"] = "mcpToolProperty",
+ ["direction"] = "In",
+ ["propertyName"] = "input",
+ ["description"] = "The input to the workflow",
+ ["isRequired"] = true,
+ ["dataType"] = "String",
+ ["propertyType"] = "string",
+ };
+
+ var clientBinding = new JsonObject
+ {
+ ["name"] = "client",
+ ["type"] = "durableClient",
+ ["direction"] = "In",
+ };
+
+ return new DefaultFunctionMetadata
+ {
+ Name = functionName,
+ Language = "dotnet-isolated",
+ RawBindings = [triggerBinding.ToJsonString(), inputBinding.ToJsonString(), clientBinding.ToJsonString()],
+ EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
+ ScriptFile = BuiltInFunctions.ScriptFile,
+ };
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
index ceb47c389a..3c5e7936da 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
@@ -27,9 +27,16 @@ public static class FunctionsApplicationBuilderExtensions
{
ArgumentNullException.ThrowIfNull(configure);
+ // Create/get shared options BEFORE the DurableTask library call so it can find them.
+ FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
+
// The main agent services registration is done in Microsoft.DurableTask.Agents.
builder.Services.ConfigureDurableAgents(configure);
+ // Ensure all agents registered through this path have default FunctionsAgentOptions.
+ // This distinguishes them from agents auto-registered by workflows.
+ DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys);
+
builder.Services.TryAddSingleton(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
@@ -67,6 +74,13 @@ public static class FunctionsApplicationBuilderExtensions
builder.Services.ConfigureDurableOptions(configure);
+ if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0)
+ {
+ builder.Services.TryAddSingleton(_ =>
+ new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ }
+
if (sharedOptions.Workflows.Workflows.Count > 0)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
@@ -102,12 +116,14 @@ public static class FunctionsApplicationBuilderExtensions
builder.UseWhen(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
+ string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
- string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
+ string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) ||
+ string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
);
builder.Services.TryAddSingleton();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs
index 6e7b6ec5a8..ee9051fa0d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs
@@ -10,6 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal sealed class FunctionsDurableOptions : DurableOptions
{
private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase);
///
/// Enables the status HTTP endpoint for the specified workflow.
@@ -26,4 +27,20 @@ internal sealed class FunctionsDurableOptions : DurableOptions
{
return this._statusEndpointWorkflows.Contains(workflowName);
}
+
+ ///
+ /// Enables the MCP tool trigger for the specified workflow.
+ ///
+ internal void EnableMcpToolTrigger(string workflowName)
+ {
+ this._mcpToolTriggerWorkflows.Add(workflowName);
+ }
+
+ ///
+ /// Returns whether the MCP tool trigger is enabled for the specified workflow.
+ ///
+ internal bool IsMcpToolTriggerEnabled(string workflowName)
+ {
+ return this._mcpToolTriggerWorkflows.Contains(workflowName);
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs
index 6f40cbb791..7cf38397ae 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs
@@ -27,4 +27,31 @@ public static class DurableWorkflowOptionsExtensions
functionsOptions.EnableStatusEndpoint(workflow.Name!);
}
}
+
+ ///
+ /// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger.
+ ///
+ /// The workflow options to add the workflow to.
+ /// The workflow instance to add.
+ /// If , a GET endpoint is generated at workflows/{name}/status/{runId}.
+ /// If , an MCP tool trigger is generated for the workflow.
+ public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint, bool exposeMcpToolTrigger)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ options.AddWorkflow(workflow);
+
+ if (options.ParentOptions is FunctionsDurableOptions functionsOptions)
+ {
+ if (exposeStatusEndpoint)
+ {
+ functionsOptions.EnableStatusEndpoint(workflow.Name!);
+ }
+
+ if (exposeMcpToolTrigger)
+ {
+ functionsOptions.EnableMcpToolTrigger(workflow.Name!);
+ }
+ }
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs
index c7ad9a5ebd..dc7b799b00 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs
@@ -50,8 +50,11 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
int initialCount = original.Count;
this._logger.LogTransformingFunctionMetadata(initialCount);
- // Track registered function names to avoid duplicates when workflows share executors.
- HashSet registeredFunctions = [];
+ // Seed with existing function names to avoid duplicates across transformers
+ // (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers).
+ HashSet registeredFunctions = new(
+ original.Select(f => f.Name!),
+ StringComparer.OrdinalIgnoreCase);
DurableWorkflowOptions workflowOptions = this._options.Workflows;
foreach (var workflow in workflowOptions.Workflows)
@@ -113,6 +116,17 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
}
}
+ // Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true).
+ if (this._options.IsMcpToolTriggerEnabled(workflow.Key))
+ {
+ string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}";
+ if (registeredFunctions.Add(mcpToolFunctionName))
+ {
+ this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool");
+ original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description));
+ }
+ }
+
// Register activity or entity functions for each executor in the workflow.
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
foreach (KeyValuePair entry in workflow.Value.ReflectExecutors())
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
index 9a74c88447..23d748e629 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
@@ -38,6 +38,7 @@ internal static class SourceBuilder
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
sb.AppendLine();
+ sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;");
// Namespace
if (!string.IsNullOrWhiteSpace(info.Namespace))
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs
index 543fdeb530..c47298f112 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs
@@ -5,11 +5,14 @@ using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
+internal record CheckpointFileIndexEntry(CheckpointInfo CheckpointInfo, string FileName);
+
///
/// Provides a file system-based implementation of a JSON checkpoint store that persists checkpoint data and index
/// information to disk using JSON files.
@@ -28,6 +31,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
internal DirectoryInfo Directory { get; }
internal HashSet CheckpointIndex { get; }
+ private static JsonTypeInfo EntryTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointFileIndexEntry;
+
///
/// Initializes a new instance of the class that uses the specified directory
///
@@ -64,9 +69,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true);
while (reader.ReadLine() is string line)
{
- if (JsonSerializer.Deserialize(line, KeyTypeInfo) is { } info)
+ if (JsonSerializer.Deserialize(line, EntryTypeInfo) is { } entry)
{
- this.CheckpointIndex.Add(info);
+ // We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to
+ // have the UrlEncoded file names in the index file for human readability
+ this.CheckpointIndex.Add(entry.CheckpointInfo);
}
}
}
@@ -93,8 +100,14 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
}
- private string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
- => Path.Combine(this.Directory.FullName, $"{sessionId}_{key.CheckpointId}.json");
+ internal string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
+ {
+ string protoPath = $"{sessionId}_{key.CheckpointId}.json";
+
+ // Escape the protoPath to ensure it is a valid file name, especially if sessionId or CheckpointId contain path separators, etc.
+ return Uri.EscapeDataString(protoPath) // This takes care of most of the invalid path characters
+ .Replace(".", "%2E"); // This takes care of escaping the root folder, since EscapeDataString does not escape dots
+ }
private CheckpointInfo GetUnusedCheckpointInfo(string sessionId)
{
@@ -116,13 +129,16 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
CheckpointInfo key = this.GetUnusedCheckpointInfo(sessionId);
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
+ string filePath = Path.Combine(this.Directory.FullName, fileName);
+
try
{
- using Stream checkpointStream = File.Open(fileName, FileMode.Create, FileAccess.Write, FileShare.None);
+ using Stream checkpointStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
using Utf8JsonWriter jsonWriter = new(checkpointStream, new JsonWriterOptions() { Indented = false });
value.WriteTo(jsonWriter);
- JsonSerializer.Serialize(this._indexFile!, key, KeyTypeInfo);
+ CheckpointFileIndexEntry entry = new(key, fileName);
+ JsonSerializer.Serialize(this._indexFile!, entry, EntryTypeInfo);
byte[] bytes = Encoding.UTF8.GetBytes(Environment.NewLine);
await this._indexFile!.WriteAsync(bytes, 0, bytes.Length, CancellationToken.None).ConfigureAwait(false);
await this._indexFile!.FlushAsync(CancellationToken.None).ConfigureAwait(false);
@@ -136,7 +152,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
try
{
// try to clean up after ourselves
- File.Delete(fileName);
+ File.Delete(filePath);
}
catch { }
@@ -149,6 +165,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
{
this.CheckDisposed();
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
+ string filePath = Path.Combine(this.Directory.FullName, fileName);
if (!this.CheckpointIndex.Contains(key) ||
!File.Exists(fileName))
@@ -156,7 +173,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'.");
}
- using FileStream checkpointFileStream = File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.Read);
+ using FileStream checkpointFileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using JsonDocument document = await JsonDocument.ParseAsync(checkpointFileStream).ConfigureAwait(false);
return document.RootElement.Clone();
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs
index e18bae72a5..d6e6df5dbd 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs
@@ -3,9 +3,9 @@
namespace Microsoft.Agents.AI.Workflows;
///
-/// Provides extensions methods for creating objects
+/// Provides extension methods for creating objects
///
-public static class ConfigurationExtensions
+internal static class ConfigurationExtensions
{
///
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs
index 3f876926be..b154bd6ca7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows;
///
/// Provides methods for creating instances.
///
-public static class Configured
+internal static class Configured
{
///
/// Creates a instance from an existing subject instance.
@@ -50,10 +50,10 @@ public static class Configured
/// A representation of a preconfigured, lazy-instantiatable instance of .
///
/// The type of the preconfigured subject.
-/// A factory to intantiate the subject when desired.
+/// A factory to instantiate the subject when desired.
/// The unique identifier for the configured subject.
///
-public class Configured(Func> factoryAsync, string id, object? raw = null)
+internal class Configured(Func> factoryAsync, string id, object? raw = null)
{
///
/// Gets the raw representation of the configured object, if any.
@@ -66,14 +66,14 @@ public class Configured(Func> fact
public string Id => id;
///
- /// Gets the factory function to create an instance of given a .
+ /// Gets the factory function to create an instance of given a .
///
- public Func> FactoryAsync => factoryAsync;
+ public Func> FactoryAsync => factoryAsync;
///
/// The configuration for this configured instance.
///
- public Config Configuration => new(this.Id);
+ public ExecutorConfig Configuration => new(this.Id);
///
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
@@ -87,11 +87,11 @@ public class Configured(Func> fact
///
/// The type of the preconfigured subject.
/// The type of configuration options for the preconfigured subject.
-/// A factory to intantiate the subject when desired.
+/// A factory to instantiate the subject when desired.
/// The unique identifier for the configured subject.
/// Additional configuration options for the subject.
///
-public class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null)
+internal class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null)
{
///
/// The raw representation of the configured object, if any.
@@ -109,14 +109,14 @@ public class Configured(Func, string, Value
public TOptions? Options => options;
///
- /// Gets the factory function to create an instance of given a .
+ /// Gets the factory function to create an instance of given a .
///
- public Func, string, ValueTask> FactoryAsync => factoryAsync;
+ public Func, string, ValueTask> FactoryAsync => factoryAsync;
///
/// The configuration for this configured instance.
///
- public Config Configuration => new(this.Id, this.Options);
+ public ExecutorConfig Configuration => new(this.Id, this.Options);
///
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
@@ -124,11 +124,11 @@ public class Configured(Func, string, Value
///
internal Func> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
- private Func> CreateValidatingMemoizedFactory()
+ private Func> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
- async ValueTask FactoryAsync(Config configuration, string sessionId)
+ async ValueTask FactoryAsync(ExecutorConfig configuration, string sessionId)
{
if (this.Id != configuration.Id)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs
index f5d1d40370..bda7e61a38 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs
@@ -53,6 +53,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
public ValueTask GetStatusAsync(CancellationToken cancellationToken = default)
=> this._eventStream.GetStatusAsync(cancellationToken);
+ internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
+ => this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId);
+
public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
//Debug.Assert(breakOnHalt);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
index 8c2162508d..6e3f2af5e6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -95,6 +96,18 @@ internal sealed class EdgeMap
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
}
+ internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
+ {
+ if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner))
+ {
+ executorId = portRunner.ExecutorId;
+ return true;
+ }
+
+ executorId = null;
+ return false;
+ }
+
internal async ValueTask> ExportStateAsync()
{
Dictionary exportedStates = [];
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
index 9b8c3c460c..8de0dbd5e2 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
@@ -19,6 +19,7 @@ internal interface ISuperStepRunner
bool HasUnprocessedMessages { get; }
ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
+ bool TryGetResponsePortExecutorId(string portId, out string? executorId);
ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default);
ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs
index a0170e7757..afca74af5b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs
@@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions
/// An id for the executor to be instantiated.
/// An optional parameter specifying the options.
/// An instance that resolves to the result of the factory call when messages get sent to it.
- public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null)
+ public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
@@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions
/// An instance that resolves to the result of the factory call when messages get sent to it.
[Obsolete("Use BindExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
- public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null)
+ public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
=> factoryAsync.BindExecutor(id, options);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs
similarity index 88%
rename from dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs
rename to dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs
index 09792d2a64..48bfd12bb9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs
@@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// Represents a configuration for an object with a string identifier. For example, object.
///
/// A unique identifier for the configurable object.
-public class Config(string id)
+public class ExecutorConfig(string id)
{
///
/// Gets a unique identifier for the configurable object.
@@ -23,7 +23,7 @@ public class Config(string id)
/// The type of options for the configurable object.
/// A unique identifier for the configurable object.
/// The options for the configurable object.
-public class Config(string id, TOptions? options = default) : Config(id)
+public class ExecutorConfig(string id, TOptions? options = default) : ExecutorConfig(id)
{
///
/// Gets the options for the configured object.
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
index 2a61f80ced..f93b09ddf3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
@@ -160,6 +160,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
+ bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId)
+ => this.RunContext.TryGetResponsePortExecutorId(portId, out executorId);
public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled;
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
index eda7b90a80..f0bb8cac26 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
@@ -296,6 +296,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
return this._externalRequests.TryRemove(requestId, out _);
}
+ internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
+ => this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId);
+
private IEventSink OutgoingEvents { get; }
internal StateManager StateManager { get; } = new();
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
index 9cc72d7310..cf9ddbe3a3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
@@ -68,10 +68,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
}
- List implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
+ // Merge the external response with any already-buffered regular messages so mixed-content
+ // resumes can be processed in one invocation.
+ return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
+ {
+ pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
- // ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
- return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
+ await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
+
+ // Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
+ return null;
+ }, context, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
@@ -84,8 +91,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
- List implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
- return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
+ // Merge the external response with any already-buffered regular messages so mixed-content
+ // resumes can be processed in one invocation.
+ return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
+ {
+ pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
+
+ await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
+
+ // Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
+ return null;
+ }, context, cancellationToken);
}
public bool ShouldEmitStreamingEvents(bool? emitEvents)
@@ -198,7 +214,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
}
- if (this._options.EmitAgentResponseEvents == true)
+ if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs
index 9173100b3e..15203fd5dc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs
@@ -16,10 +16,12 @@ internal sealed class AIContentExternalHandler _pendingRequests = new();
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func handler)
{
+ this._portId = portId;
PortBinding? portBinding = null;
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
this._portBinding = portBinding;
@@ -58,12 +60,14 @@ internal sealed class AIContentExternalHandler this._portBinding == null;
+ private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}";
+
private static string MakeKey(string id) => $"{id}_PendingRequests";
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
index b479cae75e..c7a19380b2 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
@@ -60,6 +60,9 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
internal ValueTask TrySendMessageUntypedAsync(object message, Type? declaredType = null)
=> this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
+ internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
+ => this._runHandle.TryGetResponsePortExecutorId(portId, out executorId);
+
///
/// Asynchronously streams workflow events as they occur during workflow execution.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
index 40a18dbadb..db3d299ee9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
@@ -25,6 +25,25 @@ internal sealed class WorkflowSession : AgentSession
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
+ ///
+ /// Tracks pending external requests by their workflow-facing request ID.
+ /// This mapping enables converting incoming response content back to
+ /// when resuming a workflow from a checkpoint.
+ ///
+ ///
+ ///
+ /// Entries are added when a is received during workflow execution,
+ /// and removed when a matching response is delivered via .
+ ///
+ ///
+ /// The number of entries is bounded by the number of outstanding external requests in a single workflow run.
+ /// When a session is abandoned, all pending requests are released with the session object.
+ /// Request-level timeouts, if needed, should be implemented in the workflow definition itself
+ /// (e.g., using a timer racing against an external event).
+ ///
+ ///
+ private readonly Dictionary _pendingRequests = [];
+
internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv)
{
inProcEnv = null;
@@ -90,6 +109,7 @@ internal sealed class WorkflowSession : AgentSession
this.LastCheckpoint = sessionState.LastCheckpoint;
this.StateBag = sessionState.StateBag;
+ this._pendingRequests = sessionState.PendingRequests ?? [];
}
public CheckpointInfo? LastCheckpoint { get; set; }
@@ -101,7 +121,8 @@ internal sealed class WorkflowSession : AgentSession
this.SessionId,
this.LastCheckpoint,
this._inMemoryCheckpointManager,
- this.StateBag);
+ this.StateBag,
+ this._pendingRequests);
return marshaller.Marshal(info);
}
@@ -141,7 +162,7 @@ internal sealed class WorkflowSession : AgentSession
return update;
}
- private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default)
+ private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default)
{
// The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session,
// and does not need to be checked again here.
@@ -154,18 +175,155 @@ internal sealed class WorkflowSession : AgentSession
cancellationToken)
.ConfigureAwait(false);
- await run.TrySendMessageAsync(messages).ConfigureAwait(false);
- return run;
+ // Process messages: convert response content to ExternalResponse, send regular messages as-is
+ ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false);
+ return new ResumeRunResult(run, dispatchInfo);
}
- return await this._executionEnvironment
+ StreamingRun newRun = await this._executionEnvironment
.RunStreamingAsync(this._workflow,
messages,
this.SessionId,
cancellationToken)
.ConfigureAwait(false);
+ return new ResumeRunResult(newRun);
}
+ ///
+ /// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent
+ /// to ExternalResponse when there's a matching pending request.
+ ///
+ ///
+ /// Structured information about how resume content was dispatched.
+ ///
+ private async ValueTask SendMessagesWithResponseConversionAsync(StreamingRun run, List messages)
+ {
+ List regularMessages = [];
+ // Responses are deferred until after regular messages are queued so response handlers
+ // can merge buffered regular content in the same continuation turn.
+ List<(ExternalResponse Response, string RequestId)> externalResponses = [];
+ bool hasMatchedResponseForStartExecutor = false;
+
+ // Tracks content IDs already matched to pending requests within this invocation,
+ // preventing duplicate responses for the same ID from being sent to the workflow engine.
+ HashSet? matchedContentIds = null;
+
+ foreach (ChatMessage message in messages)
+ {
+ List regularContents = [];
+
+ foreach (AIContent content in message.Contents)
+ {
+ string? contentId = GetResponseContentId(content);
+
+ // Skip duplicate response content for an already-matched content ID
+ if (contentId != null && matchedContentIds?.Contains(contentId) == true)
+ {
+ continue;
+ }
+
+ if (contentId != null
+ && this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest)
+ {
+ // For intercepted/complex topologies the port may not be registered in the EdgeMap.
+ // Treat unknown port as non-start-executor (conservative): TurnToken will still be sent.
+ if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId))
+ {
+ hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
+ }
+
+ AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
+ externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
+ (matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
+ }
+ else
+ {
+ regularContents.Add(content);
+ }
+ }
+
+ if (regularContents.Count > 0)
+ {
+ ChatMessage cloned = message.Clone();
+ cloned.Contents = regularContents;
+ regularMessages.Add(cloned);
+ }
+ }
+
+ // Send regular messages first so response handlers can merge them with responses.
+ bool hasRegularMessages = regularMessages.Count > 0;
+ if (hasRegularMessages)
+ {
+ await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false);
+ }
+
+ // Send external responses after regular messages.
+ bool hasMatchedExternalResponses = false;
+ foreach ((ExternalResponse response, string requestId) in externalResponses)
+ {
+ await run.SendResponseAsync(response).ConfigureAwait(false);
+ hasMatchedExternalResponses = true;
+ this.RemovePendingRequest(requestId);
+ }
+
+ return new ResumeDispatchInfo(
+ hasRegularMessages,
+ hasMatchedExternalResponses,
+ hasMatchedResponseForStartExecutor);
+ }
+
+ ///
+ /// Creates the workflow-facing request content surfaced in response updates.
+ ///
+ private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
+ {
+ ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
+ => CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
+ ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
+ => CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
+ ExternalRequest externalRequest
+ => externalRequest.ToFunctionCall(),
+ };
+
+ ///
+ /// Rewrites workflow-facing response content back to the original agent-owned content ID.
+ ///
+ private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch
+ {
+ FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent)
+ => CloneFunctionResultContent(functionResultContent, functionCallContent.CallId),
+ ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
+ => CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId),
+ _ => content,
+ };
+
+ ///
+ /// Gets the workflow-facing request ID from response content types.
+ ///
+ private static string? GetResponseContentId(AIContent content) => content switch
+ {
+ FunctionResultContent functionResultContent => functionResultContent.CallId,
+ ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId,
+ _ => null
+ };
+
+ ///
+ /// Tries to get a pending request by workflow-facing request ID.
+ ///
+ private ExternalRequest? TryGetPendingRequest(string requestId) =>
+ this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null;
+
+ ///
+ /// Adds a pending request indexed by workflow-facing request ID.
+ ///
+ private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request;
+
+ ///
+ /// Removes a pending request by workflow-facing request ID.
+ ///
+ private void RemovePendingRequest(string requestId) =>
+ this._pendingRequests.Remove(requestId);
+
internal async
IAsyncEnumerable InvokeStageAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
@@ -175,12 +333,25 @@ internal sealed class WorkflowSession : AgentSession
this.LastResponseId = Guid.NewGuid().ToString("N");
List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
-#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
- await using StreamingRun run =
+ ResumeRunResult resumeResult =
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
+#pragma warning disable CA2007 // Analyzer misfiring.
+ await using StreamingRun run = resumeResult.Run;
#pragma warning restore CA2007
- await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
+ ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
+
+ // Send a TurnToken to the start executor unless the only activity is an external
+ // response directed at the start executor itself (which self-emits a TurnToken via
+ // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
+ // TurnTokens after processing responses, so the session must always provide one.
+ bool shouldSendTurnToken =
+ !dispatchInfo.HasMatchedExternalResponses
+ || !dispatchInfo.HasMatchedResponseForStartExecutor;
+ if (shouldSendTurnToken)
+ {
+ await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
+ }
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
@@ -192,8 +363,13 @@ internal sealed class WorkflowSession : AgentSession
break;
case RequestInfoEvent requestInfo:
- FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
- AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
+ AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
+
+ // Track the pending request so we can convert incoming responses back to ExternalResponse.
+ // External callers respond using the workflow-facing request ID, which is always RequestId.
+ this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request);
+
+ AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent);
yield return update;
break;
@@ -267,15 +443,116 @@ internal sealed class WorkflowSession : AgentSession
///
public WorkflowChatHistoryProvider ChatHistoryProvider { get; }
+ ///
+ /// Captures the outcome of creating or resuming a workflow run,
+ /// indicating what types of messages were sent during resume.
+ ///
+ private readonly struct ResumeRunResult
+ {
+ /// The streaming run that was created or resumed.
+ public StreamingRun Run { get; }
+
+ /// How resume-time content was dispatched into the workflow runtime.
+ public ResumeDispatchInfo DispatchInfo { get; }
+
+ public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default)
+ {
+ this.Run = Throw.IfNull(run);
+ this.DispatchInfo = dispatchInfo;
+ }
+ }
+
+ ///
+ /// Captures how resumed input was split across regular-message and external-response delivery paths.
+ ///
+ private readonly struct ResumeDispatchInfo
+ {
+ public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor)
+ {
+ this.HasRegularMessages = hasRegularMessages;
+ this.HasMatchedExternalResponses = hasMatchedExternalResponses;
+ this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor;
+ }
+
+ public bool HasRegularMessages { get; }
+
+ public bool HasMatchedExternalResponses { get; }
+
+ public bool HasMatchedResponseForStartExecutor { get; }
+ }
+
+ ///
+ /// Clones a with a workflow-facing call ID.
+ ///
+ private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId)
+ {
+ FunctionCallContent clone = new(callId, content.Name, content.Arguments)
+ {
+ Exception = content.Exception,
+ InformationalOnly = content.InformationalOnly,
+ };
+
+ return CopyContentMetadata(content, clone);
+ }
+
+ ///
+ /// Clones a with an agent-owned call ID.
+ ///
+ private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId)
+ {
+ FunctionResultContent clone = new(callId, content.Result)
+ {
+ Exception = content.Exception,
+ };
+
+ return CopyContentMetadata(content, clone);
+ }
+
+ ///
+ /// Clones a with a workflow-facing request ID.
+ ///
+ private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id)
+ {
+ ToolApprovalRequestContent clone = new(id, content.ToolCall);
+ return CopyContentMetadata(content, clone);
+ }
+
+ ///
+ /// Clones a with an agent-owned request ID.
+ ///
+ private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id)
+ {
+ ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall)
+ {
+ Reason = content.Reason,
+ };
+
+ return CopyContentMetadata(content, clone);
+ }
+
+ ///
+ /// Copies shared metadata to a cloned content instance.
+ ///
+ private static TContent CopyContentMetadata(AIContent source, TContent target)
+ where TContent : AIContent
+ {
+ target.AdditionalProperties = source.AdditionalProperties;
+ target.Annotations = source.Annotations;
+ target.RawRepresentation = source.RawRepresentation;
+ return target;
+ }
+
internal sealed class SessionState(
string sessionId,
CheckpointInfo? lastCheckpoint,
InMemoryCheckpointManager? checkpointManager = null,
- AgentSessionStateBag? stateBag = null)
+ AgentSessionStateBag? stateBag = null,
+ Dictionary? pendingRequests = null)
{
public string SessionId { get; } = sessionId;
public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint;
public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager;
public AgentSessionStateBag StateBag { get; } = stateBag ?? new();
+ public Dictionary? PendingRequests { get; } = pendingRequests;
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
index 08d1dcbcbb..4a94961522 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
@@ -71,6 +71,7 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(PortableValue))]
[JsonSerializable(typeof(PortableMessageEnvelope))]
[JsonSerializable(typeof(InMemoryCheckpointManager))]
+ [JsonSerializable(typeof(CheckpointFileIndexEntry))]
// Runtime State Types
[JsonSerializable(typeof(ScopeKey))]
diff --git a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs
index 305abe0465..bf93832232 100644
--- a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs
@@ -161,6 +161,8 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient
}
// Materialize the accumulated context back into messages and options.
+ // Clone options to avoid mutating the caller's instance across calls.
+ options = options?.Clone();
var enrichedMessages = aiContext.Messages ?? [];
var tools = aiContext.Tools as IList ?? aiContext.Tools?.ToList();
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index adb6eb9f83..6722bd8738 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -138,6 +138,9 @@ public sealed partial class ChatClientAgent : AIAgent
this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider);
this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger();
+
+ // Warn if using a custom chat client stack with end-of-run persistence but no ChatHistoryPersistingChatClient.
+ this.WarnOnMissingPersistingClient();
}
///
@@ -211,12 +214,14 @@ public sealed partial class ChatClientAgent : AIAgent
ChatClientAgentContinuationToken? _) =
await this.PrepareSessionAndMessagesAsync(session, inputMessages, options, cancellationToken).ConfigureAwait(false);
- var chatClient = this.ChatClient;
+ // Update the run context with the resolved session so any downstream classes
+ // always have a valid session, even when the caller passed null.
+ EnsureRunContextHasSession(safeSession);
+ var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
var loggingAgentName = this.GetLoggingAgentName();
-
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
// Call the IChatClient and notify the AIContextProvider of any failures.
@@ -227,8 +232,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, cancellationToken).ConfigureAwait(false);
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -236,7 +240,8 @@ public sealed partial class ChatClientAgent : AIAgent
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
- this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
+ var forceEndOfRunPersistence = chatOptions?.ContinuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
+ this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
@@ -244,11 +249,10 @@ public sealed partial class ChatClientAgent : AIAgent
chatResponseMessage.AuthorName ??= this.Name;
}
- // Only notify the session of new messages if the chatResponse was successful to avoid inconsistent message state in the session.
- await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
-
- // Notify the AIContextProvider of all new messages.
- await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
+ // Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
+ // When background responses are allowed, force notification since per-service-call persistence
+ // is unreliable (the caller may stop consuming the stream before the decorator can persist).
+ await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
return new AgentResponse(chatResponse)
{
@@ -296,6 +300,10 @@ public sealed partial class ChatClientAgent : AIAgent
ChatClientAgentContinuationToken? continuationToken) =
await this.PrepareSessionAndMessagesAsync(session, inputMessages, options, cancellationToken).ConfigureAwait(false);
+ // Update the run context with the resolved session so any downstream classes
+ // always have a valid session, even when the caller passed null.
+ EnsureRunContextHasSession(safeSession);
+
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
@@ -315,8 +323,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -330,8 +337,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -353,27 +359,31 @@ public sealed partial class ChatClientAgent : AIAgent
try
{
+ // Re-ensure the run context has the resolved session before each MoveNextAsync.
+ // The base class RunStreamingAsync restores the original context (potentially with
+ // null session) after each yield, so we must re-establish it for the decorator.
+ EnsureRunContextHasSession(safeSession);
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
+ var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
+
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
- this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
+ this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
- // To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request.
- await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
-
- // Notify the AIContextProvider of all new messages.
- await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, cancellationToken).ConfigureAwait(false);
+ // Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
+ // When resuming from a continuation token or using background responses, force notification
+ // to send the combined data (per-service-call persistence is unreliable for these scenarios).
+ await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
///
@@ -441,17 +451,29 @@ public sealed partial class ChatClientAgent : AIAgent
#region Private
///
- /// Notify the when an agent run succeeded, if there is an .
+ /// Notifies the and all of successfully completed messages.
///
- private async Task NotifyAIContextProviderOfSuccessAsync(
+ ///
+ /// This method is also called by to persist messages per-service-call.
+ ///
+ internal async Task NotifyProvidersOfNewMessagesAsync(
ChatClientAgentSession session,
- IEnumerable inputMessages,
+ IEnumerable requestMessages,
IEnumerable responseMessages,
+ ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
+ ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
+
+ if (chatHistoryProvider is not null)
+ {
+ var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages);
+ await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
+
if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, responseMessages);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, responseMessages);
foreach (var contextProvider in contextProviders)
{
@@ -461,17 +483,29 @@ public sealed partial class ChatClientAgent : AIAgent
}
///
- /// Notify the of any failure during an agent run, if there is an .
+ /// Notifies the and all of a failure during a service call.
///
- private async Task NotifyAIContextProviderOfFailureAsync(
+ ///
+ /// This method is also called by to report failures per-service-call.
+ ///
+ internal async Task NotifyProvidersOfFailureAsync(
ChatClientAgentSession session,
Exception ex,
- IEnumerable inputMessages,
+ IEnumerable requestMessages,
+ ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
+ ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
+
+ if (chatHistoryProvider is not null)
+ {
+ var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex);
+ await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
+
if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, ex);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, ex);
foreach (var contextProvider in contextProviders)
{
@@ -667,6 +701,12 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
}
+ if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.PersistsChatHistoryPerServiceCall && this._logger.IsEnabled(LogLevel.Warning))
+ {
+ var warningAgentName = this.GetLoggingAgentName();
+ this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
+ }
+
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not ChatClientAgentSession typedSession)
{
@@ -754,7 +794,7 @@ public sealed partial class ChatClientAgent : AIAgent
return (typedSession, chatOptions, messagesList, continuationToken);
}
- private void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
+ internal void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(session.ConversationId))
{
@@ -798,45 +838,162 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
- private Task NotifyChatHistoryProviderOfFailureAsync(
+ ///
+ /// Updates the session conversation ID at the end of an agent run.
+ ///
+ ///
+ /// When a in persist mode handles per-service-call
+ /// conversation ID updates, this end-of-run update is skipped. When the decorator is in mark-only
+ /// mode or absent, the update is performed here. When is
+ /// (continuation token scenarios), the update is always performed.
+ ///
+ private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
+ {
+ if (!forceUpdate && this.PersistsChatHistoryPerServiceCall)
+ {
+ return;
+ }
+
+ this.UpdateSessionConversationId(session, responseConversationId, cancellationToken);
+ }
+
+ ///
+ /// Notifies providers of successfully completed messages at the end of an agent run.
+ ///
+ ///
+ /// When a in persist mode handles per-service-call
+ /// notification, this end-of-run notification is skipped. When the decorator is in mark-only mode,
+ /// only the marked messages are persisted. When no decorator is present (custom stack with
+ /// ), all messages are persisted.
+ /// When is (continuation token or
+ /// background response scenarios), notification is always performed with all messages because
+ /// per-service-call persistence is unreliable in these scenarios.
+ ///
+ private Task NotifyProvidersOfNewMessagesAtEndOfRunAsync(
+ ChatClientAgentSession session,
+ IEnumerable requestMessages,
+ IEnumerable responseMessages,
+ ChatOptions? chatOptions,
+ CancellationToken cancellationToken,
+ bool forceNotify = false)
+ {
+ if (!forceNotify && this.PersistsChatHistoryPerServiceCall)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (!forceNotify && this.HasMarkOnlyChatHistoryPersistingClient)
+ {
+ // In mark-only mode, persist only messages that were marked by the decorator.
+ var markedRequestMessages = GetMarkedMessages(requestMessages);
+ var markedResponseMessages = GetMarkedMessages(responseMessages);
+ return this.NotifyProvidersOfNewMessagesAsync(session, markedRequestMessages, markedResponseMessages, chatOptions, cancellationToken);
+ }
+
+ return this.NotifyProvidersOfNewMessagesAsync(session, requestMessages, responseMessages, chatOptions, cancellationToken);
+ }
+
+ ///
+ /// Notifies providers of a failure at the end of an agent run.
+ ///
+ ///
+ /// When a in persist mode handles per-service-call
+ /// notification (including failure), this end-of-run notification is skipped to avoid
+ /// duplicate notification. In all other cases, failure is reported at the end of the run.
+ ///
+ private Task NotifyProvidersOfFailureAtEndOfRunAsync(
ChatClientAgentSession session,
Exception ex,
IEnumerable requestMessages,
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
- ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
-
- // Only notify the provider if we have one.
- // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
- if (provider is not null)
+ if (this.PersistsChatHistoryPerServiceCall)
{
- var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex);
-
- return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
+ return Task.CompletedTask;
}
- return Task.CompletedTask;
+ return this.NotifyProvidersOfFailureAsync(session, ex, requestMessages, chatOptions, cancellationToken);
}
- private Task NotifyChatHistoryProviderOfNewMessagesAsync(
- ChatClientAgentSession session,
- IEnumerable requestMessages,
- IEnumerable responseMessages,
- ChatOptions? chatOptions,
- CancellationToken cancellationToken)
+ ///
+ /// Gets a value indicating whether the agent has a
+ /// decorator in persist mode (not mark-only), which handles per-service-call persistence.
+ ///
+ private bool PersistsChatHistoryPerServiceCall
{
- ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
-
- // Only notify the provider if we have one.
- // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
- if (provider is not null)
+ get
{
- var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages);
- return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
+ var persistingClient = this.ChatClient.GetService();
+ return persistingClient?.MarkOnly == false;
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the agent has a
+ /// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
+ ///
+ private bool HasMarkOnlyChatHistoryPersistingClient
+ {
+ get
+ {
+ var persistingClient = this.ChatClient.GetService();
+ return persistingClient?.MarkOnly == true;
+ }
+ }
+
+ ///
+ /// Returns only the messages that have been marked as persisted by a in mark-only mode.
+ ///
+ private static List GetMarkedMessages(IEnumerable messages)
+ {
+ return messages.Where(m =>
+ m.AdditionalProperties?.TryGetValue(ChatHistoryPersistingChatClient.PersistedMarkerKey, out var value) == true && value is true).ToList();
+ }
+
+ ///
+ /// Ensures that contains the resolved session.
+ ///
+ ///
+ /// The base class sets with the raw session parameter
+ /// (which may be null) and restores it after each yield in streaming scenarios. After
+ /// resolves or creates a session, we update the
+ /// context so the decorator always has a valid session.
+ /// The original agent from the context is preserved to maintain the top-of-stack agent in
+ /// decorated agent scenarios.
+ ///
+ private static void EnsureRunContextHasSession(ChatClientAgentSession safeSession)
+ {
+ var context = CurrentRunContext;
+ if (context is not null && context.Session != safeSession)
+ {
+ CurrentRunContext = new(context.Agent, safeSession, context.RequestMessages, context.RunOptions);
+ }
+ }
+
+ ///
+ /// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
+ ///
+ private void WarnOnMissingPersistingClient()
+ {
+ if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
+ {
+ return;
}
- return Task.CompletedTask;
+ if (this._agentOptions?.PersistChatHistoryAtEndOfRun is not true)
+ {
+ return;
+ }
+
+ var persistingClient = this.ChatClient.GetService();
+ if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
+ {
+ var loggingAgentName = this.GetLoggingAgentName();
+ this._logger.LogAgentChatClientMissingPersistingClient(
+ this.Id,
+ loggingAgentName);
+ }
}
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs
index 98ff4583dc..2a324522a4 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs
@@ -69,4 +69,32 @@ internal static partial class ChatClientAgentLogMessages
string chatHistoryProviderName,
string agentId,
string agentName);
+
+ ///
+ /// Logs a warning when is
+ /// and is ,
+ /// but no is found in the custom chat client stack.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Agent {AgentId}/{AgentName}: PersistChatHistoryAtEndOfRun is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ChatHistoryPersistingChatClient was found in the pipeline. All messages will be persisted at the end of the run without marking. This setup is not supported with some other features, e.g. handoffs. Consider adding a ChatHistoryPersistingChatClient to the pipeline using the UseChatHistoryPersisting extension method.")]
+ public static partial void LogAgentChatClientMissingPersistingClient(
+ this ILogger logger,
+ string agentId,
+ string agentName);
+
+ ///
+ /// Logs a warning when per-service-call persistence falls back to end-of-run persistence
+ /// because the run involves background responses (continuation token resumption or
+ /// AllowBackgroundResponses). Per-service-call persistence is
+ /// unreliable in these scenarios because the caller may stop consuming the stream before
+ /// the decorator's post-stream persistence code can execute.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Agent {AgentId}/{AgentName}: Per-service-call persistence is falling back to end-of-run persistence because the run involves background responses. Messages will be marked during the run and persisted at the end.")]
+ public static partial void LogAgentChatClientBackgroundResponseFallback(
+ this ILogger logger,
+ string agentId,
+ string agentName);
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 38cad40bbe..8df9112446 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -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;
@@ -89,6 +91,56 @@ public sealed class ChatClientAgentOptions
///
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
+ ///
+ /// Gets or sets a value indicating whether to persist chat history only at the end of the full agent run
+ /// rather than after each individual service call.
+ ///
+ ///
+ ///
+ /// By default, persists request and response messages either via
+ /// a , or the underlying AI service's chat history storage.
+ /// Persistence is done immediately after each call to the AI service within the function invocation loop.
+ /// When storing in the underlying AI service, the session's
+ /// is also updated after each service call, keeping it in sync with the service-side conversation state.
+ ///
+ ///
+ /// Setting this property to causes messages to be marked during the function
+ /// invocation loop but persisted only at the end of the full agent run, providing atomic run semantics.
+ /// Updating the is likewise deferred and
+ /// updated only at the end of the run, consistent with atomic run semantics.
+ /// A decorator is inserted into the chat client pipeline
+ /// in mark-only mode, and the persists only the marked messages at the
+ /// end of the run.
+ ///
+ ///
+ /// When this option is (the default), the
+ /// decorator persists messages and updates the
+ /// immediately after each service call. This may leave chat history in a state where
+ /// is required to start a new run if the last successful service
+ /// call returned .
+ ///
+ ///
+ /// This option has no effect when is .
+ /// When using a custom chat client stack, you can add a
+ /// manually via the
+ /// extension method.
+ ///
+ ///
+ /// Note that when using single threaded service stored chat history, like OpenAI Conversations,
+ /// there is only one id, so even if the conversation id is not updated after each service call,
+ /// the chat history will still contain intermediate messages. Setting this property to
+ /// in this case will therefore have no real effect. Setting this property to when using
+ /// OpenAI Responses with response ids on the other hand, allows atomic run semantics, since
+ /// each service request produces a new response id, and if the run fails mid-loop, the session will
+ /// still contain the pre-run respnose id, allowing the next run to start with a clean slate.
+ ///
+ ///
+ ///
+ /// Default is .
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public bool PersistChatHistoryAtEndOfRun { get; set; }
+
///
/// Creates a new instance of with the same values as this instance.
///
@@ -105,5 +157,6 @@ public sealed class ChatClientAgentOptions
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
+ PersistChatHistoryAtEndOfRun = this.PersistChatHistoryAtEndOfRun,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
index ee782dce52..a1e8b5f8a5 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs
@@ -2,8 +2,10 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -82,4 +84,46 @@ public static class ChatClientBuilderExtensions
options: options,
loggerFactory: loggerFactory,
services: services);
+
+ ///
+ /// Adds a to the chat client pipeline.
+ ///
+ ///
+ ///
+ /// This decorator should be positioned between the and the leaf
+ /// in the pipeline. It intercepts service calls to either persist messages
+ /// immediately or mark them for later persistence, depending on the parameter.
+ ///
+ ///
+ /// If is set to , the
+ /// should be configured with set to
+ /// as without this combination, messages will never be persisted when using a for
+ /// chat history persistence.
+ ///
+ ///
+ /// This extension method is intended for use with custom chat client stacks when
+ /// is .
+ /// When is (the default),
+ /// the automatically injects this decorator.
+ ///
+ ///
+ /// This decorator only works within the context of a running and will throw an
+ /// exception if used in any other stack.
+ ///
+ ///
+ /// The to add the decorator to.
+ ///
+ /// When , messages are marked with metadata but not persisted immediately,
+ /// and the session's is not updated.
+ /// The will persist only the marked messages and update the
+ /// conversation ID at the end of the run.
+ /// When (the default), messages are persisted and the conversation ID
+ /// is updated immediately after each service call.
+ ///
+ /// The for chaining.
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public static ChatClientBuilder UseChatHistoryPersisting(this ChatClientBuilder builder, bool markOnly = false)
+ {
+ return builder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index 8290c39974..fffac628a6 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -63,6 +63,15 @@ public static class ChatClientExtensions
});
}
+ // ChatHistoryPersistingChatClient is registered after FunctionInvokingChatClient so that it sits
+ // between FIC and the leaf client. ChatClientBuilder.Build applies factories in reverse order,
+ // making the first Use() call outermost. By adding our decorator second, the resulting pipeline is:
+ // FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
+ // This allows the decorator to persist messages after each individual service call within
+ // FIC's function invocation loop, or to mark them for later persistence at the end of the run.
+ bool markOnly = options?.PersistChatHistoryAtEndOfRun is true;
+ chatBuilder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
+
var agentChatClient = chatBuilder.Build(services);
if (options?.ChatOptions?.Tools is { Count: > 0 })
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
new file mode 100644
index 0000000000..0085afbdd5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
@@ -0,0 +1,313 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating chat client that notifies and
+/// instances of request and response messages after each individual call to the inner chat client,
+/// or marks messages for later persistence depending on the configured mode.
+///
+///
+///
+/// This decorator is intended to operate between the and the leaf
+/// in a pipeline.
+///
+///
+/// In persist mode (the default), it ensures that providers are notified and the session's
+/// is updated after each service call, so that
+/// intermediate messages (e.g., tool calls and results) are saved even if the process is interrupted
+/// mid-loop.
+///
+///
+/// In mark-only mode ( is ), it marks messages with metadata
+/// but does not notify providers or update the .
+/// Both are deferred to the at the end of the run, providing atomic
+/// run semantics.
+///
+///
+/// This chat client must be used within the context of a running . It retrieves the
+/// current agent and session from , which is set automatically when an agent's
+/// or
+///
+/// method is called. The ensures the run context always contains a resolved session,
+/// even when the caller passes null. An is thrown if no run context is
+/// available or if the agent is not a .
+///
+///
+internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
+{
+ ///
+ /// The key used in and
+ /// to mark messages and their content as already persisted to chat history.
+ ///
+ internal const string PersistedMarkerKey = "_chatHistoryPersisted";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying chat client that will handle the core operations.
+ ///
+ /// When , messages are marked with metadata but not persisted immediately,
+ /// and the session's is not updated.
+ /// The will persist only the marked messages and update the
+ /// conversation ID at the end of the run.
+ /// When (the default), messages are persisted and the conversation ID
+ /// is updated immediately after each service call.
+ ///
+ public ChatHistoryPersistingChatClient(IChatClient innerClient, bool markOnly = false)
+ : base(innerClient)
+ {
+ this.MarkOnly = markOnly;
+ }
+
+ ///
+ /// Gets a value indicating whether this decorator is in mark-only mode.
+ ///
+ ///
+ /// When , messages are marked with metadata but not persisted immediately,
+ /// and the session's is not updated.
+ /// Both are deferred to the at the end of the run.
+ /// When , messages are persisted and the conversation ID is updated
+ /// after each service call.
+ ///
+ public bool MarkOnly { get; }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var (agent, session) = GetRequiredAgentAndSession();
+
+ ChatResponse response;
+ try
+ {
+ response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ var newRequestMessages = GetNewRequestMessages(messages);
+
+ if (this.ShouldDeferPersistence(options))
+ {
+ // In mark-only mode or when resuming from a continuation token, just mark messages
+ // for later persistence by ChatClientAgent. Conversation ID and provider notification
+ // are deferred to end-of-run. For continuation tokens, the end-of-run handler needs
+ // to send the combined data from both the previous and current runs.
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(response.Messages);
+ }
+ else
+ {
+ // In persist mode, persist immediately and update conversation ID.
+ agent.UpdateSessionConversationId(session, response.ConversationId, cancellationToken);
+ await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, response.Messages, options, cancellationToken).ConfigureAwait(false);
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(response.Messages);
+ }
+
+ return response;
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var (agent, session) = GetRequiredAgentAndSession();
+
+ List responseUpdates = [];
+
+ IAsyncEnumerator enumerator;
+ try
+ {
+ enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ bool hasUpdates;
+ try
+ {
+ hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ while (hasUpdates)
+ {
+ var update = enumerator.Current;
+ responseUpdates.Add(update);
+ yield return update;
+
+ try
+ {
+ hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ var chatResponse = responseUpdates.ToChatResponse();
+ var newRequestMessages = GetNewRequestMessages(messages);
+
+ if (this.ShouldDeferPersistence(options))
+ {
+ // In mark-only mode or when resuming from a continuation token, just mark messages
+ // for later persistence by ChatClientAgent. Conversation ID and provider notification
+ // are deferred to end-of-run. For continuation tokens, the end-of-run handler needs
+ // to send the combined data from both the previous and current runs.
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(chatResponse.Messages);
+ }
+ else
+ {
+ // In persist mode, persist immediately and update conversation ID.
+ agent.UpdateSessionConversationId(session, chatResponse.ConversationId, cancellationToken);
+ await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false);
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(chatResponse.Messages);
+ }
+ }
+
+ ///
+ /// Gets the current and from the run context.
+ ///
+ private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession()
+ {
+ var runContext = AIAgent.CurrentRunContext
+ ?? throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
+ "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
+
+ var chatClientAgent = runContext.Agent.GetService()
+ ?? throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
+ $"The current agent is of type '{runContext.Agent.GetType().Name}'.");
+
+ if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
+ $"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
+ }
+
+ return (chatClientAgent, chatClientAgentSession);
+ }
+
+ ///
+ /// Determines whether persistence should be deferred to end-of-run instead of happening immediately.
+ ///
+ ///
+ /// when in mode, when the call is resuming from
+ /// a continuation token (since the end-of-run handler needs to combine data from the previous
+ /// and current runs), or when background responses are allowed (since the caller may stop
+ /// consuming the stream mid-run, preventing the post-stream persistence code from executing).
+ ///
+ private bool ShouldDeferPersistence(ChatOptions? options)
+ {
+ return this.MarkOnly || options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true;
+ }
+
+ ///
+ /// Returns only the request messages that have not yet been persisted to chat history.
+ ///
+ ///
+ /// A message is considered already persisted if any of the following is true:
+ ///
+ /// - It has the in its .
+ /// - It has an of
+ /// (indicating it was loaded from chat history and does not need to be re-persisted).
+ /// - It has and all of its items have the
+ /// in their . This handles the
+ /// streaming case where reconstructs objects
+ /// independently via ToChatResponse(), producing different object references that share the same
+ /// underlying instances.
+ ///
+ ///
+ /// A list of request messages that have not yet been persisted.
+ /// The full set of request messages to filter.
+ private static List GetNewRequestMessages(IEnumerable messages)
+ {
+ return messages.Where(m => !IsAlreadyPersisted(m)).ToList();
+ }
+
+ ///
+ /// Determines whether a message has already been persisted to chat history by this decorator.
+ ///
+ private static bool IsAlreadyPersisted(ChatMessage message)
+ {
+ if (message.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true)
+ {
+ return true;
+ }
+
+ if (message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.ChatHistory)
+ {
+ return true;
+ }
+
+ // In streaming mode, FunctionInvokingChatClient reconstructs ChatMessage objects via ToChatResponse()
+ // independently, producing different ChatMessage instances. However, the underlying AIContent objects
+ // (e.g., FunctionCallContent, FunctionResultContent) are shared references. Checking for markers on
+ // AIContent handles dedup in this case.
+ if (message.Contents.Count > 0 && message.Contents.All(c => c.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Marks the given messages as persisted by setting a marker on both the
+ /// and each of its items.
+ ///
+ ///
+ /// Both levels are marked because may reconstruct
+ /// objects in streaming mode (losing the message-level marker),
+ /// but the references are shared and retain their markers.
+ ///
+ /// The messages to mark as persisted.
+ private static void MarkAsPersisted(IEnumerable messages)
+ {
+ foreach (var message in messages)
+ {
+ message.AdditionalProperties ??= new();
+ message.AdditionalProperties[PersistedMarkerKey] = true;
+
+ foreach (var content in message.Contents)
+ {
+ content.AdditionalProperties ??= new();
+ content.AdditionalProperties[PersistedMarkerKey] = true;
+ }
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs
index da075ea107..a7f2f51156 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs
@@ -5,6 +5,8 @@ using System.Reflection;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
///
@@ -235,6 +237,114 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
+ [Fact]
+ public async Task WorkflowMcpToolSampleValidationAsync()
+ {
+ string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
+ await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
+ {
+ // Connect to the MCP endpoint exposed by the Azure Functions host
+ IClientTransport clientTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
+ });
+
+ await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
+
+ // Verify both workflow tools are listed
+ IList tools = await mcpClient.ListToolsAsync();
+ this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
+
+ Assert.Single(tools, t => t.Name == "Translate");
+ Assert.Single(tools, t => t.Name == "OrderLookup");
+
+ // Invoke the Translate workflow via MCP tool (returns a string result)
+ this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
+ CallToolResult translateResult = await mcpClient.CallToolAsync(
+ "Translate",
+ arguments: new Dictionary { { "input", "hello world" } });
+
+ Assert.NotEmpty(translateResult.Content);
+ string translateResponse = Assert.IsType(translateResult.Content[0]).Text;
+ this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
+ Assert.NotEmpty(translateResponse);
+ Assert.Contains("HELLO WORLD", translateResponse);
+
+ // Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON)
+ this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'...");
+ CallToolResult orderResult = await mcpClient.CallToolAsync(
+ "OrderLookup",
+ arguments: new Dictionary { { "input", "ORD-2025-42" } });
+
+ Assert.NotEmpty(orderResult.Content);
+ string orderResponse = Assert.IsType(orderResult.Content[0]).Text;
+ this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}");
+ Assert.NotEmpty(orderResponse);
+ Assert.Contains("ORD-2025-42", orderResponse);
+
+ // Verify executor activities ran in the logs
+ lock (logs)
+ {
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs.");
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs.");
+ }
+ });
+ }
+
+ [Fact]
+ public async Task WorkflowAndAgentsSampleValidationAsync()
+ {
+ string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
+ await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) =>
+ {
+ // Connect to the MCP endpoint exposed by the Azure Functions host
+ IClientTransport clientTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
+ });
+
+ await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
+
+ // Verify both the agent and workflow tools are listed
+ IList tools = await mcpClient.ListToolsAsync();
+ this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
+
+ Assert.Single(tools, t => t.Name == "Assistant");
+ Assert.Single(tools, t => t.Name == "Translate");
+
+ // Invoke the Translate workflow via MCP tool
+ this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
+ CallToolResult translateResult = await mcpClient.CallToolAsync(
+ "Translate",
+ arguments: new Dictionary { { "input", "hello world" } });
+
+ Assert.NotEmpty(translateResult.Content);
+ string translateResponse = Assert.IsType(translateResult.Content[0]).Text;
+ this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
+ Assert.Contains("HELLO WORLD", translateResponse);
+
+ // Invoke the Assistant agent via MCP tool
+ this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'...");
+ CallToolResult assistantResult = await mcpClient.CallToolAsync(
+ "Assistant",
+ arguments: new Dictionary { { "query", "What is 2 + 2?" } });
+
+ Assert.NotEmpty(assistantResult.Content);
+ string assistantResponse = Assert.IsType(assistantResult.Content[0]).Text;
+ this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}");
+ Assert.NotEmpty(assistantResponse);
+
+ // Verify workflow executor activities ran in the logs
+ lock (logs)
+ {
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
+ Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
+ }
+ });
+ }
+
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs
index 7d3a2ec13e..82824e0d8c 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs
@@ -148,6 +148,45 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
}
}
+ [Fact]
+ public void Transform_SkipsAgents_WithoutExplicitOptions()
+ {
+ // Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions.
+ // This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent.
+ Dictionary> agents = new()
+ {
+ { "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") },
+ { "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") }
+ };
+
+ FunctionsAgentOptions standaloneOptions = new();
+ standaloneOptions.HttpTrigger.IsEnabled = true;
+
+ // Only standaloneAgent has explicit options; workflowAgent does not.
+ IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary
+ {
+ { "standaloneAgent", standaloneOptions }
+ });
+
+ List metadataList = [];
+
+ DurableAgentFunctionMetadataTransformer transformer = new(
+ agents,
+ NullLogger.Instance,
+ new FakeServiceProvider(),
+ agentOptionsProvider);
+
+ // Act
+ transformer.Transform(metadataList);
+
+ // Assert: only standaloneAgent should have triggers (entity + http = 2).
+ // workflowAgent should be skipped entirely.
+ Assert.Equal(2, metadataList.Count);
+ Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent");
+ Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent");
+ Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent"));
+ }
+
private static List BuildFunctionMetadataList(int numberOfFunctions)
{
List list = [];
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs
new file mode 100644
index 0000000000..a777c69480
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs
@@ -0,0 +1,121 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
+
+namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
+
+public sealed class FunctionMetadataFactoryTests
+{
+ [Fact]
+ public void CreateEntityTrigger_SetsCorrectNameAndBindings()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent");
+
+ Assert.Equal("dafx-myAgent", metadata.Name);
+ Assert.Equal("dotnet-isolated", metadata.Language);
+ Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint);
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Equal(2, metadata.RawBindings.Count);
+ Assert.Contains("entityTrigger", metadata.RawBindings[0]);
+ Assert.Contains("durableClient", metadata.RawBindings[1]);
+ }
+
+ [Fact]
+ public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
+ "myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint);
+
+ Assert.Equal("http-myWorkflow", metadata.Name);
+ Assert.Equal("dotnet-isolated", metadata.Language);
+ Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint);
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Equal(3, metadata.RawBindings.Count);
+ Assert.Contains("httpTrigger", metadata.RawBindings[0]);
+ Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]);
+ Assert.Contains("\"post\"", metadata.RawBindings[0]);
+ Assert.Contains("http", metadata.RawBindings[1]);
+ Assert.Contains("durableClient", metadata.RawBindings[2]);
+ }
+
+ [Fact]
+ public void CreateHttpTrigger_RespectsCustomMethods()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
+ "status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\"");
+
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Contains("\"get\"", metadata.RawBindings[0]);
+ Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]);
+ }
+
+ [Fact]
+ public void CreateActivityTrigger_SetsCorrectNameAndBindings()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor");
+
+ Assert.Equal("dafx-MyExecutor", metadata.Name);
+ Assert.Equal("dotnet-isolated", metadata.Language);
+ Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint);
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Equal(2, metadata.RawBindings.Count);
+ Assert.Contains("activityTrigger", metadata.RawBindings[0]);
+ Assert.Contains("durableClient", metadata.RawBindings[1]);
+ }
+
+ [Fact]
+ public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger(
+ "dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint);
+
+ Assert.Equal("dafx-MyWorkflow", metadata.Name);
+ Assert.Equal("dotnet-isolated", metadata.Language);
+ Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint);
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Single(metadata.RawBindings);
+ Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]);
+ }
+
+ [Fact]
+ public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text");
+
+ Assert.Equal("mcptool-Translate", metadata.Name);
+ Assert.Equal("dotnet-isolated", metadata.Language);
+ Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint);
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Equal(3, metadata.RawBindings.Count);
+
+ // Verify all bindings are valid JSON
+ foreach (string binding in metadata.RawBindings)
+ {
+ JsonDocument.Parse(binding);
+ }
+
+ // mcpToolTrigger binding
+ Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]);
+ Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]);
+ Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]);
+ Assert.Contains("toolProperties", metadata.RawBindings[0]);
+
+ // mcpToolProperty binding for input
+ Assert.Contains("mcpToolProperty", metadata.RawBindings[1]);
+ Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]);
+ Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]);
+
+ // durableClient binding
+ Assert.Contains("durableClient", metadata.RawBindings[2]);
+ }
+
+ [Fact]
+ public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull()
+ {
+ DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null);
+
+ Assert.NotNull(metadata.RawBindings);
+ Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs
index 3b06bbb772..5e65c4a1a6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs
@@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests
#endregion
+ #region Shared Options Tests
+
+ [Fact]
+ public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
+ {
+ // Arrange: track tool count seen by the inner client on each call
+ var toolCountsSeenByInner = new List();
+
+ var innerClient = CreateMockChatClient(
+ onGetResponse: (_, options, _) =>
+ {
+ toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
+ return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
+ });
+
+ var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
+ var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
+
+ var sharedOptions = new ChatOptions
+ {
+ Tools = new List { new TestAITool() }
+ };
+
+ // Act: make 3 calls reusing the same ChatOptions
+ for (int i = 0; i < 3; i++)
+ {
+ await RunWithAgentContextAsync(chatClient, sharedOptions);
+ }
+
+ // Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
+ Assert.Equal(3, toolCountsSeenByInner.Count);
+ Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
+ {
+ // Arrange
+ var innerClient = CreateMockChatClient(
+ onGetResponse: (_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
+
+ var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
+ var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
+
+ var baselineTool = new TestAITool();
+ var originalTools = new List { baselineTool };
+ var sharedOptions = new ChatOptions
+ {
+ Tools = originalTools
+ };
+
+ // Act
+ await RunWithAgentContextAsync(chatClient, sharedOptions);
+
+ // Assert: the original list should still contain only the baseline tool
+ Assert.Single(originalTools);
+ Assert.Same(baselineTool, originalTools[0]);
+ Assert.Same(originalTools, sharedOptions.Tools);
+ Assert.Same(baselineTool, originalTools[0]);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
+ {
+ // Arrange
+ var toolCountsSeenByInner = new List();
+
+ var innerClient = CreateMockStreamingChatClient(
+ onGetStreamingResponse: (_, options, _) =>
+ {
+ toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
+ return ToAsyncEnumerableAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "Response"));
+ });
+
+ var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
+ var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
+
+ var sharedOptions = new ChatOptions
+ {
+ Tools = new List { new TestAITool() }
+ };
+
+ // Act: make 3 streaming calls reusing the same ChatOptions
+ for (int i = 0; i < 3; i++)
+ {
+ await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
+ }
+
+ // Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
+ Assert.Equal(3, toolCountsSeenByInner.Count);
+ Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
+ {
+ // Arrange
+ var innerClient = CreateMockStreamingChatClient(
+ onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "Response")));
+
+ var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
+ var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
+
+ var baselineTool = new TestAITool();
+ var originalTools = new List { baselineTool };
+ var sharedOptions = new ChatOptions
+ {
+ Tools = originalTools
+ };
+
+ // Act
+ await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
+
+ // Assert: the original list should still contain only the baseline tool
+ Assert.Single(originalTools);
+ Assert.Same(baselineTool, originalTools[0]);
+ }
+
+ #endregion
+
#region Builder Extension Tests
[Fact]
@@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
+ ///
+ /// Runs a chat client within an agent context with the specified options.
+ ///
+ private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
+ {
+ var agent = new TestAIAgent
+ {
+ RunAsyncFunc = async (messages, session, agentOptions, ct) =>
+ {
+ var response = await chatClient.GetResponseAsync(messages, options, ct);
+ return new AgentResponse(response);
+ }
+ };
+
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
+ }
+
+ ///
+ /// Runs a streaming chat client within an agent context with the specified options.
+ ///
+ private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List updates, ChatOptions options)
+ {
+ var agent = new TestAIAgent
+ {
+ RunAsyncFunc = async (messages, session, agentOptions, ct) =>
+ {
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
+ {
+ updates.Add(update);
+ }
+
+ return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
+ }
+ };
+
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
+ }
+
private static IChatClient CreateMockChatClient(
Func, ChatOptions?, CancellationToken, Task> onGetResponse)
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
new file mode 100644
index 0000000000..459859224f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
@@ -0,0 +1,766 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.UnitTests;
+
+///
+/// Contains unit tests for the decorator,
+/// verifying that it persists messages via the after each
+/// individual service call by default, or marks messages for end-of-run persistence when the
+/// option is enabled.
+///
+public class ChatHistoryPersistingChatClientTests
+{
+ ///
+ /// Verifies that by default (PersistChatHistoryAtEndOfRun is false),
+ /// the ChatHistoryProvider receives messages after a successful non-streaming call.
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAtEndOfRun = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called by the decorator (per service call)
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages!.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default),
+ /// the ChatHistoryProvider receives messages at the end of the run.
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAtEndOfRun = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called once by the agent (end of run)
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages!.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default) and the service call fails,
+ /// the ChatHistoryProvider is notified with the exception.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Service failed");
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ThrowsAsync(expectedException);
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAtEndOfRun = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
+
+ // Assert — the decorator should have notified the provider of the failure
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.InvokeException != null &&
+ x.InvokeException.Message == "Service failed"),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that the decorator is injected in persist mode by default
+ /// and can be discovered via GetService.
+ ///
+ [Fact]
+ public void ChatClient_ContainsDecorator_InPersistMode_ByDefault()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new());
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.NotNull(decorator);
+ Assert.False(decorator.MarkOnly);
+ }
+
+ ///
+ /// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true.
+ ///
+ [Fact]
+ public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ PersistChatHistoryAtEndOfRun = true,
+ });
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.NotNull(decorator);
+ Assert.True(decorator.MarkOnly);
+ }
+
+ ///
+ /// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true.
+ ///
+ [Fact]
+ public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ UseProvidedChatClientAsIs = true,
+ });
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.Null(decorator);
+ }
+
+ ///
+ /// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone().
+ ///
+ [Fact]
+ public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun()
+ {
+ // Arrange
+ var options = new ChatClientAgentOptions
+ {
+ PersistChatHistoryAtEndOfRun = true,
+ };
+
+ // Act
+ var cloned = options.Clone();
+
+ // Assert
+ Assert.True(cloned.PersistChatHistoryAtEndOfRun);
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default) and the service call
+ /// involves a function invocation loop, the ChatHistoryProvider is called after each individual
+ /// service call (not just once at the end).
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ // First call returns a tool call
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
+ }
+
+ // Second call returns a final response
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
+ });
+
+ var invokedContexts = new List();
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
+ .Returns(() => new ValueTask());
+
+ // Define a simple tool
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAtEndOfRun = false,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ Exception? caughtException = null;
+ try
+ {
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Diagnostic: check if there was an unexpected exception
+ Assert.Null(caughtException);
+
+ // Assert — the decorator should have been called twice (once per service call in the function invocation loop)
+ Assert.Equal(2, serviceCallCount);
+ Assert.Equal(2, invokedContexts.Count);
+
+ // First invocation should have the user message as request and tool call response
+ Assert.NotNull(invokedContexts[0].ResponseMessages);
+ var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
+ Assert.Contains(firstRequestMessages, m => m.Text == "test");
+ Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType().Any());
+
+ // Second invocation: request messages should NOT include the original user message (already notified).
+ // It should only include messages added since the first call (assistant tool call + tool result).
+ Assert.NotNull(invokedContexts[1].ResponseMessages);
+ var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
+ Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
+ Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default) with streaming,
+ /// the ChatHistoryProvider receives messages after the stream completes.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(CreateAsyncEnumerableAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
+ new ChatResponseUpdate(ChatRole.Assistant, "response")));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAtEndOfRun = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
+ {
+ // Consume stream
+ }
+
+ // Assert — InvokedCoreAsync should be called by the decorator
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages != null),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default),
+ /// AIContextProviders are also notified of new messages after a successful call.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockContextProvider = new(null, null, null);
+ mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
+ mockContextProvider
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask(new AIContext()));
+ mockContextProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockContextProvider.Object],
+ PersistChatHistoryAtEndOfRun = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
+ mockContextProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.ResponseMessages != null &&
+ x.ResponseMessages.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default) and the service fails,
+ /// AIContextProviders are notified of the failure.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Service failed");
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ThrowsAsync(expectedException);
+
+ Mock mockContextProvider = new(null, null, null);
+ mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
+ mockContextProvider
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask(new AIContext()));
+ mockContextProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockContextProvider.Object],
+ PersistChatHistoryAtEndOfRun = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
+
+ // Assert — the decorator should have notified the AIContextProvider of the failure
+ mockContextProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.InvokeException != null &&
+ x.InvokeException.Message == "Service failed"),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when per-service-call persistence is active (default),
+ /// both ChatHistoryProvider and AIContextProviders are notified together.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new(null, null, null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny