mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0f0ec99a | ||
|
|
6185ba2125 | ||
|
|
dcc1eeac36 | ||
|
|
3f2096595f | ||
|
|
45a9da5523 | ||
|
|
6364c05efc | ||
|
|
bbb871e4cd | ||
|
|
7d7b8dd1a4 | ||
|
|
2f51a5ca78 | ||
|
|
ad5749c92a | ||
|
|
ed6b290457 | ||
|
|
63039cb748 | ||
|
|
3e7c94699f | ||
|
|
6320443969 |
@@ -1,166 +0,0 @@
|
||||
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"
|
||||
@@ -41,7 +41,8 @@ ENFORCED_TARGETS: set[str] = {
|
||||
"packages.purview.agent_framework_purview",
|
||||
"packages.anthropic.agent_framework_anthropic",
|
||||
"packages.azure-ai-search.agent_framework_azure_ai_search",
|
||||
"packages.openai.agent_framework_openai",
|
||||
"packages.core.agent_framework.azure",
|
||||
"packages.core.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
|
||||
|
||||
@@ -63,8 +63,6 @@ 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:
|
||||
@@ -83,8 +81,8 @@ jobs:
|
||||
- name: Test with pytest (OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/openai/tests
|
||||
-m "integration and not azure"
|
||||
packages/core/tests/openai
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
@@ -96,9 +94,8 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
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:
|
||||
@@ -124,9 +121,7 @@ jobs:
|
||||
- name: Test with pytest (Azure OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
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
|
||||
packages/core/tests/azure
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -156,13 +151,6 @@ 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
|
||||
@@ -173,26 +161,6 @@ 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:
|
||||
@@ -204,13 +172,10 @@ 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 }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
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"
|
||||
@@ -244,8 +209,7 @@ jobs:
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Azure AI integration tests
|
||||
@@ -257,8 +221,6 @@ 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:
|
||||
@@ -282,9 +244,7 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
|
||||
@@ -47,9 +47,6 @@ 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/**'
|
||||
@@ -57,30 +54,20 @@ jobs:
|
||||
- 'python/packages/core/agent_framework/observability.py'
|
||||
openai:
|
||||
- 'python/packages/core/agent_framework/openai/**'
|
||||
- 'python/packages/openai/**'
|
||||
- 'python/samples/**/providers/openai/**'
|
||||
- 'python/packages/core/tests/openai/**'
|
||||
azure:
|
||||
- 'python/packages/openai/**'
|
||||
- 'python/packages/core/agent_framework/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'
|
||||
- 'python/packages/core/tests/azure/**'
|
||||
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
|
||||
@@ -144,8 +131,6 @@ 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:
|
||||
@@ -161,8 +146,8 @@ jobs:
|
||||
- name: Test with pytest (OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/openai/tests
|
||||
-m "integration and not azure"
|
||||
packages/core/tests/openai
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
@@ -195,9 +180,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
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:
|
||||
@@ -221,9 +205,7 @@ jobs:
|
||||
- name: Test with pytest (Azure OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
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
|
||||
packages/core/tests/azure
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -271,13 +253,6 @@ 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
|
||||
@@ -289,26 +264,6 @@ 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
|
||||
@@ -335,13 +290,10 @@ 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 }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
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"
|
||||
@@ -373,8 +325,7 @@ jobs:
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
@@ -401,8 +352,6 @@ 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:
|
||||
@@ -424,9 +373,7 @@ 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
|
||||
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
|
||||
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
|
||||
working-directory: ./python
|
||||
- name: Test Azure AI samples
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -78,7 +78,6 @@ jobs:
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
# GitHub MCP
|
||||
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
# Observability
|
||||
ENABLE_INSTRUMENTATION: "true"
|
||||
defaults:
|
||||
@@ -347,7 +346,7 @@ jobs:
|
||||
|
||||
validate-02-agents-amazon:
|
||||
name: Validate 02-agents/providers/amazon
|
||||
if: false # Temporarily disabled - requires AWS credentials
|
||||
if: false # Temporarily disabled - requires AWS credentials
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
@@ -379,7 +378,7 @@ jobs:
|
||||
|
||||
validate-02-agents-ollama:
|
||||
name: Validate 02-agents/providers/ollama
|
||||
if: false # Temporarily disabled - requires local Ollama server
|
||||
if: false # Temporarily disabled - requires local Ollama server
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
@@ -411,7 +410,7 @@ jobs:
|
||||
|
||||
validate-02-agents-foundry-local:
|
||||
name: Validate 02-agents/providers/foundry_local
|
||||
if: false # Temporarily disabled - requires local Foundry setup
|
||||
if: false # Temporarily disabled - requires local Foundry setup
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
defaults:
|
||||
@@ -441,7 +440,7 @@ jobs:
|
||||
|
||||
validate-02-agents-copilotstudio:
|
||||
name: Validate 02-agents/providers/copilotstudio
|
||||
if: false # Temporarily disabled - requires Copilot Studio setup
|
||||
if: false # Temporarily disabled - requires Copilot Studio setup
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
@@ -557,7 +556,7 @@ jobs:
|
||||
|
||||
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:
|
||||
@@ -596,7 +595,7 @@ jobs:
|
||||
|
||||
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:
|
||||
@@ -653,7 +652,6 @@ 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
|
||||
@@ -705,7 +703,6 @@ 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 }}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -57,7 +57,6 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -77,8 +76,6 @@
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,226 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@@ -45,7 +45,6 @@ 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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ using Microsoft.Extensions.AI;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concept of workflows as agents, where a workflow can be
|
||||
/// This sample introduces the concepts workflows as agents, where a workflow can be
|
||||
/// treated as an <see cref="AIAgent"/>. This allows you to interact with a workflow
|
||||
/// as if it were a single agent.
|
||||
///
|
||||
@@ -18,14 +18,6 @@ namespace WorkflowAsAnAgentSample;
|
||||
///
|
||||
/// You will interact with the workflow in an interactive loop, sending messages and receiving
|
||||
/// streaming responses from the workflow as if it were an agent who responds in both languages.
|
||||
///
|
||||
/// This sample also demonstrates <see cref="IResettableExecutor"/>, which is required
|
||||
/// for stateful executors that are shared across multiple workflow runs. Each iteration
|
||||
/// of the interactive loop triggers a new workflow run against the same workflow instance.
|
||||
/// Between runs, the framework automatically calls <see cref="IResettableExecutor.ResetAsync"/>
|
||||
/// on shared executors so that accumulated state (e.g., collected messages) is cleared
|
||||
/// before the next run begins. See <c>WorkflowFactory.ConcurrentAggregationExecutor</c>
|
||||
/// for the implementation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
@@ -47,10 +39,7 @@ public static class Program
|
||||
var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent");
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent.
|
||||
// Each iteration runs the workflow again on the same workflow instance. Between runs,
|
||||
// the framework calls IResettableExecutor.ResetAsync() on shared stateful executors
|
||||
// (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run.
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
|
||||
@@ -10,14 +10,6 @@ internal static class WorkflowFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
///
|
||||
/// In this workflow, the <c>Start</c> <see cref="ChatForwardingExecutor"/> and the
|
||||
/// <see cref="ConcurrentAggregationExecutor"/> are provided as shared instances, meaning
|
||||
/// the same executor objects are reused across multiple workflow runs. The language agents
|
||||
/// (French and English) are created via a factory and instantiated per workflow run.
|
||||
/// Stateful shared executors must implement <see cref="IResettableExecutor"/> so the
|
||||
/// framework can clear their state between runs. Framework-provided executors like
|
||||
/// <see cref="ChatForwardingExecutor"/> already implement this interface.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
@@ -48,16 +40,6 @@ internal static class WorkflowFactory
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
///
|
||||
/// This executor is stateful — it accumulates messages in <see cref="_messages"/>
|
||||
/// as they arrive from each agent. Because it is provided as a shared instance
|
||||
/// (not via a factory), the same object is reused across workflow runs. Implementing
|
||||
/// <see cref="IResettableExecutor"/> allows the framework to call <see cref="ResetAsync"/>
|
||||
/// between runs, clearing accumulated state so each run starts fresh.
|
||||
///
|
||||
/// Without <see cref="IResettableExecutor"/>, attempting to reuse a workflow containing
|
||||
/// shared executor instances that do not implement this interface would throw an
|
||||
/// <see cref="InvalidOperationException"/>.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ConcurrentAggregationExecutor() :
|
||||
@@ -83,11 +65,7 @@ internal static class WorkflowFactory
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the executor state between workflow runs by clearing accumulated messages.
|
||||
/// The framework calls this automatically when a workflow run completes, before the
|
||||
/// workflow can be used for another run.
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
this._messages.Clear();
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>WorkflowMcpTool</AssemblyName>
|
||||
<RootNamespace>WorkflowMcpTool</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowMcpTool;
|
||||
|
||||
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
|
||||
{
|
||||
public override ValueTask<TranslationResult> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine($"[Activity] TranslateText: '{message}'");
|
||||
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
TranslationResult message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("[Activity] FormatOutput: Formatting result");
|
||||
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LookupOrder() : Executor<string, OrderInfo>("LookupOrder")
|
||||
{
|
||||
public override ValueTask<OrderInfo> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine($"[Activity] LookupOrder: '{message}'");
|
||||
return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EnrichOrder() : Executor<OrderInfo, OrderSummary>("EnrichOrder")
|
||||
{
|
||||
public override ValueTask<OrderSummary> HandleAsync(
|
||||
OrderInfo message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'");
|
||||
return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TranslationResult(string Original, string Translated);
|
||||
|
||||
internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice);
|
||||
|
||||
internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status);
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool.
|
||||
// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically
|
||||
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific
|
||||
// tool name. MCP-compatible clients can then invoke the workflow as a tool.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using WorkflowMcpTool;
|
||||
|
||||
// Define executors
|
||||
TranslateText translateText = new();
|
||||
FormatOutput formatOutput = new();
|
||||
LookupOrder lookupOrder = new();
|
||||
EnrichOrder enrichOrder = new();
|
||||
|
||||
// Build a simple workflow: TranslateText -> FormatOutput
|
||||
Workflow translateWorkflow = new WorkflowBuilder(translateText)
|
||||
.WithName("Translate")
|
||||
.WithDescription("Translate text to uppercase and format the result")
|
||||
.AddEdge(translateText, formatOutput)
|
||||
.Build();
|
||||
|
||||
// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder
|
||||
Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder)
|
||||
.WithName("OrderLookup")
|
||||
.WithDescription("Look up an order by ID and return enriched order details")
|
||||
.AddEdge(lookupOrder, enrichOrder)
|
||||
.Build();
|
||||
|
||||
using IHost app = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableWorkflows(workflows =>
|
||||
{
|
||||
// Expose both workflows as MCP tool triggers.
|
||||
workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
|
||||
workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
|
||||
})
|
||||
.Build();
|
||||
app.Run();
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
# Workflow as MCP Tool Sample
|
||||
|
||||
This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true`
|
||||
- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp`
|
||||
- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects
|
||||
|
||||
## Sample Architecture
|
||||
|
||||
The sample creates two workflows exposed as MCP tools:
|
||||
|
||||
### Translate Workflow (returns a string)
|
||||
|
||||
| Executor | Input | Output | Description |
|
||||
|----------|-------|--------|-------------|
|
||||
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
|
||||
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
|
||||
|
||||
### OrderLookup Workflow (returns a POCO)
|
||||
|
||||
| Executor | Input | Output | Description |
|
||||
|----------|-------|--------|-------------|
|
||||
| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID |
|
||||
| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
|
||||
|
||||
- Prerequisites installation
|
||||
- Durable Task Scheduler setup
|
||||
- Storage emulator configuration
|
||||
|
||||
For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. **Start the Function App**:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
|
||||
func start
|
||||
```
|
||||
|
||||
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output:
|
||||
|
||||
```text
|
||||
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
|
||||
```
|
||||
|
||||
## Invoking Workflows via MCP Inspector
|
||||
|
||||
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
2. Connect to the MCP server endpoint:
|
||||
- For **Transport Type**, select **"Streamable HTTP"**
|
||||
- For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp`
|
||||
- Click the **Connect** button
|
||||
|
||||
3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`.
|
||||
|
||||
4. Test the **Translate** tool (returns a plain string):
|
||||
- Select the `Translate` tool
|
||||
- Set `hello world` as the `input` parameter
|
||||
- Click **Run Tool**
|
||||
- Expected result: `Original: hello world => Translated: HELLO WORLD`
|
||||
|
||||
5. Test the **OrderLookup** tool (returns a JSON object):
|
||||
- Select the `OrderLookup` tool
|
||||
- Set `ORD-2025-42` as the `input` parameter
|
||||
- Click **Run Tool**
|
||||
- Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status`
|
||||
|
||||
You'll see the workflow executor activities logged in the terminal where you ran `func start`.
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>WorkflowAndAgents</AssemblyName>
|
||||
<RootNamespace>WorkflowAndAgents</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowAndAgents;
|
||||
|
||||
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
|
||||
{
|
||||
public override ValueTask<TranslationResult> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine($"[Activity] TranslateText: '{message}'");
|
||||
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
TranslationResult message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("[Activity] FormatOutput: Formatting result");
|
||||
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TranslationResult(string Original, string Translated);
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows
|
||||
// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent
|
||||
// accessible via HTTP and MCP tool triggers.
|
||||
|
||||
#pragma warning disable IDE0002 // Simplify Member Access
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI.Chat;
|
||||
using WorkflowAndAgents;
|
||||
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
ChatClient chatClient = client.GetChatClient(deploymentName);
|
||||
|
||||
// Define a standalone AI agent
|
||||
AIAgent assistant = chatClient.AsAIAgent(
|
||||
"You are a helpful assistant. Answer questions clearly and concisely.",
|
||||
"Assistant",
|
||||
description: "A general-purpose helpful assistant.");
|
||||
|
||||
// Define workflow executors
|
||||
TranslateText translateText = new();
|
||||
FormatOutput formatOutput = new();
|
||||
|
||||
// Build a workflow: TranslateText -> FormatOutput
|
||||
Workflow translateWorkflow = new WorkflowBuilder(translateText)
|
||||
.WithName("Translate")
|
||||
.WithDescription("Translate text to uppercase and format the result")
|
||||
.AddEdge(translateText, formatOutput)
|
||||
.Build();
|
||||
|
||||
// Use ConfigureDurableOptions to register both agents and workflows together
|
||||
using IHost app = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options =>
|
||||
{
|
||||
// Register the standalone agent with HTTP and MCP tool triggers
|
||||
options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true);
|
||||
|
||||
// Register the workflow with an HTTP endpoint and MCP tool trigger
|
||||
options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
|
||||
})
|
||||
.Build();
|
||||
app.Run();
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
# Workflow and Agents Sample
|
||||
|
||||
This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together
|
||||
- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers
|
||||
- **Workflow**: A simple text translation workflow also exposed as an MCP tool
|
||||
- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host
|
||||
|
||||
## Sample Architecture
|
||||
|
||||
### Standalone Agent
|
||||
|
||||
| Agent | Description |
|
||||
|-------|-------------|
|
||||
| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool |
|
||||
|
||||
### Translate Workflow
|
||||
|
||||
| Executor | Input | Output | Description |
|
||||
|----------|-------|--------|-------------|
|
||||
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
|
||||
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
|
||||
|
||||
- Prerequisites installation
|
||||
- Durable Task Scheduler setup
|
||||
- Storage emulator configuration
|
||||
|
||||
This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`:
|
||||
|
||||
- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL
|
||||
- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name
|
||||
- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. **Start the Function App**:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents
|
||||
func start
|
||||
```
|
||||
|
||||
2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow:
|
||||
|
||||
- `dafx-Assistant` (entity trigger for the agent)
|
||||
- `http-Assistant` (HTTP trigger for the agent)
|
||||
- `mcptool-Assistant` (MCP tool trigger for the agent)
|
||||
- `wf-Translate` (orchestration trigger for the workflow)
|
||||
- `mcptool-wf-Translate` (MCP tool trigger for the workflow)
|
||||
|
||||
## Invoking the Agent via HTTP
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/agents/Assistant/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "What is the capital of France?"}'
|
||||
```
|
||||
|
||||
## Invoking via MCP Inspector
|
||||
|
||||
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport.
|
||||
|
||||
3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool.
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
||||
}
|
||||
}
|
||||
@@ -48,4 +48,3 @@ $env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhos
|
||||
| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions |
|
||||
| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions |
|
||||
| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions |
|
||||
| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool |
|
||||
|
||||
@@ -167,20 +167,6 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
|
||||
{
|
||||
if (mcpToolInvocationContext is null)
|
||||
{
|
||||
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(
|
||||
mcpToolInvocationContext,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ internal static class BuiltInFunctions
|
||||
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
|
||||
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
|
||||
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
|
||||
internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
@@ -379,55 +378,6 @@ internal static class BuiltInFunctions
|
||||
return agentResponse.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow via MCP tool trigger.
|
||||
/// Extracts the <c>input</c> argument, schedules a new orchestration, waits for completion, and returns the output.
|
||||
/// </summary>
|
||||
public static async Task<string?> RunWorkflowMcpToolAsync(
|
||||
[McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
if (context.Arguments is null)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
|
||||
}
|
||||
|
||||
if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
|
||||
}
|
||||
|
||||
string workflowName = context.Name;
|
||||
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
|
||||
DurableWorkflowInput<string> orchestrationInput = new() { Input = input };
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput);
|
||||
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: functionContext.CancellationToken);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' returned no metadata.");
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
|
||||
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' failed: {errorMessage}");
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.");
|
||||
}
|
||||
|
||||
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
## v1.0.0-preview.251219.1
|
||||
|
||||
+21
-9
@@ -6,8 +6,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to agent-specific options for functions agents by name.
|
||||
/// Returns <see langword="false"/> when no explicit options have been configured for an agent,
|
||||
/// which distinguishes standalone agents from those auto-registered by workflows.
|
||||
/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured.
|
||||
/// </summary>
|
||||
internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<string, FunctionsAgentOptions> functionsAgentOptions)
|
||||
: IFunctionsAgentOptionsProvider
|
||||
@@ -15,19 +14,32 @@ internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<s
|
||||
private readonly IReadOnlyDictionary<string, FunctionsAgentOptions> _functionsAgentOptions =
|
||||
functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions));
|
||||
|
||||
// Default options. HTTP trigger enabled, MCP tool disabled.
|
||||
private static readonly FunctionsAgentOptions s_defaultOptions = new()
|
||||
{
|
||||
HttpTrigger = { IsEnabled = true },
|
||||
McpToolTrigger = { IsEnabled = false }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the options associated with the specified agent name.
|
||||
/// Returns <see langword="false"/> when no options have been explicitly configured for the agent.
|
||||
/// If not found, a default options instance (with HTTP trigger enabled) is returned.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent whose options are to be retrieved. Cannot be null or empty.</param>
|
||||
/// <param name="options">
|
||||
/// When this method returns <see langword="true"/>, contains the options for the specified agent;
|
||||
/// otherwise, <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <returns><see langword="true"/> if options were found for the agent; otherwise, <see langword="false"/>.</returns>
|
||||
/// <param name="options">The options for the specified agent. Will never be null.</param>
|
||||
/// <returns>Always true. Returns configured options if present; otherwise default fallback options.</returns>
|
||||
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(agentName);
|
||||
return this._functionsAgentOptions.TryGetValue(agentName, out options);
|
||||
|
||||
if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing))
|
||||
{
|
||||
options = existing;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not defined, return default options.
|
||||
options = s_defaultOptions;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-22
@@ -6,13 +6,9 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Transforms function metadata by registering durable agent functions for each explicitly configured agent.
|
||||
/// Transforms function metadata by registering durable agent functions for each configured agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have
|
||||
/// explicit <see cref="FunctionsAgentOptions"/>. Agents auto-registered by workflows
|
||||
/// (which lack explicit options) are handled by <see cref="DurableWorkflowsFunctionMetadataTransformer"/>.
|
||||
/// </remarks>
|
||||
/// <remarks>This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application.</remarks>
|
||||
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||
{
|
||||
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
|
||||
@@ -42,27 +38,24 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
{
|
||||
string agentName = kvp.Key;
|
||||
|
||||
// Only generate triggers for agents with explicit Functions agent options.
|
||||
// Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer.
|
||||
if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
|
||||
|
||||
original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName));
|
||||
|
||||
if (agentTriggerOptions.HttpTrigger.IsEnabled)
|
||||
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
|
||||
{
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
|
||||
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
|
||||
}
|
||||
if (agentTriggerOptions.HttpTrigger.IsEnabled)
|
||||
{
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
|
||||
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
|
||||
}
|
||||
|
||||
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
|
||||
{
|
||||
AIAgent agent = kvp.Value(this._serviceProvider);
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
|
||||
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
|
||||
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
|
||||
{
|
||||
AIAgent agent = kvp.Value(this._serviceProvider);
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
|
||||
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-13
@@ -134,17 +134,4 @@ public static class DurableAgentsOptionsExtensions
|
||||
{
|
||||
return new Dictionary<string, FunctionsAgentOptions>(s_agentOptions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every agent in <paramref name="agentNames"/> has an entry in the
|
||||
/// options registry. Agents that already have explicit options are left untouched.
|
||||
/// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled).
|
||||
/// </summary>
|
||||
internal static void EnsureDefaultOptionsForAll(IEnumerable<string> agentNames)
|
||||
{
|
||||
foreach (string name in agentNames)
|
||||
{
|
||||
s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
|
||||
@@ -99,65 +98,4 @@ internal static class FunctionMetadataFactory
|
||||
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates function metadata for an MCP tool trigger function that starts a workflow.
|
||||
/// </summary>
|
||||
/// <param name="workflowName">The name of the workflow to expose as an MCP tool.</param>
|
||||
/// <param name="description">An optional description for the MCP tool. If null, a default description is generated.</param>
|
||||
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an MCP tool trigger.</returns>
|
||||
internal static DefaultFunctionMetadata CreateWorkflowMcpToolTrigger(
|
||||
string workflowName,
|
||||
string? description)
|
||||
{
|
||||
var functionName = $"{BuiltInFunctions.McpToolPrefix}{workflowName}";
|
||||
var toolDescription = description ?? $"Run the {workflowName} workflow";
|
||||
|
||||
var toolProperties = new JsonArray(new JsonObject
|
||||
{
|
||||
["propertyName"] = "input",
|
||||
["propertyType"] = "string",
|
||||
["description"] = "The input to the workflow.",
|
||||
["isRequired"] = true,
|
||||
["isArray"] = false,
|
||||
});
|
||||
|
||||
var triggerBinding = new JsonObject
|
||||
{
|
||||
["name"] = "context",
|
||||
["type"] = "mcpToolTrigger",
|
||||
["direction"] = "In",
|
||||
["toolName"] = workflowName,
|
||||
["description"] = toolDescription,
|
||||
["toolProperties"] = toolProperties.ToJsonString(),
|
||||
};
|
||||
|
||||
var inputBinding = new JsonObject
|
||||
{
|
||||
["name"] = "input",
|
||||
["type"] = "mcpToolProperty",
|
||||
["direction"] = "In",
|
||||
["propertyName"] = "input",
|
||||
["description"] = "The input to the workflow",
|
||||
["isRequired"] = true,
|
||||
["dataType"] = "String",
|
||||
["propertyType"] = "string",
|
||||
};
|
||||
|
||||
var clientBinding = new JsonObject
|
||||
{
|
||||
["name"] = "client",
|
||||
["type"] = "durableClient",
|
||||
["direction"] = "In",
|
||||
};
|
||||
|
||||
return new DefaultFunctionMetadata
|
||||
{
|
||||
Name = functionName,
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings = [triggerBinding.ToJsonString(), inputBinding.ToJsonString(), clientBinding.ToJsonString()],
|
||||
EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
|
||||
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-17
@@ -27,16 +27,9 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Create/get shared options BEFORE the DurableTask library call so it can find them.
|
||||
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
|
||||
|
||||
// The main agent services registration is done in Microsoft.DurableTask.Agents.
|
||||
builder.Services.ConfigureDurableAgents(configure);
|
||||
|
||||
// Ensure all agents registered through this path have default FunctionsAgentOptions.
|
||||
// This distinguishes them from agents auto-registered by workflows.
|
||||
DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys);
|
||||
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
|
||||
@@ -74,13 +67,6 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
|
||||
builder.Services.ConfigureDurableOptions(configure);
|
||||
|
||||
if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0)
|
||||
{
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>());
|
||||
}
|
||||
|
||||
if (sharedOptions.Workflows.Workflows.Count > 0)
|
||||
{
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowsFunctionMetadataTransformer>());
|
||||
@@ -116,14 +102,12 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
);
|
||||
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
internal sealed class FunctionsDurableOptions : DurableOptions
|
||||
{
|
||||
private readonly HashSet<string> _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Enables the status HTTP endpoint for the specified workflow.
|
||||
@@ -27,20 +26,4 @@ internal sealed class FunctionsDurableOptions : DurableOptions
|
||||
{
|
||||
return this._statusEndpointWorkflows.Contains(workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables the MCP tool trigger for the specified workflow.
|
||||
/// </summary>
|
||||
internal void EnableMcpToolTrigger(string workflowName)
|
||||
{
|
||||
this._mcpToolTriggerWorkflows.Add(workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the MCP tool trigger is enabled for the specified workflow.
|
||||
/// </summary>
|
||||
internal bool IsMcpToolTriggerEnabled(string workflowName)
|
||||
{
|
||||
return this._mcpToolTriggerWorkflows.Contains(workflowName);
|
||||
}
|
||||
}
|
||||
|
||||
-27
@@ -27,31 +27,4 @@ public static class DurableWorkflowOptionsExtensions
|
||||
functionsOptions.EnableStatusEndpoint(workflow.Name!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger.
|
||||
/// </summary>
|
||||
/// <param name="options">The workflow options to add the workflow to.</param>
|
||||
/// <param name="workflow">The workflow instance to add.</param>
|
||||
/// <param name="exposeStatusEndpoint">If <see langword="true"/>, a GET endpoint is generated at <c>workflows/{name}/status/{runId}</c>.</param>
|
||||
/// <param name="exposeMcpToolTrigger">If <see langword="true"/>, an MCP tool trigger is generated for the workflow.</param>
|
||||
public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint, bool exposeMcpToolTrigger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.AddWorkflow(workflow);
|
||||
|
||||
if (options.ParentOptions is FunctionsDurableOptions functionsOptions)
|
||||
{
|
||||
if (exposeStatusEndpoint)
|
||||
{
|
||||
functionsOptions.EnableStatusEndpoint(workflow.Name!);
|
||||
}
|
||||
|
||||
if (exposeMcpToolTrigger)
|
||||
{
|
||||
functionsOptions.EnableMcpToolTrigger(workflow.Name!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-16
@@ -50,11 +50,8 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
|
||||
int initialCount = original.Count;
|
||||
this._logger.LogTransformingFunctionMetadata(initialCount);
|
||||
|
||||
// Seed with existing function names to avoid duplicates across transformers
|
||||
// (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers).
|
||||
HashSet<string> registeredFunctions = new(
|
||||
original.Select(f => f.Name!),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
// Track registered function names to avoid duplicates when workflows share executors.
|
||||
HashSet<string> registeredFunctions = [];
|
||||
|
||||
DurableWorkflowOptions workflowOptions = this._options.Workflows;
|
||||
foreach (var workflow in workflowOptions.Workflows)
|
||||
@@ -116,17 +113,6 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
|
||||
}
|
||||
}
|
||||
|
||||
// Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true).
|
||||
if (this._options.IsMcpToolTriggerEnabled(workflow.Key))
|
||||
{
|
||||
string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}";
|
||||
if (registeredFunctions.Add(mcpToolFunctionName))
|
||||
{
|
||||
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool");
|
||||
original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description));
|
||||
}
|
||||
}
|
||||
|
||||
// Register activity or entity functions for each executor in the workflow.
|
||||
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
|
||||
|
||||
@@ -38,7 +38,6 @@ internal static class SourceBuilder
|
||||
sb.AppendLine("using System.Collections.Generic;");
|
||||
sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;");
|
||||
|
||||
// Namespace
|
||||
if (!string.IsNullOrWhiteSpace(info.Namespace))
|
||||
|
||||
+8
-25
@@ -5,14 +5,11 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
internal record CheckpointFileIndexEntry(CheckpointInfo CheckpointInfo, string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a file system-based implementation of a JSON checkpoint store that persists checkpoint data and index
|
||||
/// information to disk using JSON files.
|
||||
@@ -31,8 +28,6 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
internal DirectoryInfo Directory { get; }
|
||||
internal HashSet<CheckpointInfo> CheckpointIndex { get; }
|
||||
|
||||
private static JsonTypeInfo<CheckpointFileIndexEntry> EntryTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointFileIndexEntry;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileSystemJsonCheckpointStore"/> class that uses the specified directory
|
||||
/// </summary>
|
||||
@@ -69,11 +64,9 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true);
|
||||
while (reader.ReadLine() is string line)
|
||||
{
|
||||
if (JsonSerializer.Deserialize(line, EntryTypeInfo) is { } entry)
|
||||
if (JsonSerializer.Deserialize(line, KeyTypeInfo) is { } info)
|
||||
{
|
||||
// We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to
|
||||
// have the UrlEncoded file names in the index file for human readability
|
||||
this.CheckpointIndex.Add(entry.CheckpointInfo);
|
||||
this.CheckpointIndex.Add(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,14 +93,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
}
|
||||
}
|
||||
|
||||
internal string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
|
||||
{
|
||||
string protoPath = $"{sessionId}_{key.CheckpointId}.json";
|
||||
|
||||
// Escape the protoPath to ensure it is a valid file name, especially if sessionId or CheckpointId contain path separators, etc.
|
||||
return Uri.EscapeDataString(protoPath) // This takes care of most of the invalid path characters
|
||||
.Replace(".", "%2E"); // This takes care of escaping the root folder, since EscapeDataString does not escape dots
|
||||
}
|
||||
private string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
|
||||
=> Path.Combine(this.Directory.FullName, $"{sessionId}_{key.CheckpointId}.json");
|
||||
|
||||
private CheckpointInfo GetUnusedCheckpointInfo(string sessionId)
|
||||
{
|
||||
@@ -129,16 +116,13 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
|
||||
CheckpointInfo key = this.GetUnusedCheckpointInfo(sessionId);
|
||||
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
|
||||
string filePath = Path.Combine(this.Directory.FullName, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
using Stream checkpointStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
using Stream checkpointStream = File.Open(fileName, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
using Utf8JsonWriter jsonWriter = new(checkpointStream, new JsonWriterOptions() { Indented = false });
|
||||
value.WriteTo(jsonWriter);
|
||||
|
||||
CheckpointFileIndexEntry entry = new(key, fileName);
|
||||
JsonSerializer.Serialize(this._indexFile!, entry, EntryTypeInfo);
|
||||
JsonSerializer.Serialize(this._indexFile!, key, KeyTypeInfo);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(Environment.NewLine);
|
||||
await this._indexFile!.WriteAsync(bytes, 0, bytes.Length, CancellationToken.None).ConfigureAwait(false);
|
||||
await this._indexFile!.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -152,7 +136,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
try
|
||||
{
|
||||
// try to clean up after ourselves
|
||||
File.Delete(filePath);
|
||||
File.Delete(fileName);
|
||||
}
|
||||
catch { }
|
||||
|
||||
@@ -165,7 +149,6 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
this.CheckDisposed();
|
||||
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
|
||||
string filePath = Path.Combine(this.Directory.FullName, fileName);
|
||||
|
||||
if (!this.CheckpointIndex.Contains(key) ||
|
||||
!File.Exists(fileName))
|
||||
@@ -173,7 +156,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'.");
|
||||
}
|
||||
|
||||
using FileStream checkpointFileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using FileStream checkpointFileStream = File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(checkpointFileStream).ConfigureAwait(false);
|
||||
|
||||
return document.RootElement.Clone();
|
||||
|
||||
@@ -21,15 +21,6 @@ internal interface ICheckpointingHandle
|
||||
/// <summary>
|
||||
/// Restores the system state from the specified checkpoint asynchronously.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This contract is used by live runtime restore paths. Implementations may re-emit pending
|
||||
/// external request events as part of the restore once the active event stream is ready to
|
||||
/// observe them.
|
||||
///
|
||||
/// Initial resume paths that create a new event stream should restore state first and defer
|
||||
/// any replay until after the subscriber is attached, rather than calling this contract
|
||||
/// directly before the stream is ready.
|
||||
/// </remarks>
|
||||
/// <param name="checkpointInfo">The checkpoint information that identifies the state to restore. Cannot be null.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the restore operation.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous restore operation.</returns>
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// Represents a configuration for an object with a string identifier. For example, <see cref="IIdentified"/> object.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the configurable object.</param>
|
||||
public class ExecutorConfig(string id)
|
||||
public class Config(string id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a unique identifier for the configurable object.
|
||||
@@ -23,7 +23,7 @@ public class ExecutorConfig(string id)
|
||||
/// <typeparam name="TOptions">The type of options for the configurable object.</typeparam>
|
||||
/// <param name="id">A unique identifier for the configurable object.</param>
|
||||
/// <param name="options">The options for the configurable object.</param>
|
||||
public class ExecutorConfig<TOptions>(string id, TOptions? options = default) : ExecutorConfig(id)
|
||||
public class Config<TOptions>(string id, TOptions? options = default) : Config(id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the options for the configured object.
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for creating <see cref="Configured{TSubject}"/> objects
|
||||
/// Provides extensions methods for creating <see cref="Configured{TSubject}"/> objects
|
||||
/// </summary>
|
||||
internal static class ConfigurationExtensions
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <summary>
|
||||
/// Provides methods for creating <see cref="Configured{TSubject}"/> instances.
|
||||
/// </summary>
|
||||
internal static class Configured
|
||||
public static class Configured
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Configured{TSubject}"/> instance from an existing subject instance.
|
||||
@@ -50,10 +50,10 @@ internal static class Configured
|
||||
/// A representation of a preconfigured, lazy-instantiatable instance of <typeparamref name="TSubject"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
|
||||
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
|
||||
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
|
||||
/// <param name="id">The unique identifier for the configured subject.</param>
|
||||
/// <param name="raw"></param>
|
||||
internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
|
||||
public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the raw representation of the configured object, if any.
|
||||
@@ -66,14 +66,14 @@ internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubj
|
||||
public string Id => id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig"/>.
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config"/>.
|
||||
/// </summary>
|
||||
public Func<ExecutorConfig, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
public Func<Config, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration for this configured instance.
|
||||
/// </summary>
|
||||
public ExecutorConfig Configuration => new(this.Id);
|
||||
public Config Configuration => new(this.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
|
||||
@@ -87,11 +87,11 @@ internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubj
|
||||
/// </summary>
|
||||
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
|
||||
/// <typeparam name="TOptions">The type of configuration options for the preconfigured subject.</typeparam>
|
||||
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
|
||||
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
|
||||
/// <param name="id">The unique identifier for the configured subject.</param>
|
||||
/// <param name="options">Additional configuration options for the subject.</param>
|
||||
/// <param name="raw"></param>
|
||||
internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
|
||||
public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw representation of the configured object, if any.
|
||||
@@ -109,14 +109,14 @@ internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, str
|
||||
public TOptions? Options => options;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig{TOptions}"/>.
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config{TOptions}"/>.
|
||||
/// </summary>
|
||||
public Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
public Func<Config<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration for this configured instance.
|
||||
/// </summary>
|
||||
public ExecutorConfig<TOptions> Configuration => new(this.Id, this.Options);
|
||||
public Config<TOptions> Configuration => new(this.Id, this.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
|
||||
@@ -124,11 +124,11 @@ internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, str
|
||||
/// </summary>
|
||||
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
|
||||
|
||||
private Func<ExecutorConfig, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
|
||||
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
|
||||
{
|
||||
return FactoryAsync;
|
||||
|
||||
async ValueTask<TSubject> FactoryAsync(ExecutorConfig configuration, string sessionId)
|
||||
async ValueTask<TSubject> FactoryAsync(Config configuration, string sessionId)
|
||||
{
|
||||
if (this.Id != configuration.Id)
|
||||
{
|
||||
|
||||
@@ -36,10 +36,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
|
||||
|
||||
this._eventStream.Start();
|
||||
|
||||
// If there are already unprocessed messages or unserviced requests (e.g., from a
|
||||
// checkpoint restore that happened before this handle was created), signal the run
|
||||
// loop to start processing them
|
||||
if (stepRunner.HasUnprocessedMessages || stepRunner.HasUnservicedRequests)
|
||||
// If there are already unprocessed messages (e.g., from a checkpoint restore that happened
|
||||
// before this handle was created), signal the run loop to start processing them
|
||||
if (stepRunner.HasUnprocessedMessages)
|
||||
{
|
||||
this.SignalInputToRunLoop();
|
||||
}
|
||||
@@ -54,9 +53,6 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
|
||||
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> this._eventStream.GetStatusAsync(cancellationToken);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
//Debug.Assert(breakOnHalt);
|
||||
@@ -193,17 +189,13 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
|
||||
{
|
||||
streamingEventStream.ClearBufferedEvents();
|
||||
}
|
||||
else if (this._eventStream is LockstepRunEventStream lockstepEventStream)
|
||||
{
|
||||
lockstepEventStream.ClearBufferedEvents();
|
||||
}
|
||||
|
||||
// Restore the workflow state through the live runtime-restore path.
|
||||
// This can re-emit pending requests into the already-active event stream.
|
||||
// Restore the workflow state - this will republish unserviced requests as new events
|
||||
await this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// After restore, signal the run loop to process any restored messages. Initial resume
|
||||
// paths handle this separately when they create the event stream after restoring state.
|
||||
// After restore, signal the run loop to process any restored messages
|
||||
// This is necessary because ClearBufferedEvents() doesn't signal, and the restored
|
||||
// queued messages won't automatically wake up the run loop
|
||||
this.SignalInputToRunLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -96,18 +95,6 @@ internal sealed class EdgeMap
|
||||
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
{
|
||||
if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner))
|
||||
{
|
||||
executorId = portRunner.ExecutorId;
|
||||
return true;
|
||||
}
|
||||
|
||||
executorId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
|
||||
{
|
||||
Dictionary<EdgeId, PortableValue> exportedStates = [];
|
||||
|
||||
@@ -19,7 +19,6 @@ internal interface ISuperStepRunner
|
||||
bool HasUnprocessedMessages { get; }
|
||||
|
||||
ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
|
||||
bool TryGetResponsePortExecutorId(string portId, out string? executorId);
|
||||
|
||||
ValueTask<bool> IsValidInputTypeAsync<T>(CancellationToken cancellationToken = default);
|
||||
ValueTask<bool> EnqueueMessageAsync<T>(T message, CancellationToken cancellationToken = default);
|
||||
@@ -27,14 +26,6 @@ internal interface ISuperStepRunner
|
||||
|
||||
ConcurrentEventSink OutgoingEvents { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Re-emits <see cref="RequestInfoEvent"/>s for any pending external requests.
|
||||
/// Called by event streams after subscribing to <see cref="OutgoingEvents"/> so that
|
||||
/// requests restored from a checkpoint are observable even when the restore happened
|
||||
/// before the subscription was active.
|
||||
/// </summary>
|
||||
ValueTask RepublishPendingEventsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<bool> RunSuperStepAsync(CancellationToken cancellationToken);
|
||||
|
||||
// This cannot be cancelled
|
||||
|
||||
@@ -15,7 +15,6 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
{
|
||||
private readonly CancellationTokenSource _stopCancellation = new();
|
||||
private readonly InputWaiter _inputWaiter = new();
|
||||
private ConcurrentQueue<WorkflowEvent> _eventSink = new();
|
||||
private int _isDisposed;
|
||||
|
||||
private readonly ISuperStepRunner _stepRunner;
|
||||
@@ -36,8 +35,6 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
// doesn't leak into caller code via AsyncLocal.
|
||||
Activity? previousActivity = Activity.Current;
|
||||
|
||||
this._stepRunner.OutgoingEvents.EventRaised += this.OnWorkflowEventAsync;
|
||||
|
||||
this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
@@ -59,6 +56,10 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
|
||||
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
|
||||
|
||||
ConcurrentQueue<WorkflowEvent> eventSink = [];
|
||||
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
|
||||
|
||||
// Re-establish session as parent so the run activity nests correctly.
|
||||
Activity.Current = this._sessionActivity;
|
||||
|
||||
@@ -72,31 +73,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
// Emit WorkflowStartedEvent to the event stream for consumers
|
||||
this._eventSink.Enqueue(new WorkflowStartedEvent());
|
||||
|
||||
// Re-emit any pending external requests that were restored from a checkpoint
|
||||
// before this subscription was active. For non-resume starts this is a no-op.
|
||||
// This runs after WorkflowStartedEvent so consumers always see the started event first.
|
||||
await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// When resuming from a checkpoint with only pending requests (no queued messages),
|
||||
// the inner processing loop won't execute, so we must drain events now.
|
||||
// For normal starts this is a no-op since the inner loop handles the drain.
|
||||
if (!this._stepRunner.HasUnprocessedMessages)
|
||||
{
|
||||
var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents();
|
||||
foreach (WorkflowEvent raisedEvent in drainedEvents)
|
||||
{
|
||||
yield return raisedEvent;
|
||||
}
|
||||
|
||||
if (shouldHalt)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
|
||||
}
|
||||
eventSink.Enqueue(new WorkflowStartedEvent());
|
||||
|
||||
do
|
||||
{
|
||||
@@ -130,19 +107,26 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
yield break; // Exit if cancellation is requested
|
||||
}
|
||||
|
||||
var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents();
|
||||
|
||||
foreach (WorkflowEvent raisedEvent in drainedEvents)
|
||||
bool hadRequestHaltEvent = false;
|
||||
foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, []))
|
||||
{
|
||||
if (linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
yield break; // Exit if cancellation is requested
|
||||
}
|
||||
|
||||
yield return raisedEvent;
|
||||
// TODO: Do we actually want to interpret this as a termination request?
|
||||
if (raisedEvent is RequestHaltEvent)
|
||||
{
|
||||
hadRequestHaltEvent = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return raisedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldHalt || linkedSource.Token.IsCancellationRequested)
|
||||
if (hadRequestHaltEvent || linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// If we had a completion event, we are done.
|
||||
yield break;
|
||||
@@ -167,23 +151,25 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
finally
|
||||
{
|
||||
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
|
||||
this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
|
||||
|
||||
// Explicitly dispose the Activity so Activity.Stop fires deterministically,
|
||||
// regardless of how the async iterator enumerator is disposed.
|
||||
runActivity?.Dispose();
|
||||
}
|
||||
|
||||
ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
|
||||
{
|
||||
eventSink.Enqueue(e);
|
||||
return default;
|
||||
}
|
||||
|
||||
// If we are Idle or Ended, we should break out of the loop
|
||||
// If we are PendingRequests and not blocking on pending requests, we should break out of the loop
|
||||
// If cancellation is requested, we should break out of the loop
|
||||
bool ShouldBreak() => this.RunStatus is RunStatus.Idle or RunStatus.Ended ||
|
||||
(this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) ||
|
||||
linkedSource.Token.IsCancellationRequested;
|
||||
}
|
||||
|
||||
internal void ClearBufferedEvents()
|
||||
{
|
||||
Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue<WorkflowEvent>());
|
||||
(this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) ||
|
||||
linkedSource.Token.IsCancellationRequested;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -206,7 +192,6 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
if (Interlocked.Exchange(ref this._isDisposed, 1) == 0)
|
||||
{
|
||||
this._stopCancellation.Cancel();
|
||||
this._stepRunner.OutgoingEvents.EventRaised -= this.OnWorkflowEventAsync;
|
||||
|
||||
// Stop the session activity
|
||||
if (this._sessionActivity is not null)
|
||||
@@ -222,32 +207,4 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
|
||||
{
|
||||
this._eventSink.Enqueue(e);
|
||||
return default;
|
||||
}
|
||||
|
||||
// Atomically drains the event sink and separates workflow events from halt signals.
|
||||
// Used by both the early-drain (resume with pending requests only) and
|
||||
// the inner superstep drain to keep halt-detection logic in one place.
|
||||
private (List<WorkflowEvent> Events, bool ShouldHalt) DrainAndFilterEvents()
|
||||
{
|
||||
List<WorkflowEvent> events = [];
|
||||
bool shouldHalt = false;
|
||||
foreach (WorkflowEvent e in Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue<WorkflowEvent>()))
|
||||
{
|
||||
if (e is RequestHaltEvent)
|
||||
{
|
||||
shouldHalt = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
events.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (events, shouldHalt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +60,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Subscribe to events - they will flow directly to the channel as they're raised
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
|
||||
|
||||
// Re-emit any pending external requests that were restored from a checkpoint
|
||||
// before this subscription was active. For non-resume starts this is a no-op.
|
||||
await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Start the session-level activity that spans the entire run loop lifetime.
|
||||
// Individual run-stage activities are nested within this session activity.
|
||||
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
|
||||
@@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
@@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
=> factoryAsync.BindExecutor(id, options);
|
||||
|
||||
@@ -50,13 +50,10 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen
|
||||
return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken);
|
||||
}
|
||||
|
||||
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken = default)
|
||||
=> this.ResumeRunAsync(workflow, fromCheckpoint, knownValidInputTypes, republishPendingEvents: true, cancellationToken);
|
||||
|
||||
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, bool republishPendingEvents, CancellationToken cancellationToken = default)
|
||||
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
|
||||
{
|
||||
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, this.CheckpointManager, fromCheckpoint.SessionId, this.EnableConcurrentRuns, knownValidInputTypes);
|
||||
return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, republishPendingEvents, cancellationToken);
|
||||
return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -107,32 +104,6 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen
|
||||
return new(runHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a streaming workflow run from a checkpoint with control over whether
|
||||
/// pending request events are republished through the event stream.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow to resume.</param>
|
||||
/// <param name="fromCheckpoint">The checkpoint to resume from.</param>
|
||||
/// <param name="republishPendingEvents">
|
||||
/// When <see langword="true"/>, any pending request events are republished through the event
|
||||
/// stream after subscribing. When <see langword="false"/>, the caller is responsible for
|
||||
/// handling pending requests (e.g., <see cref="WorkflowSession"/> already sends responses).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
internal async ValueTask<StreamingRun> ResumeStreamingInternalAsync(
|
||||
Workflow workflow,
|
||||
CheckpointInfo fromCheckpoint,
|
||||
bool republishPendingEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.VerifyCheckpointingConfigured();
|
||||
|
||||
AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], republishPendingEvents, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new(runHandle);
|
||||
}
|
||||
|
||||
private async ValueTask<AsyncRunHandle> BeginRunHandlingChatProtocolAsync<TInput>(Workflow workflow,
|
||||
TInput input,
|
||||
string? sessionId = null,
|
||||
|
||||
@@ -71,28 +71,6 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
/// <inheritdoc cref="ISuperStepRunner.StartExecutorId"/>
|
||||
public string StartExecutorId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gating flag for deferred event republishing after checkpoint restore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written with <see cref="Volatile.Write(ref int, int)"/> in <see cref="ResumeStreamAsync(ExecutionMode, CheckpointInfo, bool, CancellationToken)"/>
|
||||
/// and consumed atomically with <see cref="Interlocked.Exchange(ref int, int)"/> in
|
||||
/// <see cref="ISuperStepRunner.RepublishPendingEventsAsync"/>. The write does not need a full
|
||||
/// memory barrier because it is sequenced before the <see cref="AsyncRunHandle"/> constructor
|
||||
/// by the <see langword="await"/> in <see cref="ResumeStreamAsync(ExecutionMode, CheckpointInfo, bool, CancellationToken)"/>. The constructor is the
|
||||
/// only code path that triggers consumption (via the event stream's subscribe and republish flow).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note: <see cref="AsyncRunHandle"/> also reads <see cref="ISuperStepRunner.HasUnservicedRequests"/>
|
||||
/// in its constructor to signal the run loop, but that property reads from
|
||||
/// <see cref="InProcessRunnerContext"/>'s request dictionary (restored during
|
||||
/// <see cref="RestoreCheckpointCoreAsync"/>), not from this flag. The two are independent:
|
||||
/// <c>HasUnservicedRequests</c> triggers the run loop; <c>_needsRepublish</c> triggers event emission.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private int _needsRepublish;
|
||||
|
||||
/// <inheritdoc cref="ISuperStepRunner.TelemetryContext"/>
|
||||
public WorkflowTelemetryContext TelemetryContext => this.Workflow.TelemetryContext;
|
||||
|
||||
@@ -167,10 +145,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
return new(new AsyncRunHandle(this, this, mode));
|
||||
}
|
||||
|
||||
public ValueTask<AsyncRunHandle> ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default)
|
||||
=> this.ResumeStreamAsync(mode, fromCheckpoint, republishPendingEvents: true, cancellationToken);
|
||||
|
||||
public async ValueTask<AsyncRunHandle> ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, bool republishPendingEvents, CancellationToken cancellationToken = default)
|
||||
public async ValueTask<AsyncRunHandle> ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.RunContext.CheckEnded();
|
||||
Throw.IfNull(fromCheckpoint);
|
||||
@@ -179,35 +154,12 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints.");
|
||||
}
|
||||
|
||||
// Restore checkpoint state without republishing pending request events.
|
||||
// The event stream will republish them after subscribing so that events
|
||||
// are never lost to an absent subscriber.
|
||||
await this.RestoreCheckpointCoreAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (republishPendingEvents)
|
||||
{
|
||||
// Signal the event stream to republish pending requests after subscribing.
|
||||
// This is consumed atomically by RepublishPendingEventsAsync.
|
||||
Volatile.Write(ref this._needsRepublish, 1);
|
||||
}
|
||||
|
||||
await this.RestoreCheckpointAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false);
|
||||
return new AsyncRunHandle(this, this, mode);
|
||||
}
|
||||
|
||||
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
|
||||
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
|
||||
bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this.RunContext.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
ValueTask ISuperStepRunner.RepublishPendingEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref this._needsRepublish, 0) != 0)
|
||||
{
|
||||
return this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled;
|
||||
|
||||
@@ -356,31 +308,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
this._checkpoints.Add(this._lastCheckpointInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores checkpoint state and re-emits any pending external request events.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the <see cref="ICheckpointingHandle"/> implementation used for runtime restores
|
||||
/// where the event stream subscription is already active. For initial resumes,
|
||||
/// <see cref="ResumeStreamAsync(ExecutionMode, CheckpointInfo, CancellationToken)"/> calls
|
||||
/// <see cref="RestoreCheckpointCoreAsync"/> directly and defers republishing to the event stream.
|
||||
/// </remarks>
|
||||
public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this.RestoreCheckpointCoreAsync(checkpointInfo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Republish pending request events. This is safe for runtime restores where
|
||||
// the event stream is already subscribed. For initial resumes the event stream
|
||||
// handles republishing itself, so ResumeStreamAsync calls RestoreCheckpointCoreAsync directly.
|
||||
await this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores checkpoint state (queued messages, executor state, edge state, etc.)
|
||||
/// without republishing pending request events. The caller is responsible for
|
||||
/// ensuring events are republished after an event subscriber is attached.
|
||||
/// </summary>
|
||||
private async ValueTask RestoreCheckpointCoreAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.RunContext.CheckEnded();
|
||||
Throw.IfNull(checkpointInfo);
|
||||
@@ -405,9 +333,11 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
await this.RunContext.ImportStateAsync(checkpoint).ConfigureAwait(false);
|
||||
|
||||
Task executorNotifyTask = this.RunContext.NotifyCheckpointLoadedAsync(cancellationToken);
|
||||
ValueTask republishRequestsTask = this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken);
|
||||
|
||||
await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false);
|
||||
await Task.WhenAll(executorNotifyTask,
|
||||
republishRequestsTask.AsTask(),
|
||||
restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false);
|
||||
|
||||
this._lastCheckpointInfo = checkpointInfo;
|
||||
|
||||
@@ -296,9 +296,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
return this._externalRequests.TryRemove(requestId, out _);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
=> this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
private IEventSink OutgoingEvents { get; }
|
||||
|
||||
internal StateManager StateManager { get; } = new();
|
||||
|
||||
@@ -68,17 +68,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
|
||||
}
|
||||
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
|
||||
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleFunctionResultAsync(
|
||||
@@ -91,17 +84,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
|
||||
}
|
||||
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
|
||||
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
public bool ShouldEmitStreamingEvents(bool? emitEvents)
|
||||
@@ -214,7 +198,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
|
||||
}
|
||||
|
||||
if (this._options.EmitAgentResponseEvents)
|
||||
if (this._options.EmitAgentResponseEvents == true)
|
||||
{
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
where TResponseContent : AIContent
|
||||
{
|
||||
private readonly PortBinding? _portBinding;
|
||||
private readonly string _portId;
|
||||
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
|
||||
|
||||
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
this._portId = portId;
|
||||
PortBinding? portBinding = null;
|
||||
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
|
||||
this._portBinding = portBinding;
|
||||
@@ -60,14 +58,12 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
{
|
||||
if (!this._pendingRequests.TryAdd(id, requestContent))
|
||||
{
|
||||
// Request is already pending; treat as an idempotent re-emission.
|
||||
// Do not repost to the sink because request IDs must remain unique while pending.
|
||||
return default;
|
||||
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
|
||||
}
|
||||
|
||||
return this.IsIntercepted
|
||||
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
|
||||
: this._portBinding.PostRequestAsync(requestContent, this.CreateExternalRequestId(id), cancellationToken);
|
||||
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
|
||||
}
|
||||
|
||||
public bool MarkRequestAsHandled(string id)
|
||||
@@ -78,8 +74,6 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
[MemberNotNullWhen(false, nameof(_portBinding))]
|
||||
private bool IsIntercepted => this._portBinding == null;
|
||||
|
||||
private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}";
|
||||
|
||||
private static string MakeKey(string id) => $"{id}_PendingRequests";
|
||||
|
||||
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -14,7 +14,6 @@ internal sealed class RequestPortOptions;
|
||||
|
||||
internal sealed class RequestInfoExecutor : Executor
|
||||
{
|
||||
private const string WrappedRequestsStateKey = nameof(WrappedRequestsStateKey);
|
||||
private readonly Dictionary<string, ExternalRequest> _wrappedRequests = [];
|
||||
private RequestPort Port { get; }
|
||||
private IExternalRequestSink? RequestSink { get; set; }
|
||||
@@ -125,46 +124,22 @@ internal sealed class RequestInfoExecutor : Executor
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
|
||||
{
|
||||
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!message.Data.IsType(this.Port.Response, out object? data))
|
||||
{
|
||||
throw this.Port.CreateExceptionForType(message);
|
||||
}
|
||||
|
||||
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
|
||||
{
|
||||
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this._wrappedRequests.Remove(message.RequestId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(WrappedRequestsStateKey,
|
||||
new Dictionary<string, ExternalRequest>(this._wrappedRequests, StringComparer.Ordinal),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._wrappedRequests.Clear();
|
||||
|
||||
Dictionary<string, ExternalRequest> wrappedRequests =
|
||||
await context.ReadStateAsync<Dictionary<string, ExternalRequest>>(WrappedRequestsStateKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false) ?? [];
|
||||
|
||||
foreach (KeyValuePair<string, ExternalRequest> wrappedRequest in wrappedRequests)
|
||||
{
|
||||
this._wrappedRequests[wrappedRequest.Key] = wrappedRequest.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
@@ -24,7 +23,6 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
private InProcessRunner? _activeRunner;
|
||||
private InMemoryCheckpointManager? _checkpointManager;
|
||||
private readonly ExecutorOptions _options;
|
||||
private readonly ConcurrentDictionary<string, RequestPortInfo> _pendingResponsePorts = new(StringComparer.Ordinal);
|
||||
|
||||
private ISuperStepJoinContext? _joinContext;
|
||||
private string? _joinId;
|
||||
@@ -165,11 +163,6 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
|
||||
private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response)
|
||||
{
|
||||
if (this._pendingResponsePorts.TryRemove(response.RequestId, out RequestPortInfo? originalPort))
|
||||
{
|
||||
return response with { PortInfo = originalPort };
|
||||
}
|
||||
|
||||
if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
@@ -200,7 +193,6 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
break;
|
||||
case RequestInfoEvent requestInfoEvt:
|
||||
ExternalRequest request = requestInfoEvt.Request;
|
||||
this._pendingResponsePorts[request.RequestId] = request.PortInfo;
|
||||
resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask;
|
||||
break;
|
||||
case WorkflowErrorEvent errorEvent:
|
||||
@@ -254,13 +246,9 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
}
|
||||
|
||||
private const string CheckpointManagerStateKey = nameof(CheckpointManager);
|
||||
private const string PendingResponsePortsStateKey = nameof(PendingResponsePortsStateKey);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(CheckpointManagerStateKey, this._checkpointManager, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(PendingResponsePortsStateKey,
|
||||
new Dictionary<string, RequestPortInfo>(this._pendingResponsePorts, StringComparer.Ordinal),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -281,15 +269,6 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
await this.ResetAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._pendingResponsePorts.Clear();
|
||||
Dictionary<string, RequestPortInfo> pendingResponsePorts =
|
||||
await context.ReadStateAsync<Dictionary<string, RequestPortInfo>>(PendingResponsePortsStateKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false) ?? [];
|
||||
foreach (KeyValuePair<string, RequestPortInfo> pendingResponsePort in pendingResponsePorts)
|
||||
{
|
||||
this._pendingResponsePorts[pendingResponsePort.Key] = pendingResponsePort.Value;
|
||||
}
|
||||
|
||||
await this.EnsureRunSendMessageAsync(resume: true, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -301,8 +280,6 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
this._run = null;
|
||||
}
|
||||
|
||||
this._pendingResponsePorts.Clear();
|
||||
|
||||
if (this._activeRunner != null)
|
||||
{
|
||||
this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync;
|
||||
|
||||
@@ -60,9 +60,6 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
|
||||
internal ValueTask<bool> TrySendMessageUntypedAsync(object message, Type? declaredType = null)
|
||||
=> this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._runHandle.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,38 +19,12 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
internal sealed class WorkflowSession : AgentSession
|
||||
{
|
||||
private readonly Workflow _workflow;
|
||||
|
||||
/// <summary>
|
||||
/// The execution environment for this session. Concrete type is required because
|
||||
/// <see cref="CreateOrResumeRunAsync"/> uses the internal
|
||||
/// <see cref="InProcessExecutionEnvironment.ResumeStreamingInternalAsync"/> API.
|
||||
/// </summary>
|
||||
private readonly InProcessExecutionEnvironment _inProcEnvironment;
|
||||
|
||||
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
|
||||
private readonly bool _includeExceptionDetails;
|
||||
private readonly bool _includeWorkflowOutputsInResponse;
|
||||
|
||||
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending external requests by their workflow-facing request ID.
|
||||
/// This mapping enables converting incoming response content back to <see cref="ExternalResponse"/>
|
||||
/// when resuming a workflow from a checkpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entries are added when a <see cref="RequestInfoEvent"/> is received during workflow execution,
|
||||
/// and removed when a matching response is delivered via <see cref="SendMessagesWithResponseConversionAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The number of entries is bounded by the number of outstanding external requests in a single workflow run.
|
||||
/// When a session is abandoned, all pending requests are released with the session object.
|
||||
/// Request-level timeouts, if needed, should be implemented in the workflow definition itself
|
||||
/// (e.g., using a timer racing against an external event).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly Dictionary<string, ExternalRequest> _pendingRequests = [];
|
||||
|
||||
internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv)
|
||||
{
|
||||
inProcEnv = null;
|
||||
@@ -70,22 +44,17 @@ internal sealed class WorkflowSession : AgentSession
|
||||
public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
|
||||
{
|
||||
this._workflow = Throw.IfNull(workflow);
|
||||
this._executionEnvironment = Throw.IfNull(executionEnvironment);
|
||||
this._includeExceptionDetails = includeExceptionDetails;
|
||||
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
|
||||
|
||||
IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment);
|
||||
if (VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv))
|
||||
if (VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv))
|
||||
{
|
||||
// We have an InProcessExecutionEnvironment which is not configured for checkpointing. Ensure it has an externalizable checkpoint manager,
|
||||
// since we are responsible for maintaining the state.
|
||||
env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
|
||||
this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
|
||||
}
|
||||
|
||||
this._inProcEnvironment = env as InProcessExecutionEnvironment
|
||||
?? throw new InvalidOperationException(
|
||||
$"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " +
|
||||
$"but received {env.GetType().Name}.");
|
||||
|
||||
this.SessionId = Throw.IfNullOrEmpty(sessionId);
|
||||
this.ChatHistoryProvider = new WorkflowChatHistoryProvider();
|
||||
}
|
||||
@@ -98,36 +67,29 @@ internal sealed class WorkflowSession : AgentSession
|
||||
public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
this._workflow = Throw.IfNull(workflow);
|
||||
this._executionEnvironment = Throw.IfNull(executionEnvironment);
|
||||
this._includeExceptionDetails = includeExceptionDetails;
|
||||
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
|
||||
|
||||
IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment);
|
||||
|
||||
JsonMarshaller marshaller = new(jsonSerializerOptions);
|
||||
SessionState sessionState = marshaller.Marshal<SessionState>(serializedSession);
|
||||
|
||||
this._inMemoryCheckpointManager = sessionState.CheckpointManager;
|
||||
if (this._inMemoryCheckpointManager != null &&
|
||||
VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv))
|
||||
VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv))
|
||||
{
|
||||
env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
|
||||
this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
|
||||
}
|
||||
else if (this._inMemoryCheckpointManager != null)
|
||||
{
|
||||
throw new ArgumentException("The session was saved with an externalized checkpoint manager, but the incoming execution environment does not support it.", nameof(executionEnvironment));
|
||||
}
|
||||
|
||||
this._inProcEnvironment = env as InProcessExecutionEnvironment
|
||||
?? throw new InvalidOperationException(
|
||||
$"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " +
|
||||
$"but received {env.GetType().Name}.");
|
||||
|
||||
this.SessionId = sessionState.SessionId;
|
||||
this.ChatHistoryProvider = new WorkflowChatHistoryProvider();
|
||||
|
||||
this.LastCheckpoint = sessionState.LastCheckpoint;
|
||||
this.StateBag = sessionState.StateBag;
|
||||
this._pendingRequests = sessionState.PendingRequests ?? [];
|
||||
}
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
@@ -139,8 +101,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
this.SessionId,
|
||||
this.LastCheckpoint,
|
||||
this._inMemoryCheckpointManager,
|
||||
this.StateBag,
|
||||
this._pendingRequests);
|
||||
this.StateBag);
|
||||
|
||||
return marshaller.Marshal(info);
|
||||
}
|
||||
@@ -180,173 +141,31 @@ internal sealed class WorkflowSession : AgentSession
|
||||
return update;
|
||||
}
|
||||
|
||||
private async ValueTask<ResumeRunResult> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
private async ValueTask<StreamingRun> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session,
|
||||
// and does not need to be checked again here.
|
||||
if (this.LastCheckpoint is not null)
|
||||
{
|
||||
// Use the internal resume path that suppresses pending request republishing.
|
||||
// WorkflowSession handles pending requests itself by converting matching responses
|
||||
// via SendMessagesWithResponseConversionAsync, so event-stream republishing would
|
||||
// cause unwanted duplicate events visible to the consumer.
|
||||
StreamingRun run =
|
||||
await this._inProcEnvironment
|
||||
.ResumeStreamingInternalAsync(this._workflow,
|
||||
await this._executionEnvironment
|
||||
.ResumeStreamingAsync(this._workflow,
|
||||
this.LastCheckpoint,
|
||||
republishPendingEvents: false,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Process messages: convert response content to ExternalResponse, send regular messages as-is
|
||||
ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false);
|
||||
return new ResumeRunResult(run, dispatchInfo);
|
||||
await run.TrySendMessageAsync(messages).ConfigureAwait(false);
|
||||
return run;
|
||||
}
|
||||
|
||||
StreamingRun newRun = await this._inProcEnvironment
|
||||
return await this._executionEnvironment
|
||||
.RunStreamingAsync(this._workflow,
|
||||
messages,
|
||||
this.SessionId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new ResumeRunResult(newRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent
|
||||
/// to ExternalResponse when there's a matching pending request.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Structured information about how resume content was dispatched.
|
||||
/// </returns>
|
||||
private async ValueTask<ResumeDispatchInfo> SendMessagesWithResponseConversionAsync(StreamingRun run, List<ChatMessage> messages)
|
||||
{
|
||||
List<ChatMessage> regularMessages = [];
|
||||
// Responses are deferred until after regular messages are queued so response handlers
|
||||
// can merge buffered regular content in the same continuation turn.
|
||||
List<(ExternalResponse Response, string RequestId)> externalResponses = [];
|
||||
bool hasMatchedResponseForStartExecutor = false;
|
||||
|
||||
// Tracks content IDs already matched to pending requests within this invocation,
|
||||
// preventing duplicate responses for the same ID from being sent to the workflow engine.
|
||||
HashSet<string>? matchedContentIds = null;
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
List<AIContent> regularContents = [];
|
||||
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
string? contentId = GetResponseContentId(content);
|
||||
|
||||
// Skip duplicate response content for an already-matched content ID
|
||||
if (contentId != null && matchedContentIds?.Contains(contentId) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contentId != null
|
||||
&& this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest)
|
||||
{
|
||||
// For intercepted/complex topologies the port may not be registered in the EdgeMap.
|
||||
// Treat unknown port as non-start-executor (conservative): TurnToken will still be sent.
|
||||
if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId))
|
||||
{
|
||||
hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
|
||||
externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
|
||||
(matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
|
||||
}
|
||||
else
|
||||
{
|
||||
regularContents.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (regularContents.Count > 0)
|
||||
{
|
||||
ChatMessage cloned = message.Clone();
|
||||
cloned.Contents = regularContents;
|
||||
regularMessages.Add(cloned);
|
||||
}
|
||||
}
|
||||
|
||||
// Send regular messages first so response handlers can merge them with responses.
|
||||
bool hasRegularMessages = regularMessages.Count > 0;
|
||||
if (hasRegularMessages)
|
||||
{
|
||||
await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Send external responses after regular messages.
|
||||
bool hasMatchedExternalResponses = false;
|
||||
foreach ((ExternalResponse response, string requestId) in externalResponses)
|
||||
{
|
||||
await run.SendResponseAsync(response).ConfigureAwait(false);
|
||||
hasMatchedExternalResponses = true;
|
||||
this.RemovePendingRequest(requestId);
|
||||
}
|
||||
|
||||
return new ResumeDispatchInfo(
|
||||
hasRegularMessages,
|
||||
hasMatchedExternalResponses,
|
||||
hasMatchedResponseForStartExecutor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
|
||||
{
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest
|
||||
=> externalRequest.ToFunctionCall(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
|
||||
/// </summary>
|
||||
private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch
|
||||
{
|
||||
FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionResultContent(functionResultContent, functionCallContent.CallId),
|
||||
ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId),
|
||||
_ => content,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow-facing request ID from response content types.
|
||||
/// </summary>
|
||||
private static string? GetResponseContentId(AIContent content) => content switch
|
||||
{
|
||||
FunctionResultContent functionResultContent => functionResultContent.CallId,
|
||||
ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private ExternalRequest? TryGetPendingRequest(string requestId) =>
|
||||
this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a pending request indexed by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void RemovePendingRequest(string requestId) =>
|
||||
this._pendingRequests.Remove(requestId);
|
||||
|
||||
internal async
|
||||
IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
@@ -356,25 +175,12 @@ internal sealed class WorkflowSession : AgentSession
|
||||
this.LastResponseId = Guid.NewGuid().ToString("N");
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
|
||||
|
||||
ResumeRunResult resumeResult =
|
||||
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
|
||||
await using StreamingRun run =
|
||||
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning disable CA2007 // Analyzer misfiring.
|
||||
await using StreamingRun run = resumeResult.Run;
|
||||
#pragma warning restore CA2007
|
||||
|
||||
ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
|
||||
|
||||
// Send a TurnToken to the start executor unless the only activity is an external
|
||||
// response directed at the start executor itself (which self-emits a TurnToken via
|
||||
// ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
|
||||
// TurnTokens after processing responses, so the session must always provide one.
|
||||
bool shouldSendTurnToken =
|
||||
!dispatchInfo.HasMatchedExternalResponses
|
||||
|| !dispatchInfo.HasMatchedResponseForStartExecutor;
|
||||
if (shouldSendTurnToken)
|
||||
{
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
}
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
.WithCancellation(cancellationToken))
|
||||
@@ -386,13 +192,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
|
||||
|
||||
// Track the pending request so we can convert incoming responses back to ExternalResponse.
|
||||
// External callers respond using the workflow-facing request ID, which is always RequestId.
|
||||
this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request);
|
||||
|
||||
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent);
|
||||
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
|
||||
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
|
||||
yield return update;
|
||||
break;
|
||||
|
||||
@@ -466,116 +267,15 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// <inheritdoc/>
|
||||
public WorkflowChatHistoryProvider ChatHistoryProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Captures the outcome of creating or resuming a workflow run,
|
||||
/// indicating what types of messages were sent during resume.
|
||||
/// </summary>
|
||||
private readonly struct ResumeRunResult
|
||||
{
|
||||
/// <summary>The streaming run that was created or resumed.</summary>
|
||||
public StreamingRun Run { get; }
|
||||
|
||||
/// <summary>How resume-time content was dispatched into the workflow runtime.</summary>
|
||||
public ResumeDispatchInfo DispatchInfo { get; }
|
||||
|
||||
public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default)
|
||||
{
|
||||
this.Run = Throw.IfNull(run);
|
||||
this.DispatchInfo = dispatchInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures how resumed input was split across regular-message and external-response delivery paths.
|
||||
/// </summary>
|
||||
private readonly struct ResumeDispatchInfo
|
||||
{
|
||||
public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor)
|
||||
{
|
||||
this.HasRegularMessages = hasRegularMessages;
|
||||
this.HasMatchedExternalResponses = hasMatchedExternalResponses;
|
||||
this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor;
|
||||
}
|
||||
|
||||
public bool HasRegularMessages { get; }
|
||||
|
||||
public bool HasMatchedExternalResponses { get; }
|
||||
|
||||
public bool HasMatchedResponseForStartExecutor { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionCallContent"/> with a workflow-facing call ID.
|
||||
/// </summary>
|
||||
private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId)
|
||||
{
|
||||
FunctionCallContent clone = new(callId, content.Name, content.Arguments)
|
||||
{
|
||||
Exception = content.Exception,
|
||||
InformationalOnly = content.InformationalOnly,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionResultContent"/> with an agent-owned call ID.
|
||||
/// </summary>
|
||||
private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId)
|
||||
{
|
||||
FunctionResultContent clone = new(callId, content.Result)
|
||||
{
|
||||
Exception = content.Exception,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalRequestContent"/> with a workflow-facing request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id)
|
||||
{
|
||||
ToolApprovalRequestContent clone = new(id, content.ToolCall);
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalResponseContent"/> with an agent-owned request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id)
|
||||
{
|
||||
ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall)
|
||||
{
|
||||
Reason = content.Reason,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies shared <see cref="AIContent"/> metadata to a cloned content instance.
|
||||
/// </summary>
|
||||
private static TContent CopyContentMetadata<TContent>(AIContent source, TContent target)
|
||||
where TContent : AIContent
|
||||
{
|
||||
target.AdditionalProperties = source.AdditionalProperties;
|
||||
target.Annotations = source.Annotations;
|
||||
target.RawRepresentation = source.RawRepresentation;
|
||||
return target;
|
||||
}
|
||||
|
||||
internal sealed class SessionState(
|
||||
string sessionId,
|
||||
CheckpointInfo? lastCheckpoint,
|
||||
InMemoryCheckpointManager? checkpointManager = null,
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
Dictionary<string, ExternalRequest>? pendingRequests = null)
|
||||
AgentSessionStateBag? stateBag = null)
|
||||
{
|
||||
public string SessionId { get; } = sessionId;
|
||||
public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint;
|
||||
public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager;
|
||||
public AgentSessionStateBag StateBag { get; } = stateBag ?? new();
|
||||
public Dictionary<string, ExternalRequest>? PendingRequests { get; } = pendingRequests;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(PortableValue))]
|
||||
[JsonSerializable(typeof(PortableMessageEnvelope))]
|
||||
[JsonSerializable(typeof(InMemoryCheckpointManager))]
|
||||
[JsonSerializable(typeof(CheckpointFileIndexEntry))]
|
||||
|
||||
// Runtime State Types
|
||||
[JsonSerializable(typeof(ScopeKey))]
|
||||
|
||||
-2
@@ -161,8 +161,6 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient
|
||||
}
|
||||
|
||||
// Materialize the accumulated context back into messages and options.
|
||||
// Clone options to avoid mutating the caller's instance across calls.
|
||||
options = options?.Clone();
|
||||
var enrichedMessages = aiContext.Messages ?? [];
|
||||
|
||||
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
|
||||
|
||||
@@ -138,9 +138,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider);
|
||||
|
||||
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
|
||||
|
||||
// Warn if using a custom chat client stack with end-of-run persistence but no ChatHistoryPersistingChatClient.
|
||||
this.WarnOnMissingPersistingClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -214,14 +211,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatClientAgentContinuationToken? _) =
|
||||
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);
|
||||
|
||||
var loggingAgentName = this.GetLoggingAgentName();
|
||||
|
||||
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
|
||||
|
||||
// Call the IChatClient and notify the AIContextProvider of any failures.
|
||||
@@ -232,7 +227,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -240,8 +236,7 @@ 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.
|
||||
var forceEndOfRunPersistence = chatOptions?.ContinuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
|
||||
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
|
||||
this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
|
||||
|
||||
// Ensure that the author name is set for each message in the response.
|
||||
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
|
||||
@@ -249,10 +244,11 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatResponseMessage.AuthorName ??= this.Name;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
return new AgentResponse(chatResponse)
|
||||
{
|
||||
@@ -300,10 +296,6 @@ 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);
|
||||
@@ -323,7 +315,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -337,7 +330,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -359,31 +353,27 @@ 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.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), 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.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
|
||||
this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -451,29 +441,17 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
#region Private
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
|
||||
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is also called by <see cref="ChatHistoryPersistingChatClient"/> to persist messages per-service-call.
|
||||
/// </remarks>
|
||||
internal async Task NotifyProvidersOfNewMessagesAsync(
|
||||
private async Task NotifyAIContextProviderOfSuccessAsync(
|
||||
ChatClientAgentSession session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
IEnumerable<ChatMessage> 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, requestMessages, responseMessages);
|
||||
AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, responseMessages);
|
||||
|
||||
foreach (var contextProvider in contextProviders)
|
||||
{
|
||||
@@ -483,29 +461,17 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
|
||||
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is also called by <see cref="ChatHistoryPersistingChatClient"/> to report failures per-service-call.
|
||||
/// </remarks>
|
||||
internal async Task NotifyProvidersOfFailureAsync(
|
||||
private async Task NotifyAIContextProviderOfFailureAsync(
|
||||
ChatClientAgentSession session,
|
||||
Exception ex,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
ChatOptions? chatOptions,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
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, requestMessages, ex);
|
||||
AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, ex);
|
||||
|
||||
foreach (var contextProvider in contextProviders)
|
||||
{
|
||||
@@ -701,12 +667,6 @@ 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)
|
||||
{
|
||||
@@ -794,7 +754,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
return (typedSession, chatOptions, messagesList, continuationToken);
|
||||
}
|
||||
|
||||
internal void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
|
||||
private void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(session.ConversationId))
|
||||
{
|
||||
@@ -838,162 +798,45 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the session conversation ID at the end of an agent run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a <see cref="ChatHistoryPersistingChatClient"/> 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 <paramref name="forceUpdate"/> is <see langword="true"/>
|
||||
/// (continuation token scenarios), the update is always performed.
|
||||
/// </remarks>
|
||||
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
|
||||
{
|
||||
if (!forceUpdate && this.PersistsChatHistoryPerServiceCall)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateSessionConversationId(session, responseConversationId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies providers of successfully completed messages at the end of an agent run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a <see cref="ChatHistoryPersistingChatClient"/> 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
|
||||
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/>), all messages are persisted.
|
||||
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
|
||||
/// background response scenarios), notification is always performed with all messages because
|
||||
/// per-service-call persistence is unreliable in these scenarios.
|
||||
/// </remarks>
|
||||
private Task NotifyProvidersOfNewMessagesAtEndOfRunAsync(
|
||||
ChatClientAgentSession session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies providers of a failure at the end of an agent run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a <see cref="ChatHistoryPersistingChatClient"/> 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.
|
||||
/// </remarks>
|
||||
private Task NotifyProvidersOfFailureAtEndOfRunAsync(
|
||||
private Task NotifyChatHistoryProviderOfFailureAsync(
|
||||
ChatClientAgentSession session,
|
||||
Exception ex,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (this.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)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex);
|
||||
|
||||
return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
return this.NotifyProvidersOfFailureAsync(session, ex, requestMessages, chatOptions, cancellationToken);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// decorator in persist mode (not mark-only), which handles per-service-call persistence.
|
||||
/// </summary>
|
||||
private bool PersistsChatHistoryPerServiceCall
|
||||
private Task NotifyChatHistoryProviderOfNewMessagesAsync(
|
||||
ChatClientAgentSession session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage> responseMessages,
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
get
|
||||
{
|
||||
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
return persistingClient?.MarkOnly == false;
|
||||
}
|
||||
}
|
||||
ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
|
||||
/// </summary>
|
||||
private bool HasMarkOnlyChatHistoryPersistingClient
|
||||
{
|
||||
get
|
||||
// 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)
|
||||
{
|
||||
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
return persistingClient?.MarkOnly == true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages that have been marked as persisted by a <see cref="ChatHistoryPersistingChatClient"/> in mark-only mode.
|
||||
/// </summary>
|
||||
private static List<ChatMessage> GetMarkedMessages(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
return messages.Where(m =>
|
||||
m.AdditionalProperties?.TryGetValue(ChatHistoryPersistingChatClient.PersistedMarkerKey, out var value) == true && value is true).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that <see cref="AIAgent.CurrentRunContext"/> contains the resolved session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
|
||||
/// (which may be null) and restores it after each yield in streaming scenarios. After
|
||||
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
|
||||
/// context so the <see cref="ChatHistoryPersistingChatClient"/> 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
|
||||
/// </summary>
|
||||
private void WarnOnMissingPersistingClient()
|
||||
{
|
||||
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
|
||||
{
|
||||
return;
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages);
|
||||
return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
if (this._agentOptions?.PersistChatHistoryAtEndOfRun is not true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
var loggingAgentName = this.GetLoggingAgentName();
|
||||
this._logger.LogAgentChatClientMissingPersistingClient(
|
||||
this.Id,
|
||||
loggingAgentName);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
|
||||
|
||||
@@ -69,32 +69,4 @@ internal static partial class ChatClientAgentLogMessages
|
||||
string chatHistoryProviderName,
|
||||
string agentId,
|
||||
string agentName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
|
||||
/// and <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> is <see langword="true"/>,
|
||||
/// but no <see cref="ChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
|
||||
/// </summary>
|
||||
[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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>AllowBackgroundResponses</c>). 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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;
|
||||
|
||||
@@ -91,56 +89,6 @@ public sealed class ChatClientAgentOptions
|
||||
/// </value>
|
||||
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// By default, <see cref="ChatClientAgent"/> persists request and response messages either via
|
||||
/// a <see cref="ChatHistoryProvider"/>, 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 <see cref="ChatClientAgentSession.ConversationId"/>
|
||||
/// is also updated after each service call, keeping it in sync with the service-side conversation state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting this property to <see langword="true"/> 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 <see cref="ChatClientAgentSession.ConversationId"/> is likewise deferred and
|
||||
/// updated only at the end of the run, consistent with atomic run semantics.
|
||||
/// A <see cref="ChatHistoryPersistingChatClient"/> decorator is inserted into the chat client pipeline
|
||||
/// in mark-only mode, and the <see cref="ChatClientAgent"/> persists only the marked messages at the
|
||||
/// end of the run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When this option is <see langword="false"/> (the default), the <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// decorator persists messages and updates the <see cref="ChatClientAgentSession.ConversationId"/>
|
||||
/// immediately after each service call. This may leave chat history in a state where
|
||||
/// <see cref="FunctionResultContent"/> is required to start a new run if the last successful service
|
||||
/// call returned <see cref="FunctionCallContent"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When using a custom chat client stack, you can add a <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// manually via the <see cref="ChatClientBuilderExtensions.UseChatHistoryPersisting"/>
|
||||
/// extension method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <see langword="true"/>
|
||||
/// in this case will therefore have no real effect. Setting this property to <see langword="true"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool PersistChatHistoryAtEndOfRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
@@ -157,6 +105,5 @@ public sealed class ChatClientAgentOptions
|
||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||
PersistChatHistoryAtEndOfRun = this.PersistChatHistoryAtEndOfRun,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
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;
|
||||
@@ -84,46 +82,4 @@ public static class ChatClientBuilderExtensions
|
||||
options: options,
|
||||
loggerFactory: loggerFactory,
|
||||
services: services);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a <see cref="ChatHistoryPersistingChatClient"/> to the chat client pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
||||
/// <see cref="IChatClient"/> in the pipeline. It intercepts service calls to either persist messages
|
||||
/// immediately or mark them for later persistence, depending on the <paramref name="markOnly"/> parameter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <paramref name="markOnly"/> is set to <see langword="true"/>, the <see cref="ChatClientAgent"/>
|
||||
/// should be configured with <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> set to <see langword="true"/>
|
||||
/// as without this combination, messages will never be persisted when using a <see cref="ChatHistoryProvider"/> for
|
||||
/// chat history persistence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This extension method is intended for use with custom chat client stacks when
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
||||
/// the <see cref="ChatClientAgent"/> automatically injects this decorator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
|
||||
/// exception if used in any other stack.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
||||
/// <param name="markOnly">
|
||||
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
|
||||
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
|
||||
/// The <see cref="ChatClientAgent"/> will persist only the marked messages and update the
|
||||
/// conversation ID at the end of the run.
|
||||
/// When <see langword="false"/> (the default), messages are persisted and the conversation ID
|
||||
/// is updated immediately after each service call.
|
||||
/// </param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static ChatClientBuilder UseChatHistoryPersisting(this ChatClientBuilder builder, bool markOnly = false)
|
||||
{
|
||||
return builder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +63,6 @@ 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 })
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that notifies <see cref="ChatHistoryProvider"/> and <see cref="AIContextProvider"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
||||
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In persist mode (the default), it ensures that providers are notified and the session's
|
||||
/// <see cref="ChatClientAgentSession.ConversationId"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In mark-only mode (<see cref="MarkOnly"/> is <see langword="true"/>), it marks messages with metadata
|
||||
/// but does not notify providers or update the <see cref="ChatClientAgentSession.ConversationId"/>.
|
||||
/// Both are deferred to the <see cref="ChatClientAgent"/> at the end of the run, providing atomic
|
||||
/// run semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
|
||||
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
||||
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
|
||||
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
|
||||
/// method is called. The <see cref="ChatClientAgent"/> ensures the run context always contains a resolved session,
|
||||
/// even when the caller passes null. An <see cref="InvalidOperationException"/> is thrown if no run context is
|
||||
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The key used in <see cref="ChatMessage.AdditionalProperties"/> and <see cref="AIContent.AdditionalProperties"/>
|
||||
/// to mark messages and their content as already persisted to chat history.
|
||||
/// </summary>
|
||||
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
||||
/// <param name="markOnly">
|
||||
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
|
||||
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
|
||||
/// The <see cref="ChatClientAgent"/> will persist only the marked messages and update the
|
||||
/// conversation ID at the end of the run.
|
||||
/// When <see langword="false"/> (the default), messages are persisted and the conversation ID
|
||||
/// is updated immediately after each service call.
|
||||
/// </param>
|
||||
public ChatHistoryPersistingChatClient(IChatClient innerClient, bool markOnly = false)
|
||||
: base(innerClient)
|
||||
{
|
||||
this.MarkOnly = markOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this decorator is in mark-only mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
|
||||
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
|
||||
/// Both are deferred to the <see cref="ChatClientAgent"/> at the end of the run.
|
||||
/// When <see langword="false"/>, messages are persisted and the conversation ID is updated
|
||||
/// after each service call.
|
||||
/// </remarks>
|
||||
public bool MarkOnly { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (agent, session) = GetRequiredAgentAndSession();
|
||||
|
||||
List<ChatResponseUpdate> responseUpdates = [];
|
||||
|
||||
IAsyncEnumerator<ChatResponseUpdate> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current <see cref="ChatClientAgent"/> and <see cref="ChatClientAgentSession"/> from the run context.
|
||||
/// </summary>
|
||||
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<ChatClientAgent>()
|
||||
?? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether persistence should be deferred to end-of-run instead of happening immediately.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when in <see cref="MarkOnly"/> 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).
|
||||
/// </returns>
|
||||
private bool ShouldDeferPersistence(ChatOptions? options)
|
||||
{
|
||||
return this.MarkOnly || options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the request messages that have not yet been persisted to chat history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A message is considered already persisted if any of the following is true:
|
||||
/// <list type="bullet">
|
||||
/// <item>It has the <see cref="PersistedMarkerKey"/> in its <see cref="ChatMessage.AdditionalProperties"/>.</item>
|
||||
/// <item>It has an <see cref="AgentRequestMessageSourceType"/> of <see cref="AgentRequestMessageSourceType.ChatHistory"/>
|
||||
/// (indicating it was loaded from chat history and does not need to be re-persisted).</item>
|
||||
/// <item>It has <see cref="ChatMessage.Contents"/> and all of its <see cref="AIContent"/> items have the
|
||||
/// <see cref="PersistedMarkerKey"/> in their <see cref="AIContent.AdditionalProperties"/>. This handles the
|
||||
/// streaming case where <see cref="FunctionInvokingChatClient"/> reconstructs <see cref="ChatMessage"/> objects
|
||||
/// independently via <c>ToChatResponse()</c>, producing different object references that share the same
|
||||
/// underlying <see cref="AIContent"/> instances.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <returns>A list of request messages that have not yet been persisted.</returns>
|
||||
/// <param name="messages">The full set of request messages to filter.</param>
|
||||
private static List<ChatMessage> GetNewRequestMessages(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
return messages.Where(m => !IsAlreadyPersisted(m)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a message has already been persisted to chat history by this decorator.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the given messages as persisted by setting a marker on both the <see cref="ChatMessage"/>
|
||||
/// and each of its <see cref="AIContent"/> items.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both levels are marked because <see cref="FunctionInvokingChatClient"/> may reconstruct
|
||||
/// <see cref="ChatMessage"/> objects in streaming mode (losing the message-level marker),
|
||||
/// but the <see cref="AIContent"/> references are shared and retain their markers.
|
||||
/// </remarks>
|
||||
/// <param name="messages">The messages to mark as persisted.</param>
|
||||
private static void MarkAsPersisted(IEnumerable<ChatMessage> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-110
@@ -5,8 +5,6 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
@@ -237,114 +235,6 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowMcpToolSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
|
||||
await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
|
||||
{
|
||||
// Connect to the MCP endpoint exposed by the Azure Functions host
|
||||
IClientTransport clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
|
||||
});
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
|
||||
|
||||
// Verify both workflow tools are listed
|
||||
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
|
||||
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
Assert.Single(tools, t => t.Name == "Translate");
|
||||
Assert.Single(tools, t => t.Name == "OrderLookup");
|
||||
|
||||
// Invoke the Translate workflow via MCP tool (returns a string result)
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
|
||||
CallToolResult translateResult = await mcpClient.CallToolAsync(
|
||||
"Translate",
|
||||
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
|
||||
|
||||
Assert.NotEmpty(translateResult.Content);
|
||||
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
|
||||
Assert.NotEmpty(translateResponse);
|
||||
Assert.Contains("HELLO WORLD", translateResponse);
|
||||
|
||||
// Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON)
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'...");
|
||||
CallToolResult orderResult = await mcpClient.CallToolAsync(
|
||||
"OrderLookup",
|
||||
arguments: new Dictionary<string, object?> { { "input", "ORD-2025-42" } });
|
||||
|
||||
Assert.NotEmpty(orderResult.Content);
|
||||
string orderResponse = Assert.IsType<TextContentBlock>(orderResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}");
|
||||
Assert.NotEmpty(orderResponse);
|
||||
Assert.Contains("ORD-2025-42", orderResponse);
|
||||
|
||||
// Verify executor activities ran in the logs
|
||||
lock (logs)
|
||||
{
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
|
||||
await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) =>
|
||||
{
|
||||
// Connect to the MCP endpoint exposed by the Azure Functions host
|
||||
IClientTransport clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
|
||||
});
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
|
||||
|
||||
// Verify both the agent and workflow tools are listed
|
||||
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
|
||||
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
Assert.Single(tools, t => t.Name == "Assistant");
|
||||
Assert.Single(tools, t => t.Name == "Translate");
|
||||
|
||||
// Invoke the Translate workflow via MCP tool
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
|
||||
CallToolResult translateResult = await mcpClient.CallToolAsync(
|
||||
"Translate",
|
||||
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
|
||||
|
||||
Assert.NotEmpty(translateResult.Content);
|
||||
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
|
||||
Assert.Contains("HELLO WORLD", translateResponse);
|
||||
|
||||
// Invoke the Assistant agent via MCP tool
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'...");
|
||||
CallToolResult assistantResult = await mcpClient.CallToolAsync(
|
||||
"Assistant",
|
||||
arguments: new Dictionary<string, object?> { { "query", "What is 2 + 2?" } });
|
||||
|
||||
Assert.NotEmpty(assistantResult.Content);
|
||||
string assistantResponse = Assert.IsType<TextContentBlock>(assistantResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}");
|
||||
Assert.NotEmpty(assistantResponse);
|
||||
|
||||
// Verify workflow executor activities ran in the logs
|
||||
lock (logs)
|
||||
{
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
|
||||
-39
@@ -148,45 +148,6 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_SkipsAgents_WithoutExplicitOptions()
|
||||
{
|
||||
// Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions.
|
||||
// This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent.
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") },
|
||||
{ "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") }
|
||||
};
|
||||
|
||||
FunctionsAgentOptions standaloneOptions = new();
|
||||
standaloneOptions.HttpTrigger.IsEnabled = true;
|
||||
|
||||
// Only standaloneAgent has explicit options; workflowAgent does not.
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary<string, FunctionsAgentOptions>
|
||||
{
|
||||
{ "standaloneAgent", standaloneOptions }
|
||||
});
|
||||
|
||||
List<IFunctionMetadata> metadataList = [];
|
||||
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
// Assert: only standaloneAgent should have triggers (entity + http = 2).
|
||||
// workflowAgent should be skipped entirely.
|
||||
Assert.Equal(2, metadataList.Count);
|
||||
Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent");
|
||||
Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent");
|
||||
Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent"));
|
||||
}
|
||||
|
||||
private static List<IFunctionMetadata> BuildFunctionMetadataList(int numberOfFunctions)
|
||||
{
|
||||
List<IFunctionMetadata> list = [];
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
public sealed class FunctionMetadataFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateEntityTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent");
|
||||
|
||||
Assert.Equal("dafx-myAgent", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(2, metadata.RawBindings.Count);
|
||||
Assert.Contains("entityTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
|
||||
"myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint);
|
||||
|
||||
Assert.Equal("http-myWorkflow", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(3, metadata.RawBindings.Count);
|
||||
Assert.Contains("httpTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"post\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("http", metadata.RawBindings[1]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHttpTrigger_RespectsCustomMethods()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
|
||||
"status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\"");
|
||||
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Contains("\"get\"", metadata.RawBindings[0]);
|
||||
Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateActivityTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor");
|
||||
|
||||
Assert.Equal("dafx-MyExecutor", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(2, metadata.RawBindings.Count);
|
||||
Assert.Contains("activityTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger(
|
||||
"dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint);
|
||||
|
||||
Assert.Equal("dafx-MyWorkflow", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Single(metadata.RawBindings);
|
||||
Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text");
|
||||
|
||||
Assert.Equal("mcptool-Translate", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(3, metadata.RawBindings.Count);
|
||||
|
||||
// Verify all bindings are valid JSON
|
||||
foreach (string binding in metadata.RawBindings)
|
||||
{
|
||||
JsonDocument.Parse(binding);
|
||||
}
|
||||
|
||||
// mcpToolTrigger binding
|
||||
Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("toolProperties", metadata.RawBindings[0]);
|
||||
|
||||
// mcpToolProperty binding for input
|
||||
Assert.Contains("mcpToolProperty", metadata.RawBindings[1]);
|
||||
Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]);
|
||||
Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]);
|
||||
|
||||
// durableClient binding
|
||||
Assert.Contains("durableClient", metadata.RawBindings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null);
|
||||
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]);
|
||||
}
|
||||
}
|
||||
-161
@@ -250,129 +250,6 @@ public class AIContextProviderChatClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Options Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange: track tool count seen by the inner client on each call
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
Assert.Same(originalTools, sharedOptions.Tools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response"));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 streaming calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
@@ -464,44 +341,6 @@ public class AIContextProviderChatClientTests
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
var response = await chatClient.GetResponseAsync(messages, options, ct);
|
||||
return new AgentResponse(response);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a streaming chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
|
||||
-766
@@ -1,766 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the <see cref="ChatHistoryPersistingChatClient"/> decorator,
|
||||
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
|
||||
/// individual service call by default, or marks messages for end-of-run persistence when the
|
||||
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> option is enabled.
|
||||
/// </summary>
|
||||
public class ChatHistoryPersistingChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that by default (PersistChatHistoryAtEndOfRun is false),
|
||||
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator (per service call)
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages!.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// the ChatHistoryProvider receives messages at the end of the run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called once by the agent (end of run)
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages!.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service call fails,
|
||||
/// the ChatHistoryProvider is notified with the exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Service failed");
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — the decorator should have notified the provider of the failure
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.InvokeException != null &&
|
||||
x.InvokeException.Message == "Service failed"),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is injected in persist mode by default
|
||||
/// and can be discovered via GetService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_ContainsDecorator_InPersistMode_ByDefault()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.NotNull(decorator);
|
||||
Assert.False(decorator.MarkOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.NotNull(decorator);
|
||||
Assert.True(decorator.MarkOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
UseProvidedChatClientAsIs = true,
|
||||
});
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.Null(decorator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone().
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var cloned = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.True(cloned.PersistChatHistoryAtEndOfRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service call
|
||||
/// involves a function invocation loop, the ChatHistoryProvider is called after each individual
|
||||
/// service call (not just once at the end).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// First call returns a tool call
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Second call returns a final response
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
// Define a simple tool
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
Exception? caughtException = null;
|
||||
try
|
||||
{
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
caughtException = ex;
|
||||
}
|
||||
|
||||
// Diagnostic: check if there was an unexpected exception
|
||||
Assert.Null(caughtException);
|
||||
|
||||
// Assert — the decorator should have been called twice (once per service call in the function invocation loop)
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// First invocation should have the user message as request and tool call response
|
||||
Assert.NotNull(invokedContexts[0].ResponseMessages);
|
||||
var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
|
||||
Assert.Contains(firstRequestMessages, m => m.Text == "test");
|
||||
Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType<FunctionCallContent>().Any());
|
||||
|
||||
// Second invocation: request messages should NOT include the original user message (already notified).
|
||||
// It should only include messages added since the first call (assistant tool call + tool result).
|
||||
Assert.NotNull(invokedContexts[1].ResponseMessages);
|
||||
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
|
||||
Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) with streaming,
|
||||
/// the ChatHistoryProvider receives messages after the stream completes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(CreateAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "response")));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
||||
{
|
||||
// Consume stream
|
||||
}
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages != null),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// AIContextProviders are also notified of new messages after a successful call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service fails,
|
||||
/// AIContextProviders are notified of the failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Service failed");
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — the decorator should have notified the AIContextProvider of the failure
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException != null &&
|
||||
x.InvokeException.Message == "Service failed"),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// both ChatHistoryProvider and AIContextProviders are notified together.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — both providers should have been notified
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that during a FIC loop, response messages from the first call are not
|
||||
/// re-notified as request messages on the second call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())]);
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
return Task.FromResult(new ChatResponse([assistantToolCallMessage]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// The assistant tool call message was a response in call 1
|
||||
Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage));
|
||||
|
||||
// It should NOT appear as a request in call 2 (it was already notified as a response)
|
||||
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a failure occurs on the second call in a FIC loop,
|
||||
/// only new request messages (not previously notified) are sent in the failure notification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Service failure on second call");
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — should have 2 notifications: success on call 1, failure on call 2
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// First notification: success, has user message as request
|
||||
Assert.Null(invokedContexts[0].InvokeException);
|
||||
Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test");
|
||||
|
||||
// Second notification: failure, should NOT include the user message (already notified)
|
||||
Assert.NotNull(invokedContexts[1].InvokeException);
|
||||
var failureRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after a successful run with per-service-call persistence, the notified
|
||||
/// messages are stamped with the persisted marker so they are not re-notified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MarksNotifiedMessages_WithPersistedMarkerAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var inputMessage = new ChatMessage(ChatRole.User, "test");
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([inputMessage], session);
|
||||
|
||||
// Assert — input message should be marked as persisted
|
||||
Assert.True(
|
||||
inputMessage.AdditionalProperties?.ContainsKey(ChatHistoryPersistingChatClient.PersistedMarkerKey) == true,
|
||||
"Input message should be marked as persisted after a successful run.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is enabled and the inner client returns a
|
||||
/// conversation ID, the session's ConversationId is updated after the service call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UpdatesSessionConversationId_WhenPerServiceCallPersistenceEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "conv-123";
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
||||
{
|
||||
ConversationId = ExpectedConversationId,
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — session should have the conversation ID returned by the inner client
|
||||
Assert.Equal(ExpectedConversationId, session!.ConversationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
-7
@@ -126,13 +126,6 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
|
||||
{
|
||||
hasRequest = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This is a republished event for the request we're already responding to
|
||||
// (emitted by RepublishUnservicedRequestsAsync during checkpoint resume).
|
||||
// Skip yielding it so downstream code doesn't treat it as a new pending request.
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
case ConversationUpdateEvent conversationEvent:
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for GH-2485: pending <see cref="RequestInfoEvent"/> objects must be
|
||||
/// re-emitted after resuming a workflow from a checkpoint.
|
||||
/// </summary>
|
||||
public class CheckpointResumeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that a resumed workflow re-emits <see cref="RequestInfoEvent"/>s for
|
||||
/// pending external requests that existed at the time of the checkpoint.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
ForwardMessageExecutor<string> processor = new("Processor");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, processor)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// Act 1: Run workflow, collect pending requests and a checkpoint.
|
||||
List<ExternalRequest> originalRequests = [];
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
originalRequests.Add(requestInfo.Request);
|
||||
}
|
||||
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
|
||||
originalRequests.Should().NotBeEmpty("the workflow should have created at least one external request");
|
||||
checkpoint.Should().NotBeNull("a checkpoint should have been created");
|
||||
}
|
||||
|
||||
// Act 2: Resume from the checkpoint.
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!);
|
||||
|
||||
// Assert: The pending requests should be re-emitted.
|
||||
List<ExternalRequest> reEmittedRequests = [];
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
reEmittedRequests.Add(requestInfo.Request);
|
||||
}
|
||||
}
|
||||
|
||||
reEmittedRequests.Should().HaveCount(originalRequests.Count,
|
||||
"all pending requests from the checkpoint should be re-emitted after resume");
|
||||
reEmittedRequests.Select(r => r.RequestId)
|
||||
.Should().BeEquivalentTo(originalRequests.Select(r => r.RequestId),
|
||||
"the re-emitted request IDs should match the original pending request IDs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="RunStatus"/> transitions to <see cref="RunStatus.PendingRequests"/>
|
||||
/// after resuming from a checkpoint with pending external requests (not stuck at NotStarted).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_WithPendingRequests_RunStatusIsPendingRequestsAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
ForwardMessageExecutor<string> processor = new("Processor");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, processor)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// First run: collect a checkpoint with pending requests.
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
|
||||
checkpoint.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// Act: Resume from the checkpoint and consume events so the run loop processes.
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!);
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
// Consume all events until the stream completes.
|
||||
}
|
||||
|
||||
// Assert
|
||||
RunStatus status = await resumed.GetStatusAsync();
|
||||
status.Should().Be(RunStatus.PendingRequests,
|
||||
"the resumed workflow should report PendingRequests after rehydration");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the full roundtrip: resume from checkpoint, observe the re-emitted request,
|
||||
/// send a response, and verify the workflow completes without duplicating the request.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_RespondToPendingRequest_CompletesWithoutDuplicateAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
ForwardMessageExecutor<string> processor = new("Processor");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, processor)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// First run: collect checkpoint + pending request.
|
||||
ExternalRequest? pendingRequest = null;
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
pendingRequest = requestInfo.Request;
|
||||
}
|
||||
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
|
||||
pendingRequest.Should().NotBeNull();
|
||||
checkpoint.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// Act: Resume and respond to the restored request.
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!);
|
||||
|
||||
int requestEventCount = 0;
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
|
||||
// Use blockOnPendingRequest: false for the first pass to see the re-emitted requests.
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
requestEventCount++;
|
||||
requestInfo.Request.RequestId.Should().Be(pendingRequest!.RequestId,
|
||||
"the re-emitted request should match the original");
|
||||
}
|
||||
}
|
||||
|
||||
requestEventCount.Should().Be(1,
|
||||
"the pending request should be emitted exactly once (no duplicates)");
|
||||
|
||||
// Assert intermediate state before responding: the run should be in PendingRequests
|
||||
// and we should have observed the re-emitted request. If the first WatchStreamAsync
|
||||
// didn't complete or yielded nothing, these assertions catch it with a clear message.
|
||||
RunStatus statusBeforeResponse = await resumed.GetStatusAsync();
|
||||
statusBeforeResponse.Should().Be(RunStatus.PendingRequests,
|
||||
"the run should be in PendingRequests state before we send a response");
|
||||
|
||||
// Now send the response and verify the workflow processes it.
|
||||
ExternalResponse response = pendingRequest!.CreateResponse("World");
|
||||
await resumed.SendResponseAsync(response);
|
||||
|
||||
// Consume the resulting events to verify the workflow progresses without errors.
|
||||
List<WorkflowEvent> postResponseEvents = [];
|
||||
|
||||
using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(10));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token))
|
||||
{
|
||||
postResponseEvents.Add(evt);
|
||||
}
|
||||
|
||||
postResponseEvents.Should().NotBeEmpty(
|
||||
"the workflow should process the response and produce events");
|
||||
postResponseEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"no errors should occur when processing the restored request's response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that restoring a live run to a checkpoint re-emits pending requests and allows
|
||||
/// the workflow to continue from that restored point.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Restore_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
Workflow workflow = CreateSimpleRequestWorkflow();
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
await using StreamingRun run = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello");
|
||||
|
||||
(ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run);
|
||||
|
||||
// Advance the run past the checkpoint so the restore has meaningful work to undo.
|
||||
await run.SendResponseAsync(pendingRequest.CreateResponse("World"));
|
||||
|
||||
List<WorkflowEvent> firstCompletionEvents = await ReadToHaltAsync(run);
|
||||
firstCompletionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"the workflow should continue cleanly before we restore");
|
||||
RunStatus statusAfterFirstResponse = await run.GetStatusAsync();
|
||||
statusAfterFirstResponse.Should().Be(RunStatus.Idle,
|
||||
"the workflow should finish processing the first response before we restore");
|
||||
|
||||
// Act
|
||||
await run.RestoreCheckpointAsync(checkpoint);
|
||||
|
||||
// Assert
|
||||
List<WorkflowEvent> restoredEvents = await ReadToHaltAsync(run);
|
||||
ExternalRequest[] replayedRequests = [.. restoredEvents.OfType<RequestInfoEvent>().Select(evt => evt.Request)];
|
||||
|
||||
replayedRequests.Should().ContainSingle("runtime restore should re-emit the restored pending request");
|
||||
replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId,
|
||||
"the replayed request should match the request captured at the checkpoint");
|
||||
|
||||
await run.SendResponseAsync(replayedRequests[0].CreateResponse("Again"));
|
||||
|
||||
List<WorkflowEvent> secondCompletionEvents = await ReadToHaltAsync(run);
|
||||
secondCompletionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"runtime restore replay should not introduce workflow errors");
|
||||
RunStatus statusAfterRestoreResponse = await run.GetStatusAsync();
|
||||
statusAfterRestoreResponse.Should().Be(RunStatus.Idle,
|
||||
"the workflow should be able to continue after the runtime restore replay");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_SubworkflowWithPendingRequests_RepublishesQualifiedRequestInfoEventsAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
Workflow workflow = CreateCheckpointedSubworkflowRequestWorkflow();
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
ExternalRequest pendingRequest;
|
||||
CheckpointInfo checkpoint;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
(pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun);
|
||||
}
|
||||
|
||||
// Act
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint);
|
||||
|
||||
// Assert
|
||||
List<WorkflowEvent> resumedEvents = await ReadToHaltAsync(resumed);
|
||||
ExternalRequest[] replayedRequests = [.. resumedEvents.OfType<RequestInfoEvent>().Select(evt => evt.Request)];
|
||||
|
||||
replayedRequests.Should().ContainSingle("the resumed parent workflow should surface the subworkflow request once");
|
||||
replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId,
|
||||
"the replayed subworkflow request should match the checkpointed request");
|
||||
replayedRequests[0].PortInfo.PortId.Should().Be(pendingRequest.PortInfo.PortId,
|
||||
"the replayed request should remain qualified through the subworkflow boundary");
|
||||
|
||||
await resumed.SendResponseAsync(replayedRequests[0].CreateResponse("World"));
|
||||
|
||||
List<WorkflowEvent> completionEvents = await ReadToHaltAsync(resumed);
|
||||
completionEvents.OfType<RequestInfoEvent>().Should().BeEmpty(
|
||||
"the resumed subworkflow request should not be replayed twice");
|
||||
completionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"subworkflow replay should not introduce workflow errors");
|
||||
RunStatus statusAfterSubworkflowResponse = await resumed.GetStatusAsync();
|
||||
statusAfterSubworkflowResponse.Should().Be(RunStatus.Idle,
|
||||
"the resumed subworkflow should continue after responding to the replayed request");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when <c>republishPendingEvents</c> is <see langword="false"/>,
|
||||
/// no <see cref="RequestInfoEvent"/> is re-emitted after resuming from a checkpoint.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_WithRepublishDisabled_DoesNotEmitRequestInfoEventsAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
ForwardMessageExecutor<string> processor = new("Processor");
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, processor)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// First run: collect a checkpoint with pending requests.
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
|
||||
checkpoint.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// Act: Resume with republishPendingEvents: false via the internal API.
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingInternalAsync(workflow, checkpoint!, republishPendingEvents: false);
|
||||
|
||||
// Assert: No RequestInfoEvent should appear in the event stream.
|
||||
int requestEventCount = 0;
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent)
|
||||
{
|
||||
requestEventCount++;
|
||||
}
|
||||
}
|
||||
|
||||
requestEventCount.Should().Be(0,
|
||||
"no RequestInfoEvent should be emitted when republishPendingEvents is false");
|
||||
}
|
||||
|
||||
private static Workflow CreateSimpleRequestWorkflow(
|
||||
string requestPortId = "TestPort",
|
||||
string processorId = "Processor")
|
||||
{
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>(requestPortId);
|
||||
ForwardMessageExecutor<string> processor = new(processorId);
|
||||
|
||||
return new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, processor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Workflow CreateCheckpointedSubworkflowRequestWorkflow()
|
||||
{
|
||||
ExecutorBinding subworkflow = CreateSimpleRequestWorkflow(
|
||||
requestPortId: "InnerTestPort",
|
||||
processorId: "InnerProcessor")
|
||||
.BindAsExecutor("Subworkflow");
|
||||
|
||||
return new WorkflowBuilder(subworkflow)
|
||||
.AddExternalRequest<string, string>(subworkflow, id: "ForwardedSubworkflowRequest")
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static async ValueTask<(ExternalRequest PendingRequest, CheckpointInfo Checkpoint)> CapturePendingRequestAndCheckpointAsync(StreamingRun run)
|
||||
{
|
||||
ExternalRequest? pendingRequest = null;
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
pendingRequest ??= requestInfo.Request;
|
||||
}
|
||||
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
|
||||
pendingRequest.Should().NotBeNull("the workflow should have emitted a pending request");
|
||||
checkpoint.Should().NotBeNull("the workflow should have produced a checkpoint");
|
||||
return (pendingRequest!, checkpoint!);
|
||||
}
|
||||
|
||||
private static async ValueTask<List<WorkflowEvent>> ReadToHaltAsync(StreamingRun run)
|
||||
{
|
||||
List<WorkflowEvent> events = [];
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
+35
-159
@@ -9,173 +9,49 @@ using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public DirectoryInfo DirectoryInfo { get; }
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
this.DirectoryInfo = Directory.CreateDirectory(tempDirPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.DisposeInternal();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DisposeInternal()
|
||||
{
|
||||
if (this.DirectoryInfo.Exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Best efforts
|
||||
this.DirectoryInfo.Delete(recursive: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
~TempDirectory()
|
||||
{
|
||||
// Best efforts
|
||||
this.DisposeInternal();
|
||||
}
|
||||
|
||||
public static implicit operator DirectoryInfo(TempDirectory tempDirectory) => tempDirectory.DirectoryInfo;
|
||||
|
||||
public string FullName => this.DirectoryInfo.FullName;
|
||||
|
||||
public bool IsParentOf(FileInfo candidate)
|
||||
{
|
||||
if (candidate.Directory is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.Directory.FullName == this.DirectoryInfo.FullName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.IsParentOf(candidate.Directory);
|
||||
}
|
||||
|
||||
public bool IsParentOf(DirectoryInfo candidate)
|
||||
{
|
||||
while (candidate.Parent is not null)
|
||||
{
|
||||
if (candidate.Parent.FullName == this.DirectoryInfo.FullName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
candidate = candidate.Parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public sealed class FileSystemJsonCheckpointStoreTests
|
||||
{
|
||||
public static JsonElement TestData => JsonSerializer.SerializeToElement(new { test = "data" });
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_ShouldPersistIndexToDiskBeforeDisposeAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore? store = new(tempDirectory);
|
||||
DirectoryInfo tempDir = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
|
||||
FileSystemJsonCheckpointStore? store = null;
|
||||
|
||||
string runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, TestData);
|
||||
|
||||
// Assert - Check the file size before disposing to verify data was flushed to disk
|
||||
// The index.jsonl file is held exclusively by the store, so we check via FileInfo
|
||||
string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl");
|
||||
FileInfo indexFile = new(indexPath);
|
||||
indexFile.Refresh();
|
||||
long fileSizeBeforeDispose = indexFile.Length;
|
||||
|
||||
// Data should already be on disk (file size > 0) before we dispose
|
||||
fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync");
|
||||
|
||||
// Dispose to release file lock before final verification
|
||||
store.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(indexPath);
|
||||
lines.Should().HaveCount(1);
|
||||
lines[0].Should().Contain(checkpoint.CheckpointId);
|
||||
}
|
||||
|
||||
private async ValueTask Run_EscapeRootFolderTestAsync(string escapingPath)
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
|
||||
string naivePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, escapingPath);
|
||||
|
||||
// Check that the naive path is actually outside the temp directory to validate the test is meaningful
|
||||
FileInfo naiveCheckpointFile = new(naivePath);
|
||||
tempDirectory.IsParentOf(naiveCheckpointFile).Should().BeFalse("The naive path should be outside the root folder to validate that escaping is necessary.");
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpointInfo = await store.CreateCheckpointAsync(escapingPath, TestData);
|
||||
|
||||
// Assert
|
||||
string naivePathWithCheckpointId = Path.Combine(tempDirectory.DirectoryInfo.FullName, $"{escapingPath}_{checkpointInfo.CheckpointId}.json");
|
||||
new FileInfo(naivePathWithCheckpointId).Exists.Should().BeFalse("The naive path should not be used to save a checkpoint file.");
|
||||
|
||||
string actualFileName = store.GetFileNameForCheckpoint(escapingPath, checkpointInfo);
|
||||
string actualFilePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, actualFileName);
|
||||
FileInfo actualFile = new(actualFilePath);
|
||||
|
||||
tempDirectory.IsParentOf(actualFile).Should().BeTrue("The actual checkpoint should be saved inside the root folder.");
|
||||
actualFile.Exists.Should().BeTrue("The actual path should be used to save a checkpoint file.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_ShouldNotEscapeRootFolderAsync()
|
||||
{
|
||||
// The SessionId is used as part of the file name, but if it contains path characters such as /.. it can escape the root folder.
|
||||
// Testing that such characters are escaped properly to prevent directory traversal attacks, etc.
|
||||
|
||||
await this.Run_EscapeRootFolderTestAsync("../valid_suffix");
|
||||
|
||||
#if !NETFRAMEWORK
|
||||
if (OperatingSystem.IsWindows())
|
||||
try
|
||||
{
|
||||
// Windows allows both \ and / as path separators, so we test both
|
||||
await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix");
|
||||
store = new(tempDir);
|
||||
string runId = Guid.NewGuid().ToString("N");
|
||||
JsonElement testData = JsonSerializer.SerializeToElement(new { test = "data" });
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, testData);
|
||||
|
||||
// Assert - Check the file size before disposing to verify data was flushed to disk
|
||||
// The index.jsonl file is held exclusively by the store, so we check via FileInfo
|
||||
string indexPath = Path.Combine(tempDir.FullName, "index.jsonl");
|
||||
FileInfo indexFile = new(indexPath);
|
||||
indexFile.Refresh();
|
||||
long fileSizeBeforeDispose = indexFile.Length;
|
||||
|
||||
// Data should already be on disk (file size > 0) before we dispose
|
||||
fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync");
|
||||
|
||||
// Dispose to release file lock before final verification
|
||||
store.Dispose();
|
||||
store = null;
|
||||
|
||||
string[] lines = File.ReadAllLines(indexPath);
|
||||
lines.Should().HaveCount(1);
|
||||
lines[0].Should().Contain(checkpoint.CheckpointId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
store?.Dispose();
|
||||
if (tempDir.Exists)
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
#else
|
||||
// .NET Framework is always on Windows
|
||||
await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix");
|
||||
#endif
|
||||
}
|
||||
|
||||
private const string InvalidPathCharsWin32 = "\\/:*?\"<>|";
|
||||
private const string InvalidPathCharsUnix = "/";
|
||||
private const string InvalidPathCharsMacOS = "/:";
|
||||
|
||||
[Theory]
|
||||
[InlineData(InvalidPathCharsWin32)]
|
||||
[InlineData(InvalidPathCharsUnix)]
|
||||
[InlineData(InvalidPathCharsMacOS)]
|
||||
public async Task CreateCheckpointAsync_EscapesInvalidCharsAsync(string invalidChars)
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
|
||||
string runId = $"prefix_{invalidChars}_suffix";
|
||||
|
||||
Func<Task> createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData);
|
||||
await createCheckpointAction.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,55 +673,6 @@ public class JsonSerializationTests
|
||||
ValidateCheckpoint(retrievedCheckpoint, prototype);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, ExternalRequest> pendingRequests = new()
|
||||
{
|
||||
["call-1"] = TestExternalRequest,
|
||||
["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"),
|
||||
};
|
||||
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-123",
|
||||
lastCheckpoint: TestParentCheckpointInfo,
|
||||
pendingRequests: pendingRequests);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().Be(prototype.LastCheckpoint);
|
||||
result.StateBag.Should().NotBeNull();
|
||||
result.PendingRequests.Should().NotBeNull()
|
||||
.And.HaveCount(pendingRequests.Count);
|
||||
|
||||
foreach (string key in pendingRequests.Keys)
|
||||
{
|
||||
result.PendingRequests.Should().ContainKey(key);
|
||||
ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-456",
|
||||
lastCheckpoint: null);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().BeNull();
|
||||
result.PendingRequests.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
||||
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
||||
|
||||
@@ -28,184 +28,6 @@ public sealed class ExpectedException : Exception
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple agent that emits a FunctionCallContent or ToolApprovalRequestContent request.
|
||||
/// Used to test that RequestInfoEvent handling preserves the original content type.
|
||||
/// </summary>
|
||||
internal sealed class RequestEmittingAgent : AIAgent
|
||||
{
|
||||
private readonly AIContent _requestContent;
|
||||
private readonly bool _completeOnResponse;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="RequestEmittingAgent"/> that emits the given request content.
|
||||
/// </summary>
|
||||
/// <param name="requestContent">The content to emit on each turn.</param>
|
||||
/// <param name="completeOnResponse">
|
||||
/// When <see langword="true"/>, the agent emits a text completion instead of re-emitting
|
||||
/// the request when the incoming messages contain a <see cref="FunctionResultContent"/>
|
||||
/// or <see cref="ToolApprovalResponseContent"/>. This models realistic agent behaviour
|
||||
/// where the agent processes the tool result and produces a final answer.
|
||||
/// </param>
|
||||
public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false)
|
||||
{
|
||||
this._requestContent = requestContent;
|
||||
this._completeOnResponse = completeOnResponse;
|
||||
}
|
||||
|
||||
private sealed class Session : AgentSession
|
||||
{
|
||||
public Session() { }
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._completeOnResponse && messages.Any(m => m.Contents.Any(c =>
|
||||
c is FunctionResultContent || c is ToolApprovalResponseContent)))
|
||||
{
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [new TextContent("Request processed")]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Emit the request content
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KickoffOnStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _kickoffInputText;
|
||||
private readonly string _kickoffMessageText;
|
||||
private readonly string _regularResumeText;
|
||||
private readonly string _regularProcessedText;
|
||||
|
||||
public KickoffOnStartExecutor(
|
||||
string id,
|
||||
string downstreamExecutorId,
|
||||
string kickoffInputText,
|
||||
string kickoffMessageText,
|
||||
string regularResumeText,
|
||||
string regularProcessedText)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._kickoffInputText = kickoffInputText;
|
||||
this._kickoffMessageText = kickoffMessageText;
|
||||
this._regularResumeText = regularResumeText;
|
||||
this._regularProcessedText = regularProcessedText;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<string> textContents =
|
||||
[
|
||||
.. messages
|
||||
.SelectMany(message => message.Contents.OfType<TextContent>())
|
||||
.Select(content => content.Text)
|
||||
];
|
||||
|
||||
if (textContents.Contains(this._kickoffInputText, StringComparer.Ordinal))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
new List<ChatMessage> { new(ChatRole.User, this._kickoffMessageText) },
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (textContents.Contains(this._regularResumeText, StringComparer.Ordinal))
|
||||
{
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._regularProcessedText)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A start executor that always emits a response update on every turn,
|
||||
/// useful for verifying that a TurnToken was delivered by the session.
|
||||
/// On the first turn (user messages present), it kicks off a downstream executor.
|
||||
/// </summary>
|
||||
internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _activatedMarker;
|
||||
private int _activationCount;
|
||||
|
||||
/// <summary>Gets the number of times this executor has been activated (i.e., <see cref="TakeTurnAsync"/> called).</summary>
|
||||
public int ActivationCount => this._activationCount;
|
||||
|
||||
public TurnTrackingStartExecutor(string id, string downstreamExecutorId, string activatedMarker)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._activatedMarker = activatedMarker;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._activationCount);
|
||||
|
||||
// On the first turn, forward user messages and a TurnToken to the downstream executor.
|
||||
if (messages.Any(m => m.Role == ChatRole.User))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
messages,
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Always emit a marker to prove this executor was activated.
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._activatedMarker)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowHostSmokeTests
|
||||
{
|
||||
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
|
||||
@@ -290,445 +112,4 @@ public class WorkflowHostSmokeTests
|
||||
|
||||
hadErrorContent.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data,
|
||||
/// the AgentResponseUpdate preserves the original FunctionCallContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CallId = "test-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent originalContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.CallId.Should().NotBe(CallId);
|
||||
retrievedContent.CallId.Should().EndWith($":{CallId}");
|
||||
retrievedContent.Name.Should().Be(FunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with ToolApprovalRequestContent data,
|
||||
/// the AgentResponseUpdate preserves the original ToolApprovalRequestContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string RequestId = "test-request-id";
|
||||
McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost");
|
||||
ToolApprovalRequestContent originalContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
|
||||
updateWithUserInput.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
ToolApprovalRequestContent retrievedContent = updateWithUserInput!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.Should().NotBeNull();
|
||||
retrievedContent.RequestId.Should().NotBe(RequestId);
|
||||
retrievedContent.RequestId.Should().EndWith($":{RequestId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "roundtrip-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
|
||||
FunctionCallContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.First();
|
||||
receivedRequest.CallId.Should().EndWith($":{CallId}");
|
||||
|
||||
// Act 2: Send the response back
|
||||
FunctionResultContent responseContent = new(receivedRequest.CallId, "test result");
|
||||
ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow with the response and capture the resulting updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be processed and the original request should no longer be pending.
|
||||
// Concretely, the workflow should not re-emit a FunctionCallContent with the same CallId.
|
||||
secondCallUpdates.Should().NotBeNull("processing the response should produce updates");
|
||||
secondCallUpdates.Should().NotBeEmpty("processing the response should progress the workflow");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == receivedRequest.CallId, "the external FunctionCallContent request should be cleared after processing the response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip for ToolApprovalRequestContent: workflow emits request, external caller responds.
|
||||
/// Verifying inbound ToolApprovalResponseContent conversion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a ToolApprovalRequestContent request
|
||||
const string RequestId = "roundtrip-request-id";
|
||||
McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost");
|
||||
ToolApprovalRequestContent requestContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the ToolApprovalRequestContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a ToolApprovalRequestContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
updateWithRequest.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
|
||||
ToolApprovalRequestContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.First();
|
||||
receivedRequest.RequestId.Should().EndWith($":{RequestId}");
|
||||
|
||||
// Act 2: Send the response back - use CreateResponse to get the right response type
|
||||
ToolApprovalResponseContent responseContent = receivedRequest.CreateResponse(approved: true);
|
||||
ChatMessage responseMessage = new(ChatRole.User, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow again with the response and capture the updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be applied so that the original request is no longer pending
|
||||
secondCallUpdates.Should().NotBeEmpty("handling the user input response should produce follow-up updates");
|
||||
bool requestStillPresent = secondCallUpdates.Any(u =>
|
||||
u.RawRepresentation is RequestInfoEvent
|
||||
&& u.Contents.OfType<ToolApprovalRequestContent>().Any(r => r.RequestId == receivedRequest.RequestId));
|
||||
requestStillPresent.Should().BeFalse("the original ToolApprovalRequestContent should not be re-emitted after its response is processed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the mixed-message scenario: resume contains both an external response
|
||||
/// (FunctionResultContent matching a pending request) and regular non-response content
|
||||
/// in the same message.
|
||||
/// Verifies that regular content is still processed and that no duplicate
|
||||
/// pending-request errors, redundant FunctionCallContent re-emissions,
|
||||
/// or workflow errors occur.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_BothProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "mixed-call-id";
|
||||
const string FunctionName = "mixedTestFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate requestUpdate = firstCallUpdates.First(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
FunctionCallContent emittedRequest = requestUpdate.Contents.OfType<FunctionCallContent>().Single();
|
||||
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent),
|
||||
"the first call should emit a FunctionCallContent request");
|
||||
|
||||
// Act 2: Send a mixed message containing both the function result AND regular non-response content
|
||||
FunctionResultContent responseContent = new(emittedRequest.CallId, "tool output");
|
||||
ChatMessage mixedMessage = new(ChatRole.Tool, [responseContent, new TextContent("additional context")]);
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(mixedMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The workflow should have processed both parts without errors
|
||||
secondCallUpdates.Should().NotBeEmpty("the mixed message should produce follow-up updates");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the external FunctionCallContent should be cleared after the response is processed");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty("no workflow errors should occur when processing a mixed response-and-regular message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseThenRegularAcrossMessages_NoDuplicateFunctionCallAsync()
|
||||
{
|
||||
const string CallId = "mixed-separate-call-id";
|
||||
const string FunctionName = "mixedSeparateTestFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.Tool, [new TextContent("extra context in separate message")])
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
|
||||
secondCallUpdates.Should().NotBeEmpty();
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "response+regular content split across messages should not re-emit the handled external request");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MatchingResponse_DoesNotCauseExtraTurnAsync()
|
||||
{
|
||||
const string CallId = "matching-response-call-id";
|
||||
const string FunctionName = "matchingResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == emittedRequest.CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "a matching external response should not trigger an extra TurnToken-driven turn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_CrossExecutorStartExecutorIsReawakenedAsync()
|
||||
{
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string KickoffInputText = "Start";
|
||||
const string KickoffMessageText = "kickoff downstream";
|
||||
const string ResumeRegularText = "resume regular";
|
||||
const string ResumeProcessedText = "regular message processed";
|
||||
const string CallId = "cross-executor-call-id";
|
||||
const string FunctionName = "crossExecutorFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
KickoffOnStartExecutor startExecutor = new(
|
||||
StartExecutorId,
|
||||
requestBinding.Id,
|
||||
KickoffInputText,
|
||||
KickoffMessageText,
|
||||
ResumeRegularText,
|
||||
ResumeProcessedText);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(message => message.Contents.OfType<TextContent>().Any(content => content.Text == KickoffMessageText)) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, KickoffInputText),
|
||||
session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.User, ResumeRegularText)
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
List<string> textContents = [.. secondCallUpdates.SelectMany(update => update.Contents.OfType<TextContent>()).Select(content => content.Text)];
|
||||
|
||||
textContents.Should().Contain(ResumeProcessedText, "the start executor should receive an explicit TurnToken when the matched response wakes a different executor");
|
||||
textContents.Should().Contain("Request processed", "the matched external response should still be delivered to the downstream request owner");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the handled external request should not be re-emitted while waking the start executor");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_UnmatchedResponse_TriggersTurnAndKeepsProgressingAsync()
|
||||
{
|
||||
const string CallId = "unmatched-response-call-id";
|
||||
const string FunctionName = "unmatchedResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("different-call-id", "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "an unmatched response should be treated as regular input and still drive a TurnToken continuation without workflow errors");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a resume contains only an external response directed at a non-start executor
|
||||
/// (no regular messages), the start executor still receives a TurnToken and is activated.
|
||||
/// This is a regression test for the case where the TurnToken was previously skipped because
|
||||
/// <c>HasRegularMessages</c> was <see langword="false"/>, leaving the start executor dormant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseOnlyToNonStartExecutor_StartExecutorIsStillActivatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string ActivatedMarker = "start-executor-activated";
|
||||
const string CallId = "response-only-call-id";
|
||||
const string FunctionName = "responseOnlyFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
TurnTrackingStartExecutor startExecutor = new(StartExecutorId, requestBinding.Id, ActivatedMarker);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(m => m.Contents.OfType<TextContent>().Any()) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call triggers the downstream FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
// Act 2: Resume with ONLY the external response (no regular messages)
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert: Both the downstream and start executor should have been activated
|
||||
List<string> textContents = [.. secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<TextContent>())
|
||||
.Select(c => c.Text)];
|
||||
|
||||
textContents.Should().Contain("Request processed",
|
||||
"the downstream executor should process the external response");
|
||||
textContents.Should().Contain(ActivatedMarker,
|
||||
"the start executor should receive a TurnToken and be activated even when resume contains only an external response");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -24,14 +24,14 @@ If you only need specific integrations, you can install at a more granular level
|
||||
# also includes workflows and orchestrations
|
||||
pip install agent-framework-core --pre
|
||||
|
||||
# Core + Azure AI Foundry integration
|
||||
pip install agent-framework-foundry --pre
|
||||
# Core + Azure AI integration
|
||||
pip install agent-framework-azure-ai --pre
|
||||
|
||||
# Core + Microsoft Copilot Studio integration
|
||||
pip install agent-framework-copilotstudio --pre
|
||||
|
||||
# Core + both Microsoft Copilot Studio and Azure AI Foundry integration
|
||||
pip install agent-framework-microsoft agent-framework-foundry --pre
|
||||
# Core + both Microsoft Copilot Studio and Azure AI integration
|
||||
pip install agent-framework-microsoft agent-framework-azure-ai --pre
|
||||
```
|
||||
|
||||
This selective approach is useful when you know which integrations you need, and it is the recommended way to set up lightweight environments.
|
||||
@@ -53,8 +53,8 @@ AZURE_OPENAI_API_KEY=...
|
||||
AZURE_OPENAI_ENDPOINT=...
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
|
||||
...
|
||||
FOUNDRY_PROJECT_ENDPOINT=...
|
||||
FOUNDRY_MODEL=...
|
||||
AZURE_AI_PROJECT_ENDPOINT=...
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=...
|
||||
```
|
||||
|
||||
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
||||
|
||||
@@ -15,7 +15,7 @@ The Azure AI Search integration provides context providers for RAG (Retrieval Au
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [Azure AI Search context provider examples](../../samples/02-agents/context_providers/azure_ai_search/) which demonstrate:
|
||||
See the [Azure AI Search context provider examples](../../samples/02-agents/providers/azure_ai/) which demonstrate:
|
||||
|
||||
- Semantic search with hybrid (vector + keyword) queries
|
||||
- Agentic mode with Knowledge Bases for complex multi-hop reasoning
|
||||
|
||||
+2
-3
@@ -16,7 +16,8 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Annotation, Content, Message, SupportsGetEmbeddings
|
||||
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from azure.core.credentials import AzureKeyCredential, TokenCredential
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.search.documents.aio import SearchClient
|
||||
@@ -110,8 +111,6 @@ try:
|
||||
except ImportError:
|
||||
_agentic_retrieval_available = False
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger("agent_framework.azure_ai_search")
|
||||
|
||||
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
|
||||
|
||||
@@ -2,35 +2,23 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent_provider import AzureAIAgentsProvider # pyright: ignore[reportDeprecated]
|
||||
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated]
|
||||
from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient # pyright: ignore[reportDeprecated]
|
||||
from ._deprecated_azure_openai import (
|
||||
AzureOpenAIAssistantsClient, # pyright: ignore[reportDeprecated]
|
||||
AzureOpenAIAssistantsOptions,
|
||||
AzureOpenAIChatClient, # pyright: ignore[reportDeprecated]
|
||||
AzureOpenAIChatOptions,
|
||||
AzureOpenAIConfigMixin,
|
||||
AzureOpenAIEmbeddingClient, # pyright: ignore[reportDeprecated]
|
||||
AzureOpenAIResponsesClient, # pyright: ignore[reportDeprecated]
|
||||
AzureOpenAIResponsesOptions,
|
||||
AzureOpenAISettings,
|
||||
AzureUserSecurityContext,
|
||||
)
|
||||
from ._agent_provider import AzureAIAgentsProvider
|
||||
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions
|
||||
from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient
|
||||
from ._embedding_client import (
|
||||
AzureAIInferenceEmbeddingClient,
|
||||
AzureAIInferenceEmbeddingOptions,
|
||||
AzureAIInferenceEmbeddingSettings,
|
||||
RawAzureAIInferenceEmbeddingClient,
|
||||
)
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated]
|
||||
from ._foundry_memory_provider import FoundryMemoryProvider
|
||||
from ._project_provider import AzureAIProjectAgentProvider
|
||||
from ._shared import AzureAISettings
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"AzureAIAgentClient",
|
||||
@@ -43,18 +31,7 @@ __all__ = [
|
||||
"AzureAIProjectAgentOptions",
|
||||
"AzureAIProjectAgentProvider",
|
||||
"AzureAISettings",
|
||||
"AzureCredentialTypes",
|
||||
"AzureOpenAIAssistantsClient",
|
||||
"AzureOpenAIAssistantsOptions",
|
||||
"AzureOpenAIChatClient",
|
||||
"AzureOpenAIChatOptions",
|
||||
"AzureOpenAIConfigMixin",
|
||||
"AzureOpenAIEmbeddingClient",
|
||||
"AzureOpenAIResponsesClient",
|
||||
"AzureOpenAIResponsesOptions",
|
||||
"AzureOpenAISettings",
|
||||
"AzureTokenProvider",
|
||||
"AzureUserSecurityContext",
|
||||
"FoundryMemoryProvider",
|
||||
"RawAzureAIClient",
|
||||
"RawAzureAIInferenceEmbeddingClient",
|
||||
"__version__",
|
||||
|
||||
@@ -18,23 +18,19 @@ from agent_framework import (
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.agents.models import Agent as AzureAgent
|
||||
from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated]
|
||||
from ._entra_id_authentication import AzureCredentialTypes
|
||||
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions
|
||||
from ._shared import AzureAISettings, to_azure_ai_agent_tools
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import Self, TypeVar # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self, TypeVar # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 13):
|
||||
from warnings import deprecated # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import deprecated # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -51,11 +47,6 @@ OptionsCoT = TypeVar(
|
||||
)
|
||||
|
||||
|
||||
@deprecated(
|
||||
"AzureAIAgentClient and the AzureAIAgentsProvider are deprecated. "
|
||||
"They target the V1 Agents Service API and have no direct replacement; "
|
||||
"for new Foundry projects, use FoundryAgent."
|
||||
)
|
||||
class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
"""Provider for Azure AI Agent Service V1 (Persistent Agents API).
|
||||
|
||||
@@ -435,7 +426,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
context_providers: Context providers to include during agent invocation.
|
||||
"""
|
||||
# Create the underlying client
|
||||
client = AzureAIAgentClient( # pyright: ignore[reportDeprecated]
|
||||
client = AzureAIAgentClient(
|
||||
agents_client=self._agents_client,
|
||||
agent_id=agent.id,
|
||||
agent_name=agent.name,
|
||||
|
||||
@@ -36,6 +36,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import (
|
||||
ChatClientException,
|
||||
ChatClientInvalidRequestException,
|
||||
@@ -91,14 +92,12 @@ from azure.ai.agents.models import (
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._entra_id_authentication import AzureCredentialTypes
|
||||
from ._shared import AzureAISettings, resolve_file_ids, to_azure_ai_agent_tools
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
from warnings import deprecated # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
|
||||
from typing_extensions import TypeVar # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -211,11 +210,6 @@ AzureAIAgentOptionsT = TypeVar(
|
||||
# endregion
|
||||
|
||||
|
||||
@deprecated(
|
||||
"AzureAIAgentClient is deprecated. "
|
||||
"It targets the V1 Agents Service API and has no direct replacement; "
|
||||
"for new Foundry projects, use FoundryAgent."
|
||||
)
|
||||
class AzureAIAgentClient(
|
||||
FunctionInvocationLayer[AzureAIAgentOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIAgentOptionsT],
|
||||
@@ -227,8 +221,7 @@ class AzureAIAgentClient(
|
||||
|
||||
.. deprecated::
|
||||
AzureAIAgentClient is deprecated and will be removed in a future release.
|
||||
It targets the V1 Agents Service API and has no direct replacement.
|
||||
For new Foundry projects, use :class:`FoundryAgent`.
|
||||
Use :class:`AzureAIClient` instead for the V2 (Projects/Responses) API.
|
||||
"""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
@@ -246,8 +239,7 @@ class AzureAIAgentClient(
|
||||
|
||||
.. deprecated::
|
||||
This method is deprecated and will be removed in a future release.
|
||||
For new Foundry projects, configure hosted tools on the Foundry agent definition
|
||||
in the service instead.
|
||||
Use :meth:`AzureAIClient.get_code_interpreter_tool` instead.
|
||||
|
||||
Keyword Args:
|
||||
file_ids: List of uploaded file IDs or Content objects to make available to
|
||||
@@ -280,7 +272,7 @@ class AzureAIAgentClient(
|
||||
"""
|
||||
warnings.warn(
|
||||
"AzureAIAgentClient.get_code_interpreter_tool() is deprecated and will be removed in a future release; "
|
||||
"for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.",
|
||||
"use AzureAIClient.get_code_interpreter_tool() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
@@ -296,8 +288,7 @@ class AzureAIAgentClient(
|
||||
|
||||
.. deprecated::
|
||||
This method is deprecated and will be removed in a future release.
|
||||
For new Foundry projects, configure hosted tools on the Foundry agent definition
|
||||
in the service instead.
|
||||
Use :meth:`AzureAIClient.get_file_search_tool` instead.
|
||||
|
||||
Keyword Args:
|
||||
vector_store_ids: List of vector store IDs to search within.
|
||||
@@ -317,7 +308,7 @@ class AzureAIAgentClient(
|
||||
"""
|
||||
warnings.warn(
|
||||
"AzureAIAgentClient.get_file_search_tool() is deprecated and will be removed in a future release; "
|
||||
"for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.",
|
||||
"use AzureAIClient.get_file_search_tool() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
@@ -334,8 +325,7 @@ class AzureAIAgentClient(
|
||||
|
||||
.. deprecated::
|
||||
This method is deprecated and will be removed in a future release.
|
||||
For new Foundry projects, configure hosted tools on the Foundry agent definition
|
||||
in the service instead.
|
||||
Use :meth:`AzureAIClient.get_web_search_tool` instead.
|
||||
|
||||
For Azure AI Agents, web search uses Bing Grounding or Bing Custom Search.
|
||||
If no arguments are provided, attempts to read from environment variables.
|
||||
@@ -379,7 +369,7 @@ class AzureAIAgentClient(
|
||||
"""
|
||||
warnings.warn(
|
||||
"AzureAIAgentClient.get_web_search_tool() is deprecated and will be removed in a future release; "
|
||||
"for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.",
|
||||
"use AzureAIClient.get_web_search_tool() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
@@ -420,8 +410,7 @@ class AzureAIAgentClient(
|
||||
|
||||
.. deprecated::
|
||||
This method is deprecated and will be removed in a future release.
|
||||
For new Foundry projects, configure hosted tools on the Foundry agent definition
|
||||
in the service instead.
|
||||
Use :meth:`AzureAIClient.get_mcp_tool` instead.
|
||||
|
||||
This configures an MCP (Model Context Protocol) server that will be called
|
||||
by Azure AI's service. The tools from this MCP server are executed remotely
|
||||
@@ -457,7 +446,7 @@ class AzureAIAgentClient(
|
||||
"""
|
||||
warnings.warn(
|
||||
"AzureAIAgentClient.get_mcp_tool() is deprecated and will be removed in a future release; "
|
||||
"for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.",
|
||||
"use AzureAIClient.get_mcp_tool() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
@@ -572,6 +561,12 @@ class AzureAIAgentClient(
|
||||
client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential)
|
||||
response = await client.get_response("Hello", options={"my_custom_option": "value"})
|
||||
"""
|
||||
warnings.warn(
|
||||
"AzureAIAgentClient is deprecated and will be removed in a future release; "
|
||||
"use AzureAIClient instead for the V2 (Projects/Responses) API.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
azure_ai_settings = load_settings(
|
||||
AzureAISettings,
|
||||
env_prefix="AZURE_AI_",
|
||||
|
||||
@@ -30,9 +30,10 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework.openai import OpenAIResponsesOptions
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from agent_framework.openai._responses_client import RawOpenAIResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
ApproximateLocation,
|
||||
@@ -49,14 +50,12 @@ from azure.ai.projects.models import (
|
||||
from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
from ._entra_id_authentication import AzureCredentialTypes
|
||||
from ._shared import AzureAISettings, create_text_format_config, resolve_file_ids
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
from warnings import deprecated # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
|
||||
from typing_extensions import TypeVar # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -69,7 +68,7 @@ else:
|
||||
logger = logging.getLogger("agent_framework.azure")
|
||||
|
||||
|
||||
class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): # type: ignore[misc, call-arg]
|
||||
class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False):
|
||||
"""Azure AI Project Agent options."""
|
||||
|
||||
rai_config: RaiConfig
|
||||
@@ -89,13 +88,8 @@ AzureAIClientOptionsT = TypeVar(
|
||||
_DOC_INDEX_PATTERN = re.compile(r"doc_(\d+)")
|
||||
|
||||
|
||||
@deprecated(
|
||||
"RawAzureAIClient is deprecated. "
|
||||
"Use RawFoundryAgentChatClient for low-level Foundry agent client customization, "
|
||||
"or FoundryAgent for the recommended production API."
|
||||
)
|
||||
class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]):
|
||||
"""Deprecated raw Azure AI client without middleware, telemetry, or function invocation layers.
|
||||
class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]):
|
||||
"""Raw Azure AI client without middleware, telemetry, or function invocation layers.
|
||||
|
||||
Warning:
|
||||
**This class should not normally be used directly.** It does not include middleware,
|
||||
@@ -107,8 +101,7 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
|
||||
|
||||
Use ``RawFoundryAgentChatClient`` for low-level Foundry agent customization, or
|
||||
``FoundryAgent`` for the recommended production API.
|
||||
Use ``AzureAIClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
@@ -222,10 +215,8 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
should_close_client = True
|
||||
|
||||
# Initialize parent with OpenAI client from project
|
||||
super().__init__( # type: ignore
|
||||
async_client=project_client.get_openai_client(),
|
||||
model=azure_ai_settings.get("model"), # type: ignore[arg-type]
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
@@ -689,6 +680,10 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
|
||||
return result, instructions
|
||||
|
||||
async def _initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client."""
|
||||
self.client = self.project_client.get_openai_client() # type: ignore
|
||||
|
||||
def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None:
|
||||
"""Update the agent name in the chat client.
|
||||
|
||||
@@ -847,7 +842,7 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
if not stream:
|
||||
|
||||
async def _enrich_response() -> ChatResponse:
|
||||
response = await super(RawAzureAIClient, self)._inner_get_response( # pyright: ignore[reportDeprecated]
|
||||
response = await super(RawAzureAIClient, self)._inner_get_response(
|
||||
messages=messages, options=options, stream=False, **kwargs
|
||||
)
|
||||
get_urls = self._extract_azure_search_urls(response.raw_representation.output) # type: ignore[union-attr]
|
||||
@@ -1187,8 +1182,8 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
It does NOT create an agent on the Azure AI service - the actual agent
|
||||
will be created on the server during the first invocation (run).
|
||||
|
||||
For working with pre-configured persistent agents on the server, use
|
||||
:class:`~agent_framework_azure_ai.FoundryAgent` instead.
|
||||
For creating and managing persistent agents on the server, use
|
||||
:class:`~agent_framework_azure_ai.AzureAIProjectAgentProvider` instead.
|
||||
|
||||
Keyword Args:
|
||||
id: The unique identifier for the agent. Will be created automatically if not provided.
|
||||
@@ -1218,23 +1213,21 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure
|
||||
)
|
||||
|
||||
|
||||
@deprecated("AzureAIClient is deprecated. Use FoundryAgent instead.")
|
||||
class AzureAIClient(
|
||||
FunctionInvocationLayer[AzureAIClientOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIClientOptionsT],
|
||||
ChatTelemetryLayer[AzureAIClientOptionsT],
|
||||
RawAzureAIClient[AzureAIClientOptionsT], # pyright: ignore[reportDeprecated]
|
||||
RawAzureAIClient[AzureAIClientOptionsT],
|
||||
Generic[AzureAIClientOptionsT],
|
||||
):
|
||||
"""Deprecated Azure AI client with middleware, telemetry, and function invocation support.
|
||||
"""Azure AI client with middleware, telemetry, and function invocation support.
|
||||
|
||||
This class is deprecated. Use ``FoundryAgent`` instead for connecting to
|
||||
pre-configured agents in Foundry. It includes:
|
||||
This is the recommended client for most use cases. It includes:
|
||||
- Chat middleware support for request/response interception
|
||||
- OpenTelemetry-based telemetry for observability
|
||||
- Automatic function/tool invocation handling
|
||||
|
||||
For a minimal implementation without these features, use :class:`RawFoundryAgentChatClient`.
|
||||
For a minimal implementation without these features, use :class:`RawAzureAIClient`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -1,897 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Deprecated Azure OpenAI client classes.
|
||||
|
||||
All classes in this module are deprecated and will be removed in a future release.
|
||||
Migrate to the ``agent_framework_openai`` package equivalents with an ``AsyncAzureOpenAI`` client,
|
||||
or use ``FoundryChatClient`` for Azure AI Foundry projects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from agent_framework._middleware import ChatMiddlewareLayer
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, prepend_agent_framework_to_user_agent
|
||||
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
|
||||
from agent_framework._types import Annotation, Content
|
||||
from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer
|
||||
from agent_framework_openai._assistants_client import OpenAIAssistantsClient, OpenAIAssistantsOptions
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient
|
||||
from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient
|
||||
from agent_framework_openai._shared import OpenAIBase
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from openai import AsyncOpenAI
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
from warnings import deprecated # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._middleware import MiddlewareTypes
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Constants and Settings
|
||||
|
||||
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21"
|
||||
DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105
|
||||
|
||||
|
||||
class AzureOpenAISettings(TypedDict, total=False):
|
||||
"""AzureOpenAI model settings.
|
||||
|
||||
Settings are resolved in this order: explicit keyword arguments, values from an
|
||||
explicitly provided .env file, then environment variables with the prefix
|
||||
'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail.
|
||||
|
||||
Keyword Args:
|
||||
endpoint: The endpoint of the Azure deployment.
|
||||
chat_deployment_name: The name of the Azure Chat deployment.
|
||||
responses_deployment_name: The name of the Azure Responses deployment.
|
||||
embedding_deployment_name: The name of the Azure Embedding deployment.
|
||||
api_key: The API key for the Azure deployment.
|
||||
api_version: The API version to use.
|
||||
base_url: The url of the Azure deployment.
|
||||
token_endpoint: The token endpoint to use to retrieve the authentication token.
|
||||
"""
|
||||
|
||||
chat_deployment_name: str | None
|
||||
responses_deployment_name: str | None
|
||||
embedding_deployment_name: str | None
|
||||
endpoint: str | None
|
||||
base_url: str | None
|
||||
api_key: SecretString | None
|
||||
api_version: str | None
|
||||
token_endpoint: str | None
|
||||
|
||||
|
||||
def _apply_azure_defaults(
|
||||
settings: AzureOpenAISettings,
|
||||
default_api_version: str = DEFAULT_AZURE_API_VERSION,
|
||||
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT,
|
||||
) -> None:
|
||||
"""Apply default values for api_version and token_endpoint after loading settings.
|
||||
|
||||
Args:
|
||||
settings: The loaded Azure OpenAI settings dict.
|
||||
default_api_version: The default API version to use if not set.
|
||||
default_token_endpoint: The default token endpoint to use if not set.
|
||||
"""
|
||||
if not settings.get("api_version"):
|
||||
settings["api_version"] = default_api_version
|
||||
if not settings.get("token_endpoint"):
|
||||
settings["token_endpoint"] = default_token_endpoint
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region AzureOpenAIConfigMixin
|
||||
|
||||
|
||||
class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
"""Internal class for configuring a connection to an Azure OpenAI service."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deployment_name: str,
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str = DEFAULT_AZURE_API_VERSION,
|
||||
api_key: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
instruction_role: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Configure a connection to an Azure OpenAI service.
|
||||
|
||||
Args:
|
||||
deployment_name: Name of the deployment.
|
||||
endpoint: The specific endpoint URL for the deployment.
|
||||
base_url: The base URL for Azure services.
|
||||
api_version: Azure API version.
|
||||
api_key: API key for Azure services.
|
||||
token_endpoint: Azure AD token scope.
|
||||
credential: Azure credential or token provider for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
client: An existing client to use.
|
||||
instruction_role: The role to use for 'instruction' messages.
|
||||
kwargs: Additional keyword arguments.
|
||||
"""
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
||||
if not client:
|
||||
ad_token_provider = None
|
||||
if not api_key and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint)
|
||||
|
||||
if not api_key and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
if not endpoint and not base_url:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
args: dict[str, Any] = {
|
||||
"default_headers": merged_headers,
|
||||
}
|
||||
if api_version:
|
||||
args["api_version"] = api_version
|
||||
if ad_token_provider:
|
||||
args["azure_ad_token_provider"] = ad_token_provider
|
||||
if api_key:
|
||||
args["api_key"] = api_key
|
||||
if base_url:
|
||||
args["base_url"] = str(base_url)
|
||||
if endpoint and not base_url:
|
||||
args["azure_endpoint"] = str(endpoint)
|
||||
if deployment_name:
|
||||
args["azure_deployment"] = deployment_name
|
||||
if "websocket_base_url" in kwargs:
|
||||
args["websocket_base_url"] = kwargs.pop("websocket_base_url")
|
||||
|
||||
client = AsyncAzureOpenAI(**args)
|
||||
|
||||
self.endpoint = str(endpoint)
|
||||
self.base_url = str(base_url)
|
||||
self.api_version = api_version
|
||||
self.deployment_name = deployment_name
|
||||
self.instruction_role = instruction_role
|
||||
if default_headers:
|
||||
from agent_framework._telemetry import USER_AGENT_KEY
|
||||
|
||||
def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY}
|
||||
else:
|
||||
def_headers = None
|
||||
self.default_headers = def_headers
|
||||
|
||||
super().__init__(model_id=deployment_name, client=client, **kwargs)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region AzureOpenAIResponsesClient
|
||||
|
||||
|
||||
AzureOpenAIResponsesOptionsT = TypeVar(
|
||||
"AzureOpenAIResponsesOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="OpenAIChatOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
AzureOpenAIResponsesOptions = OpenAIChatOptions
|
||||
|
||||
|
||||
@deprecated(
|
||||
"AzureOpenAIResponsesClient is deprecated. "
|
||||
"Use OpenAIChatClient with an AsyncAzureOpenAI client, or FoundryChatClient for Foundry projects."
|
||||
)
|
||||
class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
FunctionInvocationLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIResponsesOptionsT],
|
||||
RawOpenAIChatClient[AzureOpenAIResponsesOptionsT],
|
||||
Generic[AzureOpenAIResponsesOptionsT],
|
||||
):
|
||||
"""Deprecated Azure Responses client. Use OpenAIChatClient with an AsyncAzureOpenAI client instead."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
project_client: Any | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Azure OpenAI Responses client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The API key.
|
||||
deployment_name: The deployment name.
|
||||
endpoint: The deployment endpoint.
|
||||
base_url: The deployment base URL.
|
||||
api_version: The deployment API version.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
credential: Azure credential or token provider for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
project_client: An existing AIProjectClient to use.
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL.
|
||||
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
instruction_role: The role to use for 'instruction' messages.
|
||||
middleware: Optional sequence of middleware.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if (model_id := kwargs.pop("model_id", None)) and not deployment_name:
|
||||
deployment_name = str(model_id)
|
||||
|
||||
if async_client is None and (project_client is not None or project_endpoint is not None):
|
||||
async_client = self._create_client_from_project(
|
||||
project_client=project_client,
|
||||
project_endpoint=project_endpoint,
|
||||
credential=credential,
|
||||
allow_preview=allow_preview,
|
||||
)
|
||||
|
||||
azure_openai_settings = load_settings(
|
||||
AzureOpenAISettings,
|
||||
env_prefix="AZURE_OPENAI_",
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
responses_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
_apply_azure_defaults(azure_openai_settings, default_api_version="preview")
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
if (
|
||||
not azure_openai_settings.get("base_url")
|
||||
and endpoint_value
|
||||
and (hostname := urlparse(str(endpoint_value)).hostname)
|
||||
and hostname.endswith(".openai.azure.com")
|
||||
):
|
||||
azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/")
|
||||
|
||||
responses_deployment_name = azure_openai_settings.get("responses_deployment_name")
|
||||
if not responses_deployment_name:
|
||||
raise ValueError(
|
||||
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
|
||||
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
||||
|
||||
api_key_secret = azure_openai_settings.get("api_key")
|
||||
ad_token_provider = None
|
||||
if not api_key_secret and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(
|
||||
credential, azure_openai_settings.get("token_endpoint")
|
||||
)
|
||||
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
client_endpoint = azure_openai_settings.get("endpoint")
|
||||
client_base_url = azure_openai_settings.get("base_url")
|
||||
if not client_endpoint and not client_base_url:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
client_args: dict[str, Any] = {"default_headers": merged_headers}
|
||||
if resolved_api_version := azure_openai_settings.get("api_version"):
|
||||
client_args["api_version"] = resolved_api_version
|
||||
if ad_token_provider:
|
||||
client_args["azure_ad_token_provider"] = ad_token_provider
|
||||
if api_key_secret:
|
||||
client_args["api_key"] = api_key_secret.get_secret_value()
|
||||
if client_base_url:
|
||||
client_args["base_url"] = str(client_base_url)
|
||||
if client_endpoint and not client_base_url:
|
||||
client_args["azure_endpoint"] = str(client_endpoint)
|
||||
if responses_deployment_name:
|
||||
client_args["azure_deployment"] = responses_deployment_name
|
||||
if "websocket_base_url" in kwargs:
|
||||
client_args["websocket_base_url"] = kwargs.pop("websocket_base_url")
|
||||
|
||||
async_client = AsyncAzureOpenAI(**client_args)
|
||||
|
||||
# Store Azure-specific attributes for serialization
|
||||
self.endpoint = str(endpoint_value) if endpoint_value else None
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = responses_deployment_name
|
||||
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=responses_deployment_name,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_client_from_project(
|
||||
*,
|
||||
project_client: AIProjectClient | None,
|
||||
project_endpoint: str | None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None,
|
||||
allow_preview: bool | None = None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Create an AsyncOpenAI client from an Azure AI Foundry project."""
|
||||
if project_client is not None:
|
||||
return project_client.get_openai_client()
|
||||
|
||||
if not project_endpoint:
|
||||
raise ValueError("Azure AI project endpoint is required when project_client is not provided.")
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": project_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
return project_client.get_openai_client()
|
||||
|
||||
@override
|
||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||
if not options.get("model"):
|
||||
if not self.model:
|
||||
raise ValueError("deployment_name must be a non-empty string")
|
||||
options["model"] = self.model
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region AzureOpenAIChatClient
|
||||
|
||||
|
||||
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
|
||||
|
||||
|
||||
class AzureUserSecurityContext(TypedDict, total=False):
|
||||
"""User security context for Azure AI applications.
|
||||
|
||||
These fields help security operations teams investigate and mitigate security
|
||||
incidents by providing context about the application and end user.
|
||||
"""
|
||||
|
||||
application_name: str
|
||||
"""Name of the application making the request."""
|
||||
|
||||
end_user_id: str
|
||||
"""Unique identifier for the end user (recommend hashing username/email)."""
|
||||
|
||||
end_user_tenant_id: str
|
||||
"""Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps."""
|
||||
|
||||
source_ip: str
|
||||
"""The original client's IP address."""
|
||||
|
||||
|
||||
class AzureOpenAIChatOptions(OpenAIChatCompletionOptions[ResponseModelT], Generic[ResponseModelT], total=False):
|
||||
"""Azure OpenAI-specific chat options dict.
|
||||
|
||||
Extends OpenAIChatCompletionOptions with Azure-specific options including
|
||||
the "On Your Data" feature and enhanced security context.
|
||||
"""
|
||||
|
||||
data_sources: list[dict[str, Any]]
|
||||
"""Azure "On Your Data" data sources for retrieval-augmented generation."""
|
||||
|
||||
user_security_context: AzureUserSecurityContext
|
||||
"""Enhanced security context for Azure Defender integration."""
|
||||
|
||||
n: int
|
||||
"""Number of chat completion choices to generate for each input message."""
|
||||
|
||||
|
||||
AzureOpenAIChatOptionsT = TypeVar(
|
||||
"AzureOpenAIChatOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="AzureOpenAIChatOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
|
||||
@deprecated("AzureOpenAIChatClient is deprecated. Use OpenAIChatCompletionClient with an AsyncAzureOpenAI client.")
|
||||
class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
FunctionInvocationLayer[AzureOpenAIChatOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIChatOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIChatOptionsT],
|
||||
RawOpenAIChatCompletionClient[AzureOpenAIChatOptionsT],
|
||||
Generic[AzureOpenAIChatOptionsT],
|
||||
):
|
||||
"""Deprecated Azure OpenAI Chat client. Use OpenAIChatCompletionClient with AsyncAzureOpenAI instead."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
) -> None:
|
||||
"""Initialize an Azure OpenAI Chat completion client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The API key.
|
||||
deployment_name: The deployment name.
|
||||
endpoint: The deployment endpoint.
|
||||
base_url: The deployment base URL.
|
||||
api_version: The deployment API version.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
credential: Azure credential or token provider for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
instruction_role: The role to use for 'instruction' messages.
|
||||
middleware: Optional sequence of middleware.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
"""
|
||||
azure_openai_settings = load_settings(
|
||||
AzureOpenAISettings,
|
||||
env_prefix="AZURE_OPENAI_",
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
chat_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
_apply_azure_defaults(azure_openai_settings)
|
||||
|
||||
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
|
||||
if not chat_deployment_name:
|
||||
raise ValueError(
|
||||
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
|
||||
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
||||
|
||||
api_key_secret = azure_openai_settings.get("api_key")
|
||||
ad_token_provider = None
|
||||
if not api_key_secret and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(
|
||||
credential, azure_openai_settings.get("token_endpoint")
|
||||
)
|
||||
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not endpoint_value and not base_url_value:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
client_args: dict[str, Any] = {"default_headers": merged_headers}
|
||||
if resolved_api_version := azure_openai_settings.get("api_version"):
|
||||
client_args["api_version"] = resolved_api_version
|
||||
if ad_token_provider:
|
||||
client_args["azure_ad_token_provider"] = ad_token_provider
|
||||
if api_key_secret:
|
||||
client_args["api_key"] = api_key_secret.get_secret_value()
|
||||
if base_url_value:
|
||||
client_args["base_url"] = str(base_url_value)
|
||||
if endpoint_value and not base_url_value:
|
||||
client_args["azure_endpoint"] = str(endpoint_value)
|
||||
if chat_deployment_name:
|
||||
client_args["azure_deployment"] = chat_deployment_name
|
||||
|
||||
async_client = AsyncAzureOpenAI(**client_args)
|
||||
|
||||
# Store Azure-specific attributes for serialization
|
||||
self.endpoint = str(azure_openai_settings.get("endpoint") or "")
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = chat_deployment_name
|
||||
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=chat_deployment_name,
|
||||
api_version=azure_openai_settings.get("api_version"),
|
||||
instruction_role=instruction_role,
|
||||
default_headers=default_headers,
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware, # type: ignore[arg-type]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
|
||||
@override
|
||||
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
|
||||
"""Parse the choice into a Content object with type='text'.
|
||||
|
||||
Overwritten from RawOpenAIChatCompletionClient to deal with Azure On Your Data function.
|
||||
"""
|
||||
message = getattr(choice, "message", None)
|
||||
if message is None:
|
||||
message = getattr(choice, "delta", None)
|
||||
if message is None: # type: ignore
|
||||
return None
|
||||
if hasattr(message, "refusal") and message.refusal:
|
||||
return Content.from_text(text=message.refusal, raw_representation=choice)
|
||||
if not message.content:
|
||||
return None
|
||||
text_content = Content.from_text(text=message.content, raw_representation=choice)
|
||||
if not message.model_extra or "context" not in message.model_extra:
|
||||
return text_content
|
||||
|
||||
context_raw: object = cast(object, message.context) # type: ignore[union-attr]
|
||||
if isinstance(context_raw, str):
|
||||
try:
|
||||
context_raw = json.loads(context_raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Context is not a valid JSON string, ignoring context.")
|
||||
return text_content
|
||||
if not isinstance(context_raw, dict):
|
||||
logger.warning("Context is not a valid dictionary, ignoring context.")
|
||||
return text_content
|
||||
context = cast(dict[str, Any], context_raw)
|
||||
if intent := context.get("intent"):
|
||||
text_content.additional_properties = {"intent": intent}
|
||||
citations = context.get("citations")
|
||||
if isinstance(citations, list) and citations:
|
||||
annotations: list[Annotation] = []
|
||||
for citation_raw in cast(list[object], citations):
|
||||
if not isinstance(citation_raw, dict):
|
||||
continue
|
||||
citation = cast(dict[str, Any], citation_raw)
|
||||
annotations.append(
|
||||
Annotation(
|
||||
type="citation",
|
||||
title=citation.get("title", ""),
|
||||
url=citation.get("url", ""),
|
||||
snippet=citation.get("content", ""),
|
||||
file_id=citation.get("filepath", ""),
|
||||
tool_name="Azure-on-your-Data",
|
||||
additional_properties={"chunk_id": citation.get("chunk_id", "")},
|
||||
raw_representation=citation,
|
||||
)
|
||||
)
|
||||
text_content.annotations = annotations
|
||||
return text_content
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region AzureOpenAIAssistantsClient
|
||||
|
||||
|
||||
AzureOpenAIAssistantsOptionsT = TypeVar(
|
||||
"AzureOpenAIAssistantsOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="OpenAIAssistantsOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions
|
||||
|
||||
|
||||
@deprecated(
|
||||
"AzureOpenAIAssistantsClient is deprecated. "
|
||||
"Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient."
|
||||
)
|
||||
class AzureOpenAIAssistantsClient(
|
||||
OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT]
|
||||
):
|
||||
"""Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient."""
|
||||
|
||||
DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
deployment_name: str | None = None,
|
||||
assistant_id: str | None = None,
|
||||
assistant_name: str | None = None,
|
||||
assistant_description: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize an Azure OpenAI Assistants client.
|
||||
|
||||
Keyword Args:
|
||||
deployment_name: The Azure OpenAI deployment name.
|
||||
assistant_id: The ID of an Azure OpenAI assistant to use.
|
||||
assistant_name: The name to use when creating new assistants.
|
||||
assistant_description: The description to use when creating new assistants.
|
||||
thread_id: Default thread ID to use for conversations.
|
||||
api_key: The API key to use.
|
||||
endpoint: The deployment endpoint.
|
||||
base_url: The deployment base URL.
|
||||
api_version: The deployment API version.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
credential: Azure credential or token provider for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
"""
|
||||
azure_openai_settings = load_settings(
|
||||
AzureOpenAISettings,
|
||||
env_prefix="AZURE_OPENAI_",
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
chat_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
_apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
|
||||
|
||||
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
|
||||
if not chat_deployment_name:
|
||||
raise ValueError(
|
||||
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
|
||||
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
api_key_secret = azure_openai_settings.get("api_key")
|
||||
token_scope = azure_openai_settings.get("token_endpoint")
|
||||
|
||||
ad_token_provider = None
|
||||
if not async_client and not api_key_secret and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(credential, token_scope)
|
||||
|
||||
if not async_client and not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
if not async_client:
|
||||
client_params: dict[str, Any] = {
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
if resolved_api_version := azure_openai_settings.get("api_version"):
|
||||
client_params["api_version"] = resolved_api_version
|
||||
|
||||
if api_key_secret:
|
||||
client_params["api_key"] = api_key_secret.get_secret_value()
|
||||
elif ad_token_provider:
|
||||
client_params["azure_ad_token_provider"] = ad_token_provider
|
||||
|
||||
if resolved_base_url := azure_openai_settings.get("base_url"):
|
||||
client_params["base_url"] = str(resolved_base_url)
|
||||
elif resolved_endpoint := azure_openai_settings.get("endpoint"):
|
||||
client_params["azure_endpoint"] = str(resolved_endpoint)
|
||||
|
||||
async_client = AsyncAzureOpenAI(**client_params)
|
||||
|
||||
super().__init__(
|
||||
model_id=chat_deployment_name,
|
||||
assistant_id=assistant_id,
|
||||
assistant_name=assistant_name,
|
||||
assistant_description=assistant_description,
|
||||
thread_id=thread_id,
|
||||
async_client=async_client, # type: ignore[reportArgumentType]
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region AzureOpenAIEmbeddingClient
|
||||
|
||||
|
||||
AzureOpenAIEmbeddingOptionsT = TypeVar(
|
||||
"AzureOpenAIEmbeddingOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="OpenAIEmbeddingOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
|
||||
@deprecated("AzureOpenAIEmbeddingClient is deprecated. Use OpenAIEmbeddingClient with an AsyncAzureOpenAI client.")
|
||||
class AzureOpenAIEmbeddingClient(
|
||||
EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT],
|
||||
RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT],
|
||||
Generic[AzureOpenAIEmbeddingOptionsT],
|
||||
):
|
||||
"""Deprecated Azure OpenAI embedding client. Use OpenAIEmbeddingClient with AsyncAzureOpenAI instead."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
otel_provider_name: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize an Azure OpenAI embedding client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The API key.
|
||||
deployment_name: The deployment name.
|
||||
endpoint: The deployment endpoint.
|
||||
base_url: The deployment base URL.
|
||||
api_version: The deployment API version.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
credential: Azure credential or token provider for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
otel_provider_name: Override the OpenTelemetry provider name.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
"""
|
||||
azure_openai_settings = load_settings(
|
||||
AzureOpenAISettings,
|
||||
env_prefix="AZURE_OPENAI_",
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
embedding_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
_apply_azure_defaults(azure_openai_settings)
|
||||
|
||||
embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name")
|
||||
if not embedding_deployment_name:
|
||||
raise ValueError(
|
||||
"Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter "
|
||||
"or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
if not async_client:
|
||||
# Create the Azure OpenAI client directly
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
||||
|
||||
api_key_secret = azure_openai_settings.get("api_key")
|
||||
ad_token_provider = None
|
||||
if not api_key_secret and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(
|
||||
credential, azure_openai_settings.get("token_endpoint")
|
||||
)
|
||||
|
||||
if not api_key_secret and not ad_token_provider:
|
||||
raise ValueError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
endpoint_value = azure_openai_settings.get("endpoint")
|
||||
base_url_value = azure_openai_settings.get("base_url")
|
||||
if not endpoint_value and not base_url_value:
|
||||
raise ValueError("Please provide an endpoint or a base_url")
|
||||
|
||||
client_args: dict[str, Any] = {"default_headers": merged_headers}
|
||||
if resolved_api_version := azure_openai_settings.get("api_version"):
|
||||
client_args["api_version"] = resolved_api_version
|
||||
if ad_token_provider:
|
||||
client_args["azure_ad_token_provider"] = ad_token_provider
|
||||
if api_key_secret:
|
||||
client_args["api_key"] = api_key_secret.get_secret_value()
|
||||
if base_url_value:
|
||||
client_args["base_url"] = str(base_url_value)
|
||||
if endpoint_value and not base_url_value:
|
||||
client_args["azure_endpoint"] = str(endpoint_value)
|
||||
if embedding_deployment_name:
|
||||
client_args["azure_deployment"] = embedding_deployment_name
|
||||
|
||||
async_client = AsyncAzureOpenAI(**client_args)
|
||||
|
||||
# Store Azure-specific attributes for serialization
|
||||
self.endpoint = str(azure_openai_settings.get("endpoint") or "")
|
||||
self.api_version = azure_openai_settings.get("api_version") or ""
|
||||
self.deployment_name = embedding_deployment_name
|
||||
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
model=embedding_deployment_name,
|
||||
default_headers=default_headers,
|
||||
)
|
||||
if otel_provider_name is not None:
|
||||
self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc]
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -1,67 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Union
|
||||
|
||||
from agent_framework.exceptions import ChatClientInvalidAuthException
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]]
|
||||
"""A callable that returns a bearer token string, either synchronously or asynchronously."""
|
||||
|
||||
AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential]
|
||||
"""Union of Azure credential types.
|
||||
|
||||
Accepts:
|
||||
- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``)
|
||||
- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``)
|
||||
"""
|
||||
|
||||
|
||||
def resolve_credential_to_token_provider(
|
||||
credential: AzureCredentialTypes | AzureTokenProvider,
|
||||
token_endpoint: str | None,
|
||||
) -> AzureTokenProvider:
|
||||
"""Convert an Azure credential or token provider into an ``ad_token_provider`` callable.
|
||||
|
||||
If the credential is already a callable token provider, it is returned as-is
|
||||
(``token_endpoint`` is not required in this case).
|
||||
If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using
|
||||
``azure.identity.get_bearer_token_provider`` (sync or async variant) which
|
||||
handles token caching and automatic refresh.
|
||||
|
||||
Args:
|
||||
credential: An Azure credential or token provider callable.
|
||||
token_endpoint: The token scope/endpoint
|
||||
(e.g. ``"https://cognitiveservices.azure.com/.default"``).
|
||||
Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``.
|
||||
|
||||
Returns:
|
||||
A callable that returns a bearer token string (sync or async).
|
||||
|
||||
Raises:
|
||||
ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping.
|
||||
"""
|
||||
# Already a token provider callable (not a credential object) — use directly
|
||||
if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)):
|
||||
return credential
|
||||
|
||||
if not token_endpoint:
|
||||
raise ChatClientInvalidAuthException(
|
||||
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
|
||||
)
|
||||
|
||||
if isinstance(credential, AsyncTokenCredential):
|
||||
from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider
|
||||
|
||||
return get_async_bearer_token_provider(credential, token_endpoint)
|
||||
|
||||
from azure.identity import get_bearer_token_provider
|
||||
|
||||
return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type]
|
||||
+9
-9
@@ -16,11 +16,11 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
|
||||
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from openai.types.responses import ResponseInputItemParam
|
||||
|
||||
from ._entra_id_authentication import AzureCredentialTypes
|
||||
from ._shared import FoundryProjectSettings
|
||||
from ._shared import AzureAISettings
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
@@ -72,7 +72,7 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
Args:
|
||||
source_id: Unique identifier for this provider instance.
|
||||
project_client: Azure AI Project client for memory operations.
|
||||
project_endpoint: Foundry project endpoint URL. Used when project_client is not provided.
|
||||
project_endpoint: Azure AI project endpoint URL. Used when project_client is not provided.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required when project_client is not provided.
|
||||
@@ -86,20 +86,20 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
foundry_settings = load_settings(
|
||||
FoundryProjectSettings,
|
||||
env_prefix="FOUNDRY_",
|
||||
azure_ai_settings = load_settings(
|
||||
AzureAISettings,
|
||||
env_prefix="AZURE_AI_",
|
||||
project_endpoint=project_endpoint,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
if project_client is None:
|
||||
resolved_endpoint = foundry_settings.get("project_endpoint")
|
||||
resolved_endpoint = azure_ai_settings.get("project_endpoint")
|
||||
if not resolved_endpoint:
|
||||
raise ValueError(
|
||||
"Foundry project endpoint is required. Set via 'project_endpoint' parameter "
|
||||
"or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
|
||||
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
|
||||
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
|
||||
)
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when project_client is not provided.")
|
||||
@@ -18,6 +18,7 @@ from agent_framework import (
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
AgentVersionDetails,
|
||||
@@ -28,15 +29,13 @@ from azure.ai.projects.models import (
|
||||
FunctionTool as AzureFunctionTool,
|
||||
)
|
||||
|
||||
from ._client import AzureAIClient, AzureAIProjectAgentOptions # pyright: ignore[reportDeprecated]
|
||||
from ._entra_id_authentication import AzureCredentialTypes
|
||||
from ._client import AzureAIClient, AzureAIProjectAgentOptions
|
||||
from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
from warnings import deprecated # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
|
||||
from typing_extensions import TypeVar # type: ignore # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self, TypedDict # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -56,12 +55,11 @@ OptionsCoT = TypeVar(
|
||||
)
|
||||
|
||||
|
||||
@deprecated("AzureAIProjectAgentProvider is deprecated. Use FoundryAgent instead.")
|
||||
class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
"""Deprecated provider for Azure AI Agent Service (Responses API).
|
||||
"""Provider for Azure AI Agent Service (Responses API).
|
||||
|
||||
This provider is deprecated. Use ``FoundryAgent`` instead to connect to
|
||||
pre-configured agents in Foundry.
|
||||
This provider allows you to create, retrieve, and manage Azure AI agents
|
||||
using the AIProjectClient from the Azure AI Projects SDK.
|
||||
|
||||
Examples:
|
||||
Using with explicit AIProjectClient:
|
||||
@@ -202,7 +200,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
)
|
||||
|
||||
# Extract options from default_options if present
|
||||
opts: dict[str, Any] = dict(default_options) if default_options else {}
|
||||
opts = dict(default_options) if default_options else {}
|
||||
response_format = opts.get("response_format")
|
||||
rai_config = opts.get("rai_config")
|
||||
reasoning = opts.get("reasoning")
|
||||
@@ -386,7 +384,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
if not isinstance(details.definition, PromptAgentDefinition):
|
||||
raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.")
|
||||
|
||||
client = AzureAIClient( # pyright: ignore[reportDeprecated]
|
||||
client = AzureAIClient(
|
||||
project_client=self._project_client,
|
||||
agent_name=details.name,
|
||||
agent_version=details.version,
|
||||
|
||||
@@ -24,11 +24,8 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"agent-framework-openai>=1.0.0rc5",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-identity>=1,<2",
|
||||
"aiohttp>=3.7.0,<4",
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB |
@@ -15,6 +15,7 @@ from azure.ai.agents.models import (
|
||||
from azure.ai.agents.models import (
|
||||
CodeInterpreterToolDefinition,
|
||||
)
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azure_ai import (
|
||||
@@ -771,3 +772,82 @@ def test_from_azure_ai_agent_tools_unknown_dict() -> None:
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Integration Tests
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_create_agent() -> None:
|
||||
"""Integration test: Create an agent using the provider."""
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentsProvider(credential=credential) as provider,
|
||||
):
|
||||
agent = await provider.create_agent(
|
||||
name="IntegrationTestAgent",
|
||||
instructions="You are a helpful assistant for testing.",
|
||||
)
|
||||
|
||||
try:
|
||||
assert isinstance(agent, Agent)
|
||||
assert agent.name == "IntegrationTestAgent"
|
||||
assert agent.id is not None
|
||||
finally:
|
||||
# Cleanup: delete the agent
|
||||
if agent.id:
|
||||
await provider._agents_client.delete_agent(agent.id) # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_get_agent() -> None:
|
||||
"""Integration test: Get an existing agent using the provider."""
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentsProvider(credential=credential) as provider,
|
||||
):
|
||||
# First create an agent
|
||||
created = await provider._agents_client.create_agent( # type: ignore
|
||||
model=os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"),
|
||||
name="GetAgentTest",
|
||||
instructions="Test agent",
|
||||
)
|
||||
|
||||
try:
|
||||
# Then get it using the provider
|
||||
agent = await provider.get_agent(created.id)
|
||||
|
||||
assert isinstance(agent, Agent)
|
||||
assert agent.id == created.id
|
||||
finally:
|
||||
await provider._agents_client.delete_agent(created.id) # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_create_and_run() -> None:
|
||||
"""Integration test: Create an agent and run a conversation."""
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIAgentsProvider(credential=credential) as provider,
|
||||
):
|
||||
agent = await provider.create_agent(
|
||||
name="RunTestAgent",
|
||||
instructions="You are a helpful assistant. Always respond with 'Hello!' to any greeting.",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await agent.run("Hi there!")
|
||||
|
||||
assert result is not None
|
||||
assert len(result.messages) > 0
|
||||
finally:
|
||||
if agent.id:
|
||||
await provider._agents_client.delete_agent(agent.id) # type: ignore
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
@@ -22,6 +28,7 @@ from azure.ai.agents.models import (
|
||||
AgentsNamedToolChoiceType,
|
||||
AgentsToolChoiceOptionMode,
|
||||
CodeInterpreterToolDefinition,
|
||||
FileInfo,
|
||||
MessageDeltaChunk,
|
||||
MessageDeltaTextContent,
|
||||
MessageDeltaTextFileCitationAnnotation,
|
||||
@@ -34,12 +41,19 @@ from azure.ai.agents.models import (
|
||||
SubmitToolApprovalAction,
|
||||
SubmitToolOutputsAction,
|
||||
ThreadRun,
|
||||
VectorStore,
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings
|
||||
|
||||
skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"),
|
||||
reason="No real AZURE_AI_PROJECT_ENDPOINT provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
def create_test_azure_ai_chat_client(
|
||||
mock_agents_client: MagicMock,
|
||||
@@ -88,15 +102,6 @@ def create_test_azure_ai_chat_client(
|
||||
return client
|
||||
|
||||
|
||||
def test_init_emits_updated_deprecation_warning(mock_agents_client: MagicMock) -> None:
|
||||
"""Test that construction emits the updated class deprecation warning."""
|
||||
with pytest.deprecated_call(match="V1 Agents Service API and has no direct replacement"):
|
||||
AzureAIAgentClient(
|
||||
agents_client=mock_agents_client,
|
||||
agent_id="test-agent",
|
||||
)
|
||||
|
||||
|
||||
def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test AzureAISettings initialization."""
|
||||
settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_")
|
||||
@@ -1522,6 +1527,401 @@ def get_weather(
|
||||
return f"The weather in {location} is sunny with a high of 25°C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_get_response() -> None:
|
||||
"""Test Azure AI Chat Client response."""
|
||||
async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client:
|
||||
assert isinstance(azure_ai_chat_client, SupportsChatGetResponse)
|
||||
|
||||
messages: list[Message] = []
|
||||
messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
text="The weather in Seattle is currently sunny with a high of 25°C. "
|
||||
"It's a beautiful day for outdoor activities.",
|
||||
)
|
||||
)
|
||||
messages.append(Message(role="user", text="What's the weather like today?"))
|
||||
|
||||
# Test that the agents_client can be used to get a response
|
||||
response = await azure_ai_chat_client.get_response(messages=messages)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_get_response_tools() -> None:
|
||||
"""Test Azure AI Chat Client response with tools."""
|
||||
async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client:
|
||||
assert isinstance(azure_ai_chat_client, SupportsChatGetResponse)
|
||||
|
||||
messages: list[Message] = []
|
||||
messages.append(Message(role="user", text="What's the weather like in Seattle?"))
|
||||
|
||||
# Test that the agents_client can be used to get a response
|
||||
response = await azure_ai_chat_client.get_response(
|
||||
messages=messages,
|
||||
options={"tools": [get_weather], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_streaming() -> None:
|
||||
"""Test Azure AI Chat Client streaming response."""
|
||||
async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client:
|
||||
assert isinstance(azure_ai_chat_client, SupportsChatGetResponse)
|
||||
|
||||
messages: list[Message] = []
|
||||
messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
text="The weather in Seattle is currently sunny with a high of 25°C. "
|
||||
"It's a beautiful day for outdoor activities.",
|
||||
)
|
||||
)
|
||||
messages.append(Message(role="user", text="What's the weather like today?"))
|
||||
|
||||
# Test that the agents_client can be used to get a response
|
||||
response = azure_ai_chat_client.get_response(messages=messages, stream=True)
|
||||
|
||||
full_message: str = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_streaming_tools() -> None:
|
||||
"""Test Azure AI Chat Client streaming response with tools."""
|
||||
async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client:
|
||||
assert isinstance(azure_ai_chat_client, SupportsChatGetResponse)
|
||||
|
||||
messages: list[Message] = []
|
||||
messages.append(Message(role="user", text="What's the weather like in Seattle?"))
|
||||
|
||||
# Test that the agents_client can be used to get a response
|
||||
response = azure_ai_chat_client.get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options={"tools": [get_weather], "tool_choice": "auto"},
|
||||
)
|
||||
full_message: str = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_basic_run() -> None:
|
||||
"""Test Agent basic run functionality with AzureAIAgentClient."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
) as agent:
|
||||
# Run a simple query
|
||||
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
assert "Hello World" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None:
|
||||
"""Test Agent basic streaming functionality with AzureAIAgentClient."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
) as agent:
|
||||
# Run streaming query
|
||||
full_message: str = ""
|
||||
async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True):
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, AgentResponseUpdate)
|
||||
if chunk.text:
|
||||
full_message += chunk.text
|
||||
|
||||
# Validate streaming response
|
||||
assert len(full_message) > 0
|
||||
assert "streaming response test" in full_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_thread_persistence() -> None:
|
||||
"""Test Agent session persistence across runs with AzureAIAgentClient."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new session that will be reused
|
||||
session = agent.create_session()
|
||||
|
||||
# First message - establish context
|
||||
first_response = await agent.run(
|
||||
"Remember this number: 42. What number did I just tell you to remember?", session=session
|
||||
)
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert "42" in first_response.text
|
||||
|
||||
# Second message - test conversation memory
|
||||
second_response = await agent.run(
|
||||
"What number did I tell you to remember in my previous message?", session=session
|
||||
)
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert "42" in second_response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_existing_thread_id() -> None:
|
||||
"""Test Agent existing thread ID functionality with AzureAIAgentClient."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
# Start a conversation and get the session ID
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
|
||||
|
||||
# Validate first response
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = session.service_session_id
|
||||
assert existing_thread_id is not None
|
||||
|
||||
# Now continue with the same thread ID in a new agent instance
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
# Create a session with the existing ID
|
||||
session = AgentSession(service_session_id=existing_thread_id)
|
||||
|
||||
# Ask about the previous conversation
|
||||
response2 = await second_agent.run("What is my name?", session=session)
|
||||
|
||||
# Validate that the agent remembers the previous conversation
|
||||
assert isinstance(response2, AgentResponse)
|
||||
assert response2.text is not None
|
||||
# Should reference Alice from the previous conversation
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_code_interpreter():
|
||||
"""Test Agent with code interpreter through AzureAIAgentClient."""
|
||||
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant that can write and execute Python code.",
|
||||
tools=[AzureAIAgentClient.get_code_interpreter_tool()],
|
||||
) as agent:
|
||||
# Request code execution
|
||||
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
# Factorial of 5 is 120
|
||||
assert "120" in response.text or "factorial" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_file_search():
|
||||
"""Test Agent with file search through AzureAIAgentClient."""
|
||||
|
||||
client = AzureAIAgentClient(credential=AzureCliCredential())
|
||||
file: FileInfo | None = None
|
||||
vector_store: VectorStore | None = None
|
||||
|
||||
try:
|
||||
# 1. Read and upload the test file to the Azure AI agent service
|
||||
test_file_path = Path(__file__).parent / "resources" / "employees.pdf"
|
||||
file = await client.agents_client.files.upload_and_poll(file_path=str(test_file_path), purpose="assistants")
|
||||
vector_store = await client.agents_client.vector_stores.create_and_poll(
|
||||
file_ids=[file.id], name="test_employees_vectorstore"
|
||||
)
|
||||
|
||||
# 2. Create file search tool with uploaded resources
|
||||
file_search_tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=[vector_store.id])
|
||||
|
||||
async with Agent(
|
||||
client=client,
|
||||
instructions="You are a helpful assistant that can search through uploaded employee files.",
|
||||
tools=[file_search_tool],
|
||||
) as agent:
|
||||
# 3. Test file search functionality
|
||||
response = await agent.run("Who is the youngest employee in the files?")
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
# Should find information about Alice Johnson (age 24) being the youngest
|
||||
assert any(term in response.text.lower() for term in ["alice", "johnson", "24"])
|
||||
|
||||
finally:
|
||||
# 4. Cleanup: Delete the vector store and file
|
||||
try:
|
||||
if vector_store:
|
||||
await client.agents_client.vector_stores.delete(vector_store.id)
|
||||
if file:
|
||||
await client.agents_client.files.delete(file.id)
|
||||
except Exception:
|
||||
# Ignore cleanup errors to avoid masking the actual test failure
|
||||
pass
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_hosted_mcp_tool() -> None:
|
||||
"""Integration test for MCP tool with Azure AI Agent using Microsoft Learn MCP."""
|
||||
|
||||
mcp_tool = AzureAIAgentClient.get_mcp_tool(
|
||||
name="Microsoft Learn MCP",
|
||||
url="https://learn.microsoft.com/api/mcp",
|
||||
description="A Microsoft Learn MCP server for documentation questions",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
|
||||
tools=[mcp_tool],
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"How to create an Azure storage account using az cli?",
|
||||
options={"max_tokens": 200},
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
# With never_require approval mode, there should be no approval requests
|
||||
assert len(response.user_input_requests) == 0, (
|
||||
f"Expected no approval requests with never_require mode, but got {len(response.user_input_requests)}"
|
||||
)
|
||||
|
||||
# Should contain Azure-related content since it's asking about Azure CLI
|
||||
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with AzureAIAgentClient."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant that uses available tools.",
|
||||
tools=[get_weather],
|
||||
) as agent:
|
||||
# First run - agent-level tool should be available
|
||||
first_response = await agent.run("What's the weather like in Chicago?")
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
# Should use the agent-level weather tool
|
||||
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "25"])
|
||||
|
||||
# Second run - agent-level tool should still be available (persistence test)
|
||||
second_response = await agent.run("What's the weather in Miami?")
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
# Should use the agent-level weather tool again
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_chat_options_run_level() -> None:
|
||||
"""Test ChatOptions parameter coverage at run level."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant.",
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"Provide a brief, helpful response.",
|
||||
tools=[get_weather],
|
||||
options={
|
||||
"max_tokens": 100,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"tool_choice": "auto",
|
||||
"metadata": {"test": "value"},
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_chat_options_agent_level() -> None:
|
||||
"""Test ChatOptions parameter coverage agent level."""
|
||||
async with Agent(
|
||||
client=AzureAIAgentClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"max_tokens": 100,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"tool_choice": "auto",
|
||||
"metadata": {"test": "value"},
|
||||
},
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"Provide a brief, helpful response.",
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_cleanup_agent_when_enabled_and_created(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
|
||||
@@ -11,6 +11,8 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
Annotation,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
@@ -22,7 +24,7 @@ from agent_framework import (
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from agent_framework.openai._responses_client import RawOpenAIResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
ApproximateLocation,
|
||||
@@ -39,11 +41,17 @@ from azure.identity.aio import AzureCliCredential
|
||||
from openai.types.responses.parsed_response import ParsedResponse
|
||||
from openai.types.responses.response import Response as OpenAIResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pytest import fixture
|
||||
from pytest import fixture, param
|
||||
|
||||
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
|
||||
from agent_framework_azure_ai._shared import from_azure_ai_tools
|
||||
|
||||
skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/")
|
||||
or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "",
|
||||
reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_client() -> MagicMock:
|
||||
@@ -407,7 +415,7 @@ async def test_prepare_options_basic(mock_project_client: MagicMock) -> None:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model"},
|
||||
),
|
||||
patch.object(
|
||||
@@ -444,7 +452,7 @@ async def test_prepare_options_with_application_endpoint(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model"},
|
||||
),
|
||||
patch.object(
|
||||
@@ -486,7 +494,7 @@ async def test_prepare_options_with_application_project_client(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model"},
|
||||
),
|
||||
patch.object(
|
||||
@@ -504,6 +512,19 @@ async def test_prepare_options_with_application_project_client(
|
||||
assert "extra_body" not in run_options
|
||||
|
||||
|
||||
async def test_initialize_client(mock_project_client: MagicMock) -> None:
|
||||
"""Test _initialize_client method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
mock_openai_client = MagicMock()
|
||||
mock_project_client.get_openai_client = MagicMock(return_value=mock_openai_client)
|
||||
|
||||
await client._initialize_client()
|
||||
|
||||
assert client.client is mock_openai_client
|
||||
mock_project_client.get_openai_client.assert_called_once()
|
||||
|
||||
|
||||
def test_update_agent_name_and_description(mock_project_client: MagicMock) -> None:
|
||||
"""Test _update_agent_name_and_description method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
@@ -806,14 +827,14 @@ async def test_runtime_tools_override_logs_warning(
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]},
|
||||
):
|
||||
await client._prepare_options(messages, {})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]},
|
||||
),
|
||||
patch("agent_framework_azure_ai._client.logger.warning") as mock_warning,
|
||||
@@ -832,7 +853,7 @@ async def test_prepare_options_logs_warning_for_tools_with_existing_agent_versio
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]},
|
||||
),
|
||||
patch("agent_framework_azure_ai._client.logger.warning") as mock_warning,
|
||||
@@ -854,7 +875,7 @@ async def test_prepare_options_logs_warning_for_tools_on_application_endpoint(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]},
|
||||
),
|
||||
patch.object(client, "_get_agent_reference_or_create", new_callable=AsyncMock) as mock_get_agent_reference,
|
||||
@@ -1080,14 +1101,14 @@ async def test_runtime_structured_output_override_logs_warning(
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model"},
|
||||
):
|
||||
await client._prepare_options(messages, {"response_format": ResponseFormatModel})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={"model": "test-model"},
|
||||
),
|
||||
patch("agent_framework_azure_ai._client.logger.warning") as mock_warning,
|
||||
@@ -1108,7 +1129,7 @@ async def test_prepare_options_excludes_response_format(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={
|
||||
"model": "test-model",
|
||||
"response_format": ResponseFormatModel,
|
||||
@@ -1143,7 +1164,7 @@ async def test_prepare_options_keeps_values_for_unsupported_option_keys(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
"agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options",
|
||||
return_value={
|
||||
"model": "test-model",
|
||||
"tools": [{"type": "function", "name": "weather"}],
|
||||
@@ -1344,6 +1365,352 @@ async def client() -> AsyncGenerator[AzureAIClient, None]:
|
||||
await project_client.agents.delete(agent_name=agent_name)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
@pytest.mark.parametrize(
|
||||
"option_name,option_value,needs_validation",
|
||||
[
|
||||
# Simple ChatOptions - just verify they don't fail
|
||||
param("top_p", 0.9, False, id="top_p"),
|
||||
param("max_tokens", 500, False, id="max_tokens"),
|
||||
param("seed", 123, False, id="seed"),
|
||||
param("user", "test-user-id", False, id="user"),
|
||||
param("metadata", {"test_key": "test_value"}, False, id="metadata"),
|
||||
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
|
||||
param("presence_penalty", 0.3, False, id="presence_penalty"),
|
||||
param("stop", ["END"], False, id="stop"),
|
||||
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
|
||||
param("tool_choice", "none", True, id="tool_choice_none"),
|
||||
param("tool_choice", "auto", True, id="tool_choice_auto"),
|
||||
param("tool_choice", "required", True, id="tool_choice_required_any"),
|
||||
param(
|
||||
"tool_choice",
|
||||
{"mode": "required", "required_function_name": "get_weather"},
|
||||
True,
|
||||
id="tool_choice_required",
|
||||
),
|
||||
# OpenAIResponsesOptions - just verify they don't fail
|
||||
param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"),
|
||||
param("truncation", "auto", False, id="truncation"),
|
||||
param("top_logprobs", 5, False, id="top_logprobs"),
|
||||
param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"),
|
||||
param("max_tool_calls", 3, False, id="max_tool_calls"),
|
||||
],
|
||||
)
|
||||
async def test_integration_options(
|
||||
option_name: str,
|
||||
option_value: Any,
|
||||
needs_validation: bool,
|
||||
client: AzureAIClient,
|
||||
) -> None:
|
||||
"""Parametrized test covering options that can be set at runtime for a Foundry Agent.
|
||||
|
||||
Tests both streaming and non-streaming modes for each option to ensure
|
||||
they don't cause failures. Options marked with needs_validation also
|
||||
check that the feature actually works correctly.
|
||||
|
||||
This test reuses a single agent.
|
||||
"""
|
||||
# Prepare test message
|
||||
if option_name.startswith("tool_choice"):
|
||||
# Use weather-related prompt for tool tests
|
||||
messages = [Message(role="user", text="What is the weather in Seattle?")]
|
||||
else:
|
||||
# Generic prompt for simple options
|
||||
messages = [Message(role="user", text="Say 'Hello World' briefly.")]
|
||||
|
||||
# Build options dict
|
||||
options: dict[str, Any] = {option_name: option_value, "tools": [get_weather]}
|
||||
|
||||
for streaming in [False, True]:
|
||||
if streaming:
|
||||
# Test streaming mode
|
||||
response_stream = client.get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=options,
|
||||
)
|
||||
|
||||
response = await response_stream.get_final_response()
|
||||
else:
|
||||
# Test non-streaming mode
|
||||
response = await client.get_response(
|
||||
messages=messages,
|
||||
options=options,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
|
||||
# For tool_choice="required", we return after tool execution without a model text response
|
||||
is_required_tool_choice = option_name == "tool_choice" and (
|
||||
option_value == "required" or (isinstance(option_value, dict) and option_value.get("mode") == "required")
|
||||
)
|
||||
|
||||
if is_required_tool_choice:
|
||||
# Response should have function call and function result, but no text from model
|
||||
assert len(response.messages) >= 2, f"Expected function call + result for {option_name}"
|
||||
has_function_call = any(c.type == "function_call" for msg in response.messages for c in msg.contents)
|
||||
has_function_result = any(c.type == "function_result" for msg in response.messages for c in msg.contents)
|
||||
assert has_function_call, f"No function call in response for {option_name}"
|
||||
assert has_function_result, f"No function result in response for {option_name}"
|
||||
else:
|
||||
assert response.text is not None, f"No text in response for option '{option_name}'"
|
||||
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
|
||||
|
||||
# Validate based on option type
|
||||
if needs_validation:
|
||||
if option_name.startswith("tool_choice") and not is_required_tool_choice:
|
||||
# Should have called the weather function
|
||||
text = response.text.lower()
|
||||
assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}"
|
||||
elif option_name == "response_format":
|
||||
if option_value == OutputStruct:
|
||||
# Should have structured output
|
||||
assert response.value is not None, "No structured output"
|
||||
assert isinstance(response.value, OutputStruct)
|
||||
assert "seattle" in response.value.location.lower()
|
||||
else:
|
||||
# Runtime JSON schema
|
||||
assert response.value is None, "No structured output, can't parse any json."
|
||||
response_value = json.loads(response.text)
|
||||
assert isinstance(response_value, dict)
|
||||
assert "location" in response_value
|
||||
assert "seattle" in response_value["location"].lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
@pytest.mark.parametrize(
|
||||
"option_name,option_value,needs_validation",
|
||||
[
|
||||
param("temperature", 0.7, False, id="temperature"),
|
||||
# Complex options requiring output validation
|
||||
param("response_format", OutputStruct, True, id="response_format_pydantic"),
|
||||
param(
|
||||
"response_format",
|
||||
{
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "WeatherDigest",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"title": "WeatherDigest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"conditions": {"type": "string"},
|
||||
"temperature_c": {"type": "number"},
|
||||
"advisory": {"type": "string"},
|
||||
},
|
||||
"required": ["location", "conditions", "temperature_c", "advisory"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
True,
|
||||
id="response_format_runtime_json_schema",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_integration_agent_options(
|
||||
option_name: str,
|
||||
option_value: Any,
|
||||
needs_validation: bool,
|
||||
) -> None:
|
||||
"""Test Foundry agent level options in both streaming and non-streaming modes.
|
||||
|
||||
Tests both streaming and non-streaming modes for each option to ensure
|
||||
they don't cause failures. Options marked with needs_validation also
|
||||
check that the feature actually works correctly.
|
||||
|
||||
This test create a new client and uses it for both streaming and non-streaming tests.
|
||||
"""
|
||||
async with temporary_chat_client(agent_name=f"test-agent-{option_name.replace('_', '-')}-{uuid4()}") as client:
|
||||
for streaming in [False, True]:
|
||||
# Prepare test message
|
||||
if option_name.startswith("response_format"):
|
||||
# Use prompt that works well with structured output
|
||||
messages = [Message(role="user", text="The weather in Seattle is sunny")]
|
||||
messages.append(Message(role="user", text="What is the weather in Seattle?"))
|
||||
else:
|
||||
# Generic prompt for simple options
|
||||
messages = [Message(role="user", text="Say 'Hello World' briefly.")]
|
||||
|
||||
# Build options dict
|
||||
options = {option_name: option_value}
|
||||
|
||||
if streaming:
|
||||
# Test streaming mode
|
||||
response_stream = client.get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=options,
|
||||
)
|
||||
|
||||
response = await response_stream.get_final_response()
|
||||
else:
|
||||
# Test non-streaming mode
|
||||
response = await client.get_response(
|
||||
messages=messages,
|
||||
options=options,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None, f"No text in response for option '{option_name}'"
|
||||
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
|
||||
|
||||
# Validate based on option type
|
||||
if needs_validation and option_name.startswith("response_format"):
|
||||
if option_value == OutputStruct:
|
||||
# Should have structured output
|
||||
assert response.value is not None, "No structured output"
|
||||
assert isinstance(response.value, OutputStruct)
|
||||
assert "seattle" in response.value.location.lower()
|
||||
else:
|
||||
# Runtime JSON schema
|
||||
assert response.value is None, "No structured output, can't parse any json."
|
||||
response_value = json.loads(response.text)
|
||||
assert isinstance(response_value, dict)
|
||||
assert "location" in response_value
|
||||
assert "seattle" in response_value["location"].lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_web_search() -> None:
|
||||
async with temporary_chat_client(agent_name="af-int-test-web-search") as client:
|
||||
for streaming in [False, True]:
|
||||
content = {
|
||||
"messages": [
|
||||
Message(
|
||||
role="user",
|
||||
text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
|
||||
)
|
||||
],
|
||||
"options": {
|
||||
"tool_choice": "auto",
|
||||
"tools": [client.get_web_search_tool()],
|
||||
},
|
||||
}
|
||||
if streaming:
|
||||
response = await client.get_response(stream=True, **content).get_final_response()
|
||||
else:
|
||||
response = await client.get_response(**content)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert "Rumi" in response.text
|
||||
assert "Mira" in response.text
|
||||
assert "Zoey" in response.text
|
||||
|
||||
# Test that the client will use the web search tool with location
|
||||
content = {
|
||||
"messages": [
|
||||
Message(role="user", text="What is the current weather? Do not ask for my current location.")
|
||||
],
|
||||
"options": {
|
||||
"tool_choice": "auto",
|
||||
"tools": [client.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})],
|
||||
},
|
||||
}
|
||||
if streaming:
|
||||
response = await client.get_response(stream=True, **content).get_final_response()
|
||||
else:
|
||||
response = await client.get_response(**content)
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_agent_hosted_mcp_tool() -> None:
|
||||
"""Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP."""
|
||||
async with temporary_chat_client(agent_name="af-int-test-mcp") as client:
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", text="How to create an Azure storage account using az cli?")],
|
||||
options={
|
||||
# this needs to be high enough to handle the full MCP tool response.
|
||||
"max_tokens": 5000,
|
||||
"tools": client.get_mcp_tool(
|
||||
name="Microsoft Learn MCP",
|
||||
url="https://learn.microsoft.com/api/mcp",
|
||||
description="A Microsoft Learn MCP server for documentation questions",
|
||||
approval_mode="never_require",
|
||||
),
|
||||
},
|
||||
)
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text
|
||||
# Should contain Azure-related content since it's asking about Azure CLI
|
||||
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_agent_hosted_code_interpreter_tool():
|
||||
"""Test Azure Responses Client agent with code interpreter tool through AzureAIClient."""
|
||||
async with temporary_chat_client(agent_name="af-int-test-code-interpreter") as client:
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")],
|
||||
options={
|
||||
"tools": [client.get_code_interpreter_tool()],
|
||||
},
|
||||
)
|
||||
# Should contain calculation result (sum of 1-10 = 55) or code execution content
|
||||
contains_relevant_content = any(
|
||||
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
|
||||
)
|
||||
assert contains_relevant_content or len(response.text.strip()) > 10
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_integration_agent_existing_session():
|
||||
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
|
||||
# First conversation - capture the session
|
||||
preserved_session = None
|
||||
|
||||
async with (
|
||||
temporary_chat_client(agent_name="af-int-test-existing-session") as client,
|
||||
Agent(
|
||||
client=client,
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent,
|
||||
):
|
||||
# Start a conversation and capture the session
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# Preserve the session for reuse
|
||||
preserved_session = session
|
||||
|
||||
# Second conversation - reuse the session in a new agent instance
|
||||
if preserved_session:
|
||||
async with (
|
||||
temporary_chat_client(agent_name="af-int-test-existing-session-2") as client,
|
||||
Agent(
|
||||
client=client,
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent,
|
||||
):
|
||||
# Reuse the preserved session
|
||||
second_response = await second_agent.run("What is my hobby?", session=preserved_session)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
assert "photography" in second_response.text.lower()
|
||||
|
||||
|
||||
# region Factory Method Tests
|
||||
|
||||
|
||||
@@ -1664,7 +2031,7 @@ async def test_inner_get_response_enriches_non_streaming(mock_project_client: Ma
|
||||
async def _fake_awaitable() -> ChatResponse:
|
||||
return base_response
|
||||
|
||||
with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()):
|
||||
with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=_fake_awaitable()):
|
||||
result_awaitable = client._inner_get_response(messages=[], options={}, stream=False)
|
||||
result = await result_awaitable # type: ignore[misc]
|
||||
|
||||
@@ -1687,7 +2054,7 @@ async def test_inner_get_response_no_search_output_non_streaming(mock_project_cl
|
||||
async def _fake_awaitable() -> ChatResponse:
|
||||
return base_response
|
||||
|
||||
with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()):
|
||||
with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=_fake_awaitable()):
|
||||
result_awaitable = client._inner_get_response(messages=[], options={}, stream=False)
|
||||
result = await result_awaitable # type: ignore[misc]
|
||||
|
||||
@@ -1708,7 +2075,7 @@ def test_inner_get_response_streaming_registers_hook(mock_project_client: MagicM
|
||||
|
||||
mock_stream = _create_mock_stream()
|
||||
|
||||
with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream):
|
||||
with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream):
|
||||
result = client._inner_get_response(messages=[], options={}, stream=True)
|
||||
|
||||
assert result is mock_stream
|
||||
@@ -1721,7 +2088,7 @@ def test_streaming_hook_captures_search_urls(mock_project_client: MagicMock) ->
|
||||
|
||||
mock_stream = _create_mock_stream()
|
||||
|
||||
with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream):
|
||||
with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream):
|
||||
client._inner_get_response(messages=[], options={}, stream=True)
|
||||
|
||||
hook = mock_stream._transform_hooks[0]
|
||||
@@ -1749,7 +2116,7 @@ def test_streaming_hook_enriches_url_citation(mock_project_client: MagicMock) ->
|
||||
|
||||
mock_stream = _create_mock_stream()
|
||||
|
||||
with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream):
|
||||
with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream):
|
||||
client._inner_get_response(messages=[], options={}, stream=True)
|
||||
|
||||
hook = mock_stream._transform_hooks[0]
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import pytest
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
|
||||
from agent_framework_foundry._foundry_memory_provider import FoundryMemoryProvider
|
||||
from agent_framework_azure_ai._foundry_memory_provider import FoundryMemoryProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -81,7 +81,7 @@ class TestInit:
|
||||
def test_init_with_project_endpoint_and_credential(
|
||||
self, mock_project_client: AsyncMock, mock_credential: Mock
|
||||
) -> None:
|
||||
with patch("agent_framework_foundry._foundry_memory_provider.AIProjectClient") as mock_ai_project_client:
|
||||
with patch("agent_framework_azure_ai._foundry_memory_provider.AIProjectClient") as mock_ai_project_client:
|
||||
mock_ai_project_client.return_value = mock_project_client
|
||||
provider = FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
@@ -100,7 +100,7 @@ class TestInit:
|
||||
|
||||
def test_init_requires_project_endpoint_without_project_client(self) -> None:
|
||||
with (
|
||||
patch("agent_framework_foundry._foundry_memory_provider.load_settings") as mock_load_settings,
|
||||
patch("agent_framework_azure_ai._foundry_memory_provider.load_settings") as mock_load_settings,
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(ValueError, match="project endpoint is required"),
|
||||
):
|
||||
@@ -1,10 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, FunctionTool
|
||||
from agent_framework._mcp import MCPTool
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
AgentVersionDetails,
|
||||
PromptAgentDefinition,
|
||||
@@ -12,9 +14,16 @@ from azure.ai.projects.models import (
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as AzureFunctionTool,
|
||||
)
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
from agent_framework_azure_ai import AzureAIProjectAgentProvider
|
||||
|
||||
skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/")
|
||||
or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "",
|
||||
reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_client() -> MagicMock:
|
||||
@@ -680,3 +689,42 @@ async def test_provider_create_agent_with_mcp_and_regular_tools(
|
||||
assert "regular_function" in tool_names
|
||||
assert "mcp_function_1" in tool_names
|
||||
assert "mcp_function_2" in tool_names
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_provider_create_and_get_agent_integration() -> None:
|
||||
"""Integration test for provider create_agent and get_agent."""
|
||||
endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
|
||||
model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
|
||||
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=endpoint, credential=credential) as project_client,
|
||||
):
|
||||
provider = AzureAIProjectAgentProvider(project_client=project_client)
|
||||
|
||||
try:
|
||||
# Create agent
|
||||
agent = await provider.create_agent(
|
||||
name="ProviderTestAgent",
|
||||
model=model,
|
||||
instructions="You are a helpful assistant. Always respond with 'Hello from provider!'",
|
||||
)
|
||||
|
||||
assert isinstance(agent, Agent)
|
||||
assert agent.name == "ProviderTestAgent"
|
||||
|
||||
# Run the agent
|
||||
response = await agent.run("Hi!")
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
# Get the same agent
|
||||
retrieved_agent = await provider.get_agent(name="ProviderTestAgent")
|
||||
assert retrieved_agent.name == "ProviderTestAgent"
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await project_client.agents.delete(agent_name="ProviderTestAgent")
|
||||
|
||||
@@ -13,13 +13,10 @@ from typing import Any, ClassVar, TypedDict
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
|
||||
from agent_framework._sessions import BaseHistoryProvider
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.cosmos import PartitionKey
|
||||
from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ cp .env.example .env
|
||||
|
||||
Required variables:
|
||||
- `AZURE_OPENAI_ENDPOINT`
|
||||
- `AZURE_OPENAI_DEPLOYMENT_NAME`
|
||||
- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`
|
||||
- `AZURE_OPENAI_API_KEY`
|
||||
- `AzureWebJobsStorage`
|
||||
- `DURABLE_TASK_SCHEDULER_CONNECTION_STRING`
|
||||
|
||||
@@ -111,17 +111,13 @@ def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]:
|
||||
f"Durable Task Scheduler emulator not running on port {_DTS_EMULATOR_PORT}. Start with: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest", # noqa: E501
|
||||
)
|
||||
|
||||
has_foundry_config = bool(os.getenv("FOUNDRY_PROJECT_ENDPOINT", "").strip()) and bool(
|
||||
os.getenv("FOUNDRY_MODEL", "").strip()
|
||||
)
|
||||
has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool(
|
||||
os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "").strip()
|
||||
)
|
||||
if not has_foundry_config and not has_azure_openai_config:
|
||||
return (
|
||||
True,
|
||||
"No real FOUNDRY_* or AZURE_OPENAI_* configuration provided; skipping integration tests.",
|
||||
)
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()
|
||||
if not endpoint or endpoint == "https://your-resource.openai.azure.com/":
|
||||
return True, "No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests."
|
||||
|
||||
deployment_name = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "").strip()
|
||||
if not deployment_name or deployment_name == "your-deployment-name":
|
||||
return True, "No real AZURE_OPENAI_CHAT_DEPLOYMENT_NAME provided; skipping integration tests."
|
||||
|
||||
return False, "Integration tests enabled."
|
||||
|
||||
@@ -326,22 +322,22 @@ def _is_port_in_use(port: int, host: str = _DEFAULT_HOST) -> bool:
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def _load_and_validate_env(sample_path: Path) -> None:
|
||||
def _load_and_validate_env() -> None:
|
||||
"""Load .env file from current directory if it exists, then validate required environment variables.
|
||||
|
||||
Raises pytest.fail if required environment variables are missing.
|
||||
"""
|
||||
_load_env_file_if_present()
|
||||
|
||||
# Required environment variables for Azure Functions samples
|
||||
# These match the variables defined in .env.example
|
||||
required_env_vars = [
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",
|
||||
"AzureWebJobsStorage",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
|
||||
"FUNCTIONS_WORKER_RUNTIME",
|
||||
]
|
||||
if sample_path.name == "11_workflow_parallel":
|
||||
required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"])
|
||||
else:
|
||||
required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"])
|
||||
|
||||
# Check if required env vars are set
|
||||
missing_vars = [var for var in required_env_vars if not os.environ.get(var)]
|
||||
@@ -530,7 +526,7 @@ def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str,
|
||||
assert sample_path is not None, "Sample path must be resolved before starting the function app"
|
||||
|
||||
# Load .env file if it exists and validate required env vars
|
||||
_load_and_validate_env(sample_path)
|
||||
_load_and_validate_env()
|
||||
|
||||
max_attempts = 3
|
||||
last_error: Exception | None = None
|
||||
|
||||
@@ -42,7 +42,6 @@ class TestWorkflowParallel:
|
||||
self.base_url = base_url
|
||||
self.helper = sample_helper
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
def test_parallel_workflow_document_analysis(self) -> None:
|
||||
"""Test parallel workflow with a standard document."""
|
||||
payload = {
|
||||
@@ -71,7 +70,6 @@ class TestWorkflowParallel:
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
def test_parallel_workflow_short_document(self) -> None:
|
||||
"""Test parallel workflow with a short document."""
|
||||
payload = {
|
||||
@@ -91,7 +89,6 @@ class TestWorkflowParallel:
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
def test_parallel_workflow_technical_document(self) -> None:
|
||||
"""Test parallel workflow with a technical document."""
|
||||
payload = {
|
||||
@@ -115,7 +112,6 @@ class TestWorkflowParallel:
|
||||
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
def test_workflow_status_endpoint(self) -> None:
|
||||
"""Test that the workflow status endpoint works correctly."""
|
||||
payload = {
|
||||
|
||||
@@ -14,8 +14,8 @@ Highlights
|
||||
|
||||
```bash
|
||||
pip install agent-framework-core --pre
|
||||
# Optional: Add Azure AI Foundry integration
|
||||
pip install agent-framework-foundry --pre
|
||||
# Optional: Add Azure AI integration
|
||||
pip install agent-framework-azure-ai --pre
|
||||
```
|
||||
|
||||
Supported Platforms:
|
||||
@@ -36,8 +36,8 @@ AZURE_OPENAI_API_KEY=...
|
||||
AZURE_OPENAI_ENDPOINT=...
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
|
||||
...
|
||||
FOUNDRY_PROJECT_ENDPOINT=...
|
||||
FOUNDRY_MODEL=...
|
||||
AZURE_AI_PROJECT_ENDPOINT=...
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=...
|
||||
```
|
||||
|
||||
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
||||
|
||||
@@ -24,6 +24,9 @@ from typing import (
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.shared.exceptions import McpError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage]
|
||||
@@ -68,9 +71,6 @@ else:
|
||||
from typing_extensions import Self, TypedDict # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
|
||||
from ._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from ._types import ChatOptions
|
||||
|
||||
@@ -1369,15 +1369,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
Returns:
|
||||
The MCP server instance.
|
||||
"""
|
||||
try:
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
"`mcp` is required to use `Agent.as_mcp_server()`. Please install `mcp`."
|
||||
) from exc
|
||||
|
||||
server_args: dict[str, Any] = {
|
||||
"name": server_name,
|
||||
"version": version,
|
||||
@@ -1478,8 +1469,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
|
||||
class Agent(
|
||||
AgentMiddlewareLayer,
|
||||
AgentTelemetryLayer,
|
||||
AgentMiddlewareLayer,
|
||||
RawAgent[OptionsCoT],
|
||||
Generic[OptionsCoT],
|
||||
):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user