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/Directory.Packages.props b/dotnet/Directory.Packages.props
index fa54be567c..ef1a882465 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -70,6 +70,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index af7ca9f0be..19bd84a236 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -57,6 +57,7 @@
+
@@ -452,6 +453,10 @@
+
+
+
+
diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props
index 94ac5b417b..1a3feb4c4f 100644
--- a/dotnet/eng/MSBuild/Shared.props
+++ b/dotnet/eng/MSBuild/Shared.props
@@ -29,4 +29,7 @@
+
+
+
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/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs
index 35baa055d1..6f9f37518e 100644
--- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs
@@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Compliance.Redaction;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -37,7 +38,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
private readonly string _memoryStoreName;
private readonly int _maxMemories;
private readonly int _updateDelay;
- private readonly bool _enableSensitiveTelemetryData;
+ private readonly Redactor _redactor;
private readonly AIProjectClient _client;
private readonly ILogger? _logger;
@@ -79,7 +80,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
this._memoryStoreName = memoryStoreName;
this._maxMemories = effectiveOptions.MaxMemories;
this._updateDelay = effectiveOptions.UpdateDelay;
- this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
+ this._redactor = effectiveOptions.EnableSensitiveTelemetryData ? NullRedactor.Instance : (effectiveOptions.Redactor ?? new ReplacingRedactor(""));
}
///
@@ -416,7 +417,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider
private static bool IsAllowedRole(ChatRole role) =>
role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System;
- private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "";
+ private string SanitizeLogData(string? data) => this._redactor.Redact(data);
///
/// Represents the state of a stored in the .
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs
index 870fe1d271..cf4fb5ab15 100644
--- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Compliance.Redaction;
namespace Microsoft.Agents.AI.FoundryMemory;
@@ -37,8 +38,22 @@ public sealed class FoundryMemoryProviderOptions
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
///
/// Defaults to .
+ ///
+ /// When set to , sensitive data is passed through to logs unchanged and any
+ /// configured is ignored. This property takes precedence over .
+ ///
public bool EnableSensitiveTelemetryData { get; set; }
+ ///
+ /// Gets or sets a custom used to redact sensitive data in log output.
+ ///
+ ///
+ /// When (the default), sensitive data is replaced with a placeholder.
+ /// When set, this redactor is used to transform sensitive values before they are logged.
+ /// Ignored when is .
+ ///
+ public Redactor? Redactor { get; set; }
+
///
/// Gets or sets the key used to store the provider state in the session's .
///
diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj
index a1b8f85ae8..7abc3d0bcc 100644
--- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj
@@ -8,6 +8,7 @@
truetrue
+ truetruetrue
@@ -20,6 +21,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs
index d7c54e2114..8be799ac1a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Compliance.Redaction;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
@@ -51,7 +52,7 @@ public sealed class Mem0Provider : MessageAIContextProvider
private readonly ProviderSessionState _sessionState;
private IReadOnlyList? _stateKeys;
private readonly string _contextPrompt;
- private readonly bool _enableSensitiveTelemetryData;
+ private readonly Redactor _redactor;
private readonly Mem0Client _client;
private readonly ILogger? _logger;
@@ -91,7 +92,7 @@ public sealed class Mem0Provider : MessageAIContextProvider
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
- this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false;
+ this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor(""));
}
///
@@ -297,5 +298,5 @@ public sealed class Mem0Provider : MessageAIContextProvider
public Mem0ProviderScope SearchScope { get; }
}
- private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "";
+ private string SanitizeLogData(string? data) => this._redactor.Redact(data);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs
index 4a3a16712f..2c09bf9a7d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Compliance.Redaction;
namespace Microsoft.Agents.AI.Mem0;
@@ -21,8 +22,22 @@ public sealed class Mem0ProviderOptions
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
///
/// Defaults to .
+ ///
+ /// When set to , sensitive data is passed through to logs unchanged and any
+ /// configured is ignored. This property takes precedence over .
+ ///
public bool EnableSensitiveTelemetryData { get; set; }
+ ///
+ /// Gets or sets a custom used to redact sensitive data in log output.
+ ///
+ ///
+ /// When (the default), sensitive data is replaced with a placeholder.
+ /// When set, this redactor is used to transform sensitive values before they are logged.
+ /// Ignored when is .
+ ///
+ public Redactor? Redactor { get; set; }
+
///
/// Gets or sets the key used to store the provider state in the session's .
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj
index 52bcdda165..9612b3d4b0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj
@@ -6,6 +6,7 @@
true
+ truetrue
@@ -23,6 +24,10 @@
+
+
+
+
Microsoft Agent Framework - Mem0 integration
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/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs
index 6881f7303f..2bc8408a26 100644
--- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs
@@ -7,6 +7,7 @@ using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Compliance.Redaction;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.VectorData;
using Microsoft.Shared.Diagnostics;
@@ -80,7 +81,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
private readonly VectorStoreCollection