mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ae71251d | ||
|
|
48edeb3f67 | ||
|
|
8940b45d5b | ||
|
|
78c7d5fc84 | ||
|
|
9cf4c9169a | ||
|
|
2c6aa98b18 | ||
|
|
334ce4dfe0 | ||
|
|
0b69d7fd15 | ||
|
|
7b70f80036 | ||
|
|
da32e8cf80 | ||
|
|
62e02da698 |
@@ -336,6 +336,53 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Integration Tests - Cosmos
|
||||
@@ -402,6 +449,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -465,6 +513,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -80,6 +81,8 @@ jobs:
|
||||
- 'python/packages/foundry/**'
|
||||
- 'python/samples/**/providers/foundry/**'
|
||||
- 'python/samples/02-agents/embeddings/foundry_embeddings.py'
|
||||
foundry_hosting:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
# run only if 'python' files were changed
|
||||
@@ -488,6 +491,67 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Tests - Foundry Hosting Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.foundryHostingChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
@@ -569,6 +633,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -629,6 +694,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
+21
-3
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add functional workflow API ([#4238](https://github.com/microsoft/agent-framework/pull/4238))
|
||||
- **agent-framework-core**, **agent-framework-github-copilot**: Add OpenTelemetry integration for `GitHubCopilotAgent` ([#5142](https://github.com/microsoft/agent-framework/pull/5142))
|
||||
- **agent-framework-a2a**: Add Agent Framework to A2A bridge support ([#2403](https://github.com/microsoft/agent-framework/pull/2403))
|
||||
- **agent-framework-foundry**: Surface `oauth_consent_request` events from Responses API in Foundry clients ([#5070](https://github.com/microsoft/agent-framework/pull/5070))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**, **agent-framework-foundry**: Update `FoundryAgent` for hosted agent sessions ([#5447](https://github.com/microsoft/agent-framework/pull/5447))
|
||||
- **agent-framework-foundry-hosting**: Upgrade hosting server dependency and add more type support ([#5459](https://github.com/microsoft/agent-framework/pull/5459))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-ag-ui**: Fix reasoning role and multimodal media parsing to follow specification ([#5389](https://github.com/microsoft/agent-framework/pull/5389))
|
||||
- **agent-framework-foundry**: Stop emitting `[TOOLBOXES]` warning for every `FoundryChatClient` call ([#5440](https://github.com/microsoft/agent-framework/pull/5440))
|
||||
- **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**: Fix user agent prefix ([#5455](https://github.com/microsoft/agent-framework/pull/5455))
|
||||
|
||||
## [1.1.1] - 2026-04-23
|
||||
|
||||
### Added
|
||||
@@ -26,8 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125))
|
||||
- **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414))
|
||||
- **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384))
|
||||
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads
|
||||
([#5424](https://github.com/microsoft/agent-framework/pull/5424))
|
||||
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
|
||||
|
||||
## [1.1.0] - 2026-04-21
|
||||
|
||||
@@ -961,7 +977,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...HEAD
|
||||
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
|
||||
[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1
|
||||
[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0
|
||||
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
|
||||
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -213,6 +213,15 @@ from ._workflows._executor import (
|
||||
handler,
|
||||
)
|
||||
from ._workflows._function_executor import FunctionExecutor, executor
|
||||
from ._workflows._functional import (
|
||||
FunctionalWorkflow,
|
||||
FunctionalWorkflowAgent,
|
||||
RunContext,
|
||||
StepWrapper,
|
||||
get_run_context,
|
||||
step,
|
||||
workflow,
|
||||
)
|
||||
from ._workflows._request_info_mixin import response_handler
|
||||
from ._workflows._runner import Runner
|
||||
from ._workflows._runner_context import (
|
||||
@@ -332,6 +341,8 @@ __all__ = [
|
||||
"FunctionMiddleware",
|
||||
"FunctionMiddlewareTypes",
|
||||
"FunctionTool",
|
||||
"FunctionalWorkflow",
|
||||
"FunctionalWorkflowAgent",
|
||||
"GeneratedEmbeddings",
|
||||
"GraphConnectivityError",
|
||||
"HistoryProvider",
|
||||
@@ -354,6 +365,7 @@ __all__ = [
|
||||
"ResponseStream",
|
||||
"Role",
|
||||
"RoleLiteral",
|
||||
"RunContext",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SecretString",
|
||||
@@ -366,6 +378,7 @@ __all__ = [
|
||||
"SkillScriptRunner",
|
||||
"SkillsProvider",
|
||||
"SlidingWindowStrategy",
|
||||
"StepWrapper",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SummarizationStrategy",
|
||||
@@ -424,6 +437,7 @@ __all__ = [
|
||||
"evaluator",
|
||||
"executor",
|
||||
"function_middleware",
|
||||
"get_run_context",
|
||||
"handler",
|
||||
"included_messages",
|
||||
"included_token_count",
|
||||
@@ -439,6 +453,7 @@ __all__ = [
|
||||
"register_state_type",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"step",
|
||||
"tool",
|
||||
"tool_call_args_match",
|
||||
"tool_called_check",
|
||||
@@ -447,4 +462,5 @@ __all__ = [
|
||||
"validate_tool_mode",
|
||||
"validate_tools",
|
||||
"validate_workflow_graph",
|
||||
"workflow",
|
||||
]
|
||||
|
||||
@@ -48,6 +48,7 @@ class ExperimentalFeature(str, Enum):
|
||||
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
SKILLS = "SKILLS"
|
||||
TOOLBOXES = "TOOLBOXES"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Final
|
||||
@@ -60,13 +61,12 @@ def _detect_hosted_environment() -> None:
|
||||
global _hosted_env_detected
|
||||
if _hosted_env_detected:
|
||||
return
|
||||
_hosted_env_detected = True
|
||||
|
||||
env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)
|
||||
if env_value is not None:
|
||||
if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None:
|
||||
# Env var exists — trust its value and skip the fallback.
|
||||
if env_value:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
_hosted_env_detected = True
|
||||
return
|
||||
|
||||
# Env var not set — fall back to AgentConfig as a second layer of defense.
|
||||
@@ -78,13 +78,12 @@ def _detect_hosted_environment() -> None:
|
||||
return
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(ImportError, AttributeError):
|
||||
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
|
||||
|
||||
if AgentConfig.from_env().is_hosted:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
_hosted_env_detected = True
|
||||
|
||||
|
||||
def get_user_agent() -> str:
|
||||
|
||||
@@ -120,6 +120,7 @@ WorkflowEventType = Literal[
|
||||
"executor_invoked", # Executor handler was called (use .executor_id, .data)
|
||||
"executor_completed", # Executor handler completed (use .executor_id, .data)
|
||||
"executor_failed", # Executor handler raised error (use .executor_id, .details)
|
||||
"executor_bypassed", # Executor skipped via cache hit during replay (use .executor_id, .data)
|
||||
# Orchestration event types (use .data for typed payload)
|
||||
"group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501
|
||||
"handoff_sent", # Handoff routing events (use .data as HandoffSentEvent)
|
||||
@@ -148,6 +149,7 @@ class WorkflowEvent(Generic[DataT]):
|
||||
- `WorkflowEvent.executor_invoked(executor_id)` - executor handler called
|
||||
- `WorkflowEvent.executor_completed(executor_id)` - executor handler completed
|
||||
- `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed
|
||||
- `WorkflowEvent.executor_bypassed(executor_id)` - executor skipped via cache hit
|
||||
|
||||
The generic parameter DataT represents the type of the event's data payload:
|
||||
- Lifecycle events: `WorkflowEvent[None]` (data is None)
|
||||
@@ -318,6 +320,11 @@ class WorkflowEvent(Generic[DataT]):
|
||||
"""Create an 'executor_failed' event when an executor handler raises an error."""
|
||||
return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details)
|
||||
|
||||
@classmethod
|
||||
def executor_bypassed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_bypassed' event when a step is skipped via cache hit during replay."""
|
||||
return cls("executor_bypassed", executor_id=executor_id, data=data)
|
||||
|
||||
# ==========================================================================
|
||||
# Property for type-safe access
|
||||
# ==========================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -340,10 +340,10 @@ class Workflow(DictConvertible):
|
||||
# Emit explicit start/status events to the stream
|
||||
with _framework_event_origin():
|
||||
started = WorkflowEvent.started()
|
||||
yield started
|
||||
yield started # noqa: RUF070
|
||||
with _framework_event_origin():
|
||||
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
yield in_progress
|
||||
yield in_progress # noqa: RUF070
|
||||
|
||||
# Reset context for a new run if supported
|
||||
if reset_context:
|
||||
@@ -388,7 +388,7 @@ class Workflow(DictConvertible):
|
||||
emitted_in_progress_pending = True
|
||||
with _framework_event_origin():
|
||||
pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
yield pending_status
|
||||
yield pending_status # noqa: RUF070
|
||||
# Workflow runs until idle - emit final status based on whether requests are pending
|
||||
if saw_request:
|
||||
with _framework_event_origin():
|
||||
@@ -409,10 +409,10 @@ class Workflow(DictConvertible):
|
||||
details = WorkflowErrorDetails.from_exception(exc)
|
||||
with _framework_event_origin():
|
||||
failed_event = WorkflowEvent.failed(details)
|
||||
yield failed_event
|
||||
yield failed_event # noqa: RUF070
|
||||
with _framework_event_origin():
|
||||
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
|
||||
yield failed_status
|
||||
yield failed_status # noqa: RUF070
|
||||
span.add_event(
|
||||
name=OtelAttr.WORKFLOW_ERROR,
|
||||
attributes={
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -529,11 +529,12 @@ class TestFunctionExecutor:
|
||||
assert "@handler on instance methods" in str(exc_info.value)
|
||||
|
||||
async def test_async_staticmethod_detection_behavior(self):
|
||||
"""Document the behavior of asyncio.iscoroutinefunction with staticmethod descriptors.
|
||||
"""Document the behavior of inspect.iscoroutinefunction with staticmethod descriptors.
|
||||
|
||||
This test explains why the unwrapping is necessary when decorators are stacked.
|
||||
"""
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
# When @staticmethod is applied, it creates a descriptor
|
||||
async def my_async_func():
|
||||
@@ -544,19 +545,19 @@ class TestFunctionExecutor:
|
||||
static_wrapped = staticmethod(my_async_func)
|
||||
|
||||
# Direct check on descriptor object fails (this is the bug)
|
||||
assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated]
|
||||
assert not inspect.iscoroutinefunction(static_wrapped)
|
||||
assert isinstance(static_wrapped, staticmethod)
|
||||
|
||||
# But unwrapping __func__ reveals the async function
|
||||
unwrapped = static_wrapped.__func__
|
||||
assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated]
|
||||
assert inspect.iscoroutinefunction(unwrapped)
|
||||
|
||||
# When accessed via class attribute, Python's descriptor protocol
|
||||
# automatically unwraps it, so it works:
|
||||
class C:
|
||||
async_static = static_wrapped
|
||||
|
||||
assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol
|
||||
assert inspect.iscoroutinefunction(C.async_static) # Works via descriptor protocol
|
||||
|
||||
|
||||
class TestExecutorExplicitTypes:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -655,7 +655,13 @@ async def test_devui_streaming_renderer_memory_is_bounded(
|
||||
)
|
||||
|
||||
try:
|
||||
websocket_url = await _get_devtools_websocket_url(debug_port)
|
||||
try:
|
||||
websocket_url = await _get_devtools_websocket_url(debug_port)
|
||||
except RuntimeError as exc:
|
||||
return_code = browser_process.poll()
|
||||
if return_code is not None:
|
||||
pytest.skip(f"Chromium exited before DevTools became available (code {return_code}).")
|
||||
pytest.skip(str(exc))
|
||||
|
||||
async with websocket_connect(websocket_url, max_size=None) as websocket:
|
||||
client = _CDPClient(websocket)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient
|
||||
from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
|
||||
from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
|
||||
from ._embedding_client import (
|
||||
FoundryEmbeddingClient,
|
||||
@@ -25,6 +25,7 @@ except importlib.metadata.PackageNotFoundError:
|
||||
|
||||
__all__ = [
|
||||
"FoundryAgent",
|
||||
"FoundryAgentOptions",
|
||||
"FoundryChatClient",
|
||||
"FoundryChatOptions",
|
||||
"FoundryEmbeddingClient",
|
||||
|
||||
@@ -16,8 +16,10 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentMiddlewareLayer,
|
||||
AgentSession,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
ChatResponseUpdate,
|
||||
ContextProvider,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
@@ -34,6 +36,8 @@ from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
|
||||
|
||||
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -52,11 +56,13 @@ else:
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentRunInputs,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ContextProvider,
|
||||
MiddlewareTypes,
|
||||
ToolTypes,
|
||||
)
|
||||
from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
|
||||
|
||||
@@ -81,14 +87,54 @@ class FoundryAgentSettings(TypedDict, total=False):
|
||||
agent_version: str | None
|
||||
|
||||
|
||||
class FoundryAgentOptions(OpenAIChatOptions, total=False):
|
||||
"""Microsoft Foundry agent-specific chat options.
|
||||
|
||||
Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by
|
||||
``FoundryAgent`` / ``RawFoundryAgent``.
|
||||
|
||||
Keyword Args:
|
||||
extra_body: Additional request body values sent to the Responses API.
|
||||
isolation_key: Isolation key used when lazily creating a hosted-agent
|
||||
session through ``project_client.beta.agents.create_session(...)``.
|
||||
"""
|
||||
|
||||
extra_body: dict[str, Any]
|
||||
isolation_key: str
|
||||
|
||||
|
||||
FoundryAgentOptionsT = TypeVar(
|
||||
"FoundryAgentOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="OpenAIChatOptions",
|
||||
default="FoundryAgentOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
|
||||
def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Normalize and merge provider-specific extra_body values."""
|
||||
if extra_body is None:
|
||||
merged: dict[str, Any] = {}
|
||||
elif isinstance(extra_body, Mapping):
|
||||
merged = dict(cast(Mapping[str, Any], extra_body))
|
||||
else:
|
||||
raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.")
|
||||
|
||||
if additions:
|
||||
merged.update(additions)
|
||||
return merged
|
||||
|
||||
|
||||
def _uses_foundry_agent_session(conversation_id: Any) -> bool:
|
||||
"""Return whether a conversation_id should be treated as a Foundry agent session id."""
|
||||
return (
|
||||
isinstance(conversation_id, str)
|
||||
and bool(conversation_id)
|
||||
and not conversation_id.startswith("resp_")
|
||||
and not conversation_id.startswith("conv_")
|
||||
)
|
||||
|
||||
|
||||
class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
RawOpenAIChatClient[FoundryAgentOptionsT],
|
||||
Generic[FoundryAgentOptionsT],
|
||||
@@ -167,13 +213,15 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
)
|
||||
|
||||
resolved_endpoint = settings.get("project_endpoint")
|
||||
self.agent_name = settings.get("agent_name")
|
||||
self.agent_version = settings.get("agent_version")
|
||||
agent_name_setting = settings.get("agent_name")
|
||||
self.agent_version: str | None = settings.get("agent_version")
|
||||
self.allow_preview = allow_preview or False
|
||||
|
||||
if not self.agent_name:
|
||||
if not agent_name_setting:
|
||||
raise ValueError(
|
||||
"Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable."
|
||||
)
|
||||
self.agent_name = agent_name_setting
|
||||
|
||||
# Create or use provided project client
|
||||
self._should_close_client = False
|
||||
@@ -197,11 +245,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
self.project_client = AIProjectClient(**project_client_kwargs)
|
||||
self._should_close_client = True
|
||||
|
||||
# Get OpenAI client from project
|
||||
async_client = self.project_client.get_openai_client()
|
||||
|
||||
openai_client_kwargs: dict[str, Any] = {}
|
||||
if default_headers:
|
||||
openai_client_kwargs["default_headers"] = dict(default_headers)
|
||||
if allow_preview:
|
||||
openai_client_kwargs["agent_name"] = self.agent_name
|
||||
super().__init__(
|
||||
async_client=async_client,
|
||||
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
|
||||
default_headers=default_headers,
|
||||
instruction_role=instruction_role,
|
||||
compaction_strategy=compaction_strategy,
|
||||
@@ -209,13 +259,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
def _get_agent_reference(self) -> dict[str, str]:
|
||||
"""Build the agent reference dict for the Responses API."""
|
||||
ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item]
|
||||
if self.agent_version:
|
||||
ref["version"] = self.agent_version
|
||||
return ref
|
||||
|
||||
@override
|
||||
def as_agent(
|
||||
self,
|
||||
@@ -270,7 +313,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare options for the Responses API, injecting agent reference and validating tools."""
|
||||
"""Prepare options for the Responses API and validate client-side tools."""
|
||||
# Validate tools — only FunctionTool allowed
|
||||
tools = options.get("tools", [])
|
||||
if tools:
|
||||
@@ -292,18 +335,61 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
if "input" in run_options and isinstance(run_options["input"], list):
|
||||
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
|
||||
|
||||
# Inject agent reference
|
||||
run_options["extra_body"] = {"agent_reference": self._get_agent_reference()}
|
||||
# Merge caller-supplied extra_body with any agent-specific request payload.
|
||||
conversation_id = options.get("conversation_id")
|
||||
extra_body = _merge_extra_body(run_options.pop("extra_body", None))
|
||||
if _uses_foundry_agent_session(conversation_id):
|
||||
run_options.pop("previous_response_id", None)
|
||||
run_options.pop("conversation", None)
|
||||
extra_body["agent_session_id"] = conversation_id
|
||||
if extra_body:
|
||||
run_options["extra_body"] = extra_body
|
||||
|
||||
run_options.pop("isolation_key", None)
|
||||
|
||||
# Strip tools from request body - Foundry API rejects requests with both
|
||||
# agent_reference and tools present. FunctionTools are invoked client-side
|
||||
# agent endpoint and tools present. FunctionTools are invoked client-side
|
||||
# by the function invocation layer, not sent to the service.
|
||||
run_options.pop("tools", None)
|
||||
run_options.pop("tool_choice", None)
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
run_options.pop("model", None)
|
||||
if not self.allow_preview:
|
||||
run_options.pop("tools", None)
|
||||
run_options.pop("tool_choice", None)
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
|
||||
return run_options
|
||||
|
||||
@override
|
||||
def _parse_response_from_openai(
|
||||
self,
|
||||
response: Any,
|
||||
options: dict[str, Any],
|
||||
) -> Any:
|
||||
parsed_response = super()._parse_response_from_openai(response, options)
|
||||
if _uses_foundry_agent_session(options.get("conversation_id")):
|
||||
parsed_response.conversation_id = None
|
||||
return parsed_response
|
||||
|
||||
@override
|
||||
def _parse_chunk_from_openai(
|
||||
self,
|
||||
event: Any,
|
||||
options: dict[str, Any],
|
||||
function_call_ids: dict[int, tuple[str, str]],
|
||||
seen_reasoning_delta_item_ids: set[str] | None = None,
|
||||
) -> ChatResponseUpdate:
|
||||
"""Parse streaming events while preserving hosted-agent session state."""
|
||||
update = try_parse_oauth_consent_event(event, self.model)
|
||||
if update is None:
|
||||
update = super()._parse_chunk_from_openai(
|
||||
event,
|
||||
options,
|
||||
function_call_ids,
|
||||
seen_reasoning_delta_item_ids,
|
||||
)
|
||||
if _uses_foundry_agent_session(options.get("conversation_id")):
|
||||
update.conversation_id = None
|
||||
return update
|
||||
|
||||
@override
|
||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||
"""Skip model check — model is configured on the Foundry agent."""
|
||||
@@ -368,6 +454,26 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
|
||||
return transformed
|
||||
|
||||
async def get_agent_version(self) -> str | None:
|
||||
"""Return the agent version if available, else None."""
|
||||
if self.agent_version is not None:
|
||||
return self.agent_version
|
||||
if not self.allow_preview:
|
||||
return None
|
||||
agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
agent_name=self.agent_name
|
||||
)
|
||||
versions_object = getattr(agent_details, "versions", None)
|
||||
if not isinstance(versions_object, Mapping):
|
||||
raise TypeError("Foundry agent details did not include a versions mapping.")
|
||||
versions = cast(Mapping[str, Any], versions_object)
|
||||
latest_version = versions.get("latest")
|
||||
agent_version = getattr(cast(Any, latest_version), "version", None)
|
||||
if not isinstance(agent_version, str):
|
||||
raise TypeError("Foundry agent details did not include a latest version string.")
|
||||
self.agent_version = agent_version
|
||||
return agent_version
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the project client if we created it."""
|
||||
if self._should_close_client:
|
||||
@@ -395,7 +501,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
client = FoundryAgentClient(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
agent_name="my-prompt-agent",
|
||||
agent_version="1.0",
|
||||
agent_version="1",
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
@@ -477,7 +583,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
agent = RawFoundryAgent(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
agent_name="my-prompt-agent",
|
||||
agent_version="1.0",
|
||||
agent_version="1",
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
result = await agent.run("Hello!")
|
||||
@@ -570,7 +676,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
client=client, # type: ignore[arg-type]
|
||||
instructions=instructions,
|
||||
id=id,
|
||||
name=name,
|
||||
name=name or agent_name,
|
||||
description=description,
|
||||
tools=tools, # type: ignore[arg-type]
|
||||
default_options=cast(FoundryAgentOptionsT | None, default_options),
|
||||
@@ -582,6 +688,81 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
additional_properties=dict(additional_properties) if additional_properties is not None else None,
|
||||
)
|
||||
|
||||
def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str:
|
||||
"""Resolve the isolation key from an explicit value or default_options."""
|
||||
resolved_isolation_key = (
|
||||
isolation_key if isolation_key is not None else self.default_options.get("isolation_key")
|
||||
)
|
||||
if resolved_isolation_key is None:
|
||||
raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].")
|
||||
return resolved_isolation_key
|
||||
|
||||
async def _create_service_session_id(
|
||||
self,
|
||||
*,
|
||||
isolation_key: str | None = None,
|
||||
) -> str:
|
||||
"""Create a hosted Foundry service session and return the service session ID."""
|
||||
if not isinstance(self.client, RawFoundryAgentChatClient):
|
||||
raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.")
|
||||
if not self.client.allow_preview:
|
||||
raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.")
|
||||
|
||||
create_session_kwargs: dict[str, Any] = {
|
||||
"agent_name": self.client.agent_name,
|
||||
"isolation_key": self._resolve_service_session_isolation_key(isolation_key),
|
||||
}
|
||||
if version := await self.client.get_agent_version():
|
||||
from azure.ai.projects.models import VersionRefIndicator
|
||||
|
||||
create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore
|
||||
|
||||
service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs)
|
||||
agent_session_id = getattr(service_session, "agent_session_id", None)
|
||||
if not isinstance(agent_session_id, str) or not agent_session_id:
|
||||
raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.")
|
||||
|
||||
return agent_session_id
|
||||
|
||||
@override
|
||||
async def _prepare_run_context(
|
||||
self,
|
||||
*,
|
||||
messages: AgentRunInputs | None,
|
||||
session: AgentSession | None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
|
||||
options: Mapping[str, Any] | None,
|
||||
compaction_strategy: CompactionStrategy | None,
|
||||
tokenizer: TokenizerProtocol | None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None,
|
||||
client_kwargs: Mapping[str, Any] | None,
|
||||
) -> _RunContext:
|
||||
runtime_options = dict(options) if options else {}
|
||||
effective_options = {
|
||||
**{key: value for key, value in self.default_options.items() if value is not None},
|
||||
**{key: value for key, value in runtime_options.items() if value is not None},
|
||||
}
|
||||
|
||||
if (
|
||||
session is not None
|
||||
and session.service_session_id is None
|
||||
and effective_options.get("isolation_key") is not None
|
||||
):
|
||||
session.service_session_id = await self._create_service_session_id(
|
||||
isolation_key=cast(str | None, effective_options.get("isolation_key")),
|
||||
)
|
||||
|
||||
return await super()._prepare_run_context(
|
||||
messages=messages,
|
||||
session=session,
|
||||
tools=tools,
|
||||
options=runtime_options,
|
||||
compaction_strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
)
|
||||
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
enable_sensitive_data: bool = False,
|
||||
@@ -708,6 +889,19 @@ class FoundryAgent( # type: ignore[misc]
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent with full middleware and telemetry.
|
||||
|
||||
``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents
|
||||
typically provide ``agent_version`` directly. HostedAgents can omit
|
||||
``agent_version`` and, when they need preview-only session APIs, should
|
||||
opt in with ``allow_preview=True`` when this class creates the underlying
|
||||
``AIProjectClient``. If you pass ``project_client`` explicitly, it must
|
||||
already be configured for preview APIs before being passed to
|
||||
``FoundryAgent``.
|
||||
|
||||
To lazily create HostedAgent service sessions inside the agent, pass an
|
||||
``isolation_key`` through ``default_options`` (or per-run options). The
|
||||
agent stores the resulting HostedAgent session ID in
|
||||
``AgentSession.service_session_id`` and reuses it on subsequent runs.
|
||||
|
||||
Keyword Args:
|
||||
project_endpoint: The Foundry project endpoint URL.
|
||||
agent_name: The name of the Foundry agent to connect to.
|
||||
@@ -715,6 +909,9 @@ class FoundryAgent( # type: ignore[misc]
|
||||
credential: Azure credential for authentication.
|
||||
project_client: An existing AIProjectClient to use.
|
||||
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
|
||||
Set this to ``True`` for HostedAgents that need preview-only
|
||||
session APIs, including lazy service session creation from
|
||||
``isolation_key``.
|
||||
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
|
||||
context_providers: Optional context providers.
|
||||
middleware: Optional agent-level middleware.
|
||||
@@ -726,6 +923,8 @@ class FoundryAgent( # type: ignore[misc]
|
||||
description: Optional local description for the local agent wrapper.
|
||||
instructions: Optional instructions for the local agent wrapper.
|
||||
default_options: Default chat options for the local agent wrapper.
|
||||
``FoundryAgentOptions`` can include ``isolation_key`` and
|
||||
``extra_body`` when working with HostedAgents.
|
||||
require_per_service_call_history_persistence: Whether to require per-service-call
|
||||
chat history persistence when using local history providers.
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
|
||||
|
||||
from agent_framework import (
|
||||
ChatMiddlewareLayer,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
@@ -33,6 +34,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
|
||||
|
||||
from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -204,9 +207,13 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
|
||||
openai_kwargs: dict[str, Any] = {}
|
||||
if default_headers:
|
||||
openai_kwargs["default_headers"] = default_headers
|
||||
|
||||
super().__init__(
|
||||
model=resolved_model,
|
||||
async_client=project_client.get_openai_client(),
|
||||
async_client=project_client.get_openai_client(**openai_kwargs),
|
||||
default_headers=default_headers,
|
||||
instruction_role=instruction_role,
|
||||
compaction_strategy=compaction_strategy,
|
||||
@@ -237,6 +244,20 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
response_tools = super()._prepare_tools_for_openai(tools)
|
||||
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
|
||||
@override
|
||||
def _parse_chunk_from_openai(
|
||||
self,
|
||||
event: Any,
|
||||
options: dict[str, Any],
|
||||
function_call_ids: dict[int, tuple[str, str]],
|
||||
seen_reasoning_delta_item_ids: set[str] | None = None,
|
||||
) -> ChatResponseUpdate:
|
||||
"""Parse streaming event, intercepting oauth_consent_request items."""
|
||||
update = try_parse_oauth_consent_event(event, self.model)
|
||||
if update is not None:
|
||||
return update
|
||||
return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids)
|
||||
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
enable_sensitive_data: bool = False,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent_framework import ChatResponseUpdate, Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_consent_link(consent_link: str, item_id: str) -> str:
|
||||
"""Validate a consent link is HTTPS with a valid netloc.
|
||||
|
||||
Returns the link unchanged if valid, or an empty string if not.
|
||||
"""
|
||||
parsed = urlparse(consent_link)
|
||||
if parsed.scheme.lower() != "https" or not parsed.netloc:
|
||||
logger.warning(
|
||||
"Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
|
||||
item_id,
|
||||
)
|
||||
return ""
|
||||
return consent_link
|
||||
|
||||
|
||||
def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
|
||||
"""Parse an oauth_consent_request from a streaming event, if present.
|
||||
|
||||
Returns a ``ChatResponseUpdate`` when *event* is a
|
||||
``response.output_item.added`` carrying an ``oauth_consent_request`` item
|
||||
or a top-level ``response.oauth_consent_requested`` event,
|
||||
or ``None`` so the caller can fall through to the base implementation.
|
||||
"""
|
||||
consent_link: str = ""
|
||||
raw_item: Any = None
|
||||
|
||||
event_type = getattr(event, "type", None)
|
||||
|
||||
if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
|
||||
raw_item = event.item
|
||||
consent_link = getattr(raw_item, "consent_link", None) or ""
|
||||
elif event_type == "response.oauth_consent_requested":
|
||||
raw_item = event
|
||||
consent_link = getattr(event, "consent_link", None) or ""
|
||||
else:
|
||||
return None
|
||||
|
||||
item_id = getattr(raw_item, "id", "<unknown>")
|
||||
|
||||
if consent_link:
|
||||
consent_link = _validate_consent_link(consent_link, item_id)
|
||||
|
||||
contents: list[Content] = []
|
||||
if consent_link:
|
||||
contents.append(
|
||||
Content.from_oauth_consent_request(
|
||||
consent_link=consent_link,
|
||||
raw_representation=raw_item,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Received oauth_consent_request output without valid consent_link (item id=%s)",
|
||||
item_id,
|
||||
)
|
||||
|
||||
return ChatResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
model=model,
|
||||
raw_representation=event,
|
||||
)
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
|
||||
@@ -5,11 +5,22 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentSession,
|
||||
ChatContext,
|
||||
ChatMiddleware,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Message,
|
||||
tool,
|
||||
)
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -54,7 +65,7 @@ def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None:
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
|
||||
"""Test construction with agent_name and project_client."""
|
||||
"""Test construction with agent_name and project_client without preview agent binding."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
@@ -67,6 +78,27 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
|
||||
|
||||
assert client.agent_name == "test-agent"
|
||||
assert client.agent_version == "1.0"
|
||||
mock_project.get_openai_client.assert_called_once_with()
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_passes_agent_name_when_preview_enabled() -> None:
|
||||
"""Test preview-enabled clients bind the OpenAI client to the agent endpoint."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
allow_preview=True,
|
||||
default_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
assert client.agent_name == "hosted-agent"
|
||||
mock_project.get_openai_client.assert_called_once_with(
|
||||
agent_name="hosted-agent",
|
||||
default_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
@@ -80,38 +112,6 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None:
|
||||
"""Test agent reference includes version when provided."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="my-agent",
|
||||
agent_version="2.0",
|
||||
)
|
||||
|
||||
ref = client._get_agent_reference()
|
||||
assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"}
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None:
|
||||
"""Test agent reference omits version for HostedAgents."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
)
|
||||
|
||||
ref = client._get_agent_reference()
|
||||
assert ref == {"name": "hosted-agent", "type": "agent_reference"}
|
||||
assert "version" not in ref
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None:
|
||||
"""Test that as_agent() wraps the client in FoundryAgent using the same client class."""
|
||||
|
||||
@@ -196,12 +196,11 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
|
||||
options={"tools": [my_func]},
|
||||
)
|
||||
|
||||
assert "extra_body" in result
|
||||
assert result["extra_body"]["agent_reference"]["name"] == "test-agent"
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> None:
|
||||
"""Test that _prepare_options strips tools, tool_choice, and parallel_tool_calls from run_options."""
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
|
||||
"""Test that _prepare_options strips model and tool-loop fields from run_options."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = MagicMock()
|
||||
@@ -222,6 +221,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"model": "gpt-4.1",
|
||||
"tools": [{"type": "function", "function": {"name": "my_func"}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
@@ -232,11 +232,94 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N
|
||||
options={"tools": [my_func]},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
assert "tools" not in result
|
||||
assert "tool_choice" not in result
|
||||
assert "parallel_tool_calls" not in result
|
||||
assert "extra_body" in result
|
||||
assert result["extra_body"]["agent_reference"]["name"] == "test-agent"
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
|
||||
"""Test that service_session_id is forwarded as agent_session_id for hosted sessions."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = MagicMock()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"extra_body": {"custom": "value"},
|
||||
"previous_response_id": "should-be-removed",
|
||||
},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"},
|
||||
)
|
||||
|
||||
assert result["extra_body"] == {
|
||||
"custom": "value",
|
||||
"agent_session_id": "agent-session-123",
|
||||
}
|
||||
assert "previous_response_id" not in result
|
||||
assert "conversation" not in result
|
||||
assert "isolation_key" not in result
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None:
|
||||
"""Test that agent-session continuations do not overwrite session.service_session_id."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
parsed = ChatResponse(conversation_id="resp_123")
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai",
|
||||
return_value=parsed,
|
||||
):
|
||||
result = client._parse_response_from_openai(
|
||||
response=MagicMock(),
|
||||
options={"conversation_id": "agent-session-123"},
|
||||
)
|
||||
|
||||
assert result.conversation_id is None
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None:
|
||||
"""Test that agent-session stream updates do not overwrite session.service_session_id."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
parsed = ChatResponseUpdate(conversation_id="resp_123")
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai",
|
||||
return_value=parsed,
|
||||
):
|
||||
result = client._parse_chunk_from_openai(
|
||||
event=MagicMock(type="response.output_text.delta"),
|
||||
options={"conversation_id": "agent-session-123"},
|
||||
function_call_ids={},
|
||||
)
|
||||
|
||||
assert result.conversation_id is None
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None:
|
||||
@@ -366,6 +449,74 @@ def test_raw_foundry_agent_init_with_function_tools() -> None:
|
||||
assert agent.default_options.get("tools") is not None
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None:
|
||||
"""Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
mock_project.beta = SimpleNamespace(
|
||||
agents=SimpleNamespace(
|
||||
create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123"))
|
||||
)
|
||||
)
|
||||
|
||||
agent = RawFoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
agent_version="1.0",
|
||||
allow_preview=True,
|
||||
)
|
||||
session = AgentSession()
|
||||
|
||||
with patch(
|
||||
"agent_framework._agents.RawAgent._prepare_run_context",
|
||||
new=AsyncMock(return_value={"ok": True}),
|
||||
) as mock_prepare_run_context:
|
||||
result = await agent._prepare_run_context(
|
||||
messages="hi",
|
||||
session=session,
|
||||
tools=None,
|
||||
options={"isolation_key": "iso-key"},
|
||||
compaction_strategy=None,
|
||||
tokenizer=None,
|
||||
function_invocation_kwargs=None,
|
||||
client_kwargs=None,
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert session.service_session_id == "agent-session-123"
|
||||
mock_project.beta.agents.create_session.assert_awaited_once()
|
||||
create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs
|
||||
assert create_session_kwargs["agent_name"] == "test-agent"
|
||||
assert create_session_kwargs["isolation_key"] == "iso-key"
|
||||
assert "version_indicator" in create_session_kwargs
|
||||
mock_prepare_run_context.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None:
|
||||
"""Test that hosted-agent sessions require allow_preview=True."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
agent = RawFoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="allow_preview=True"):
|
||||
await agent._prepare_run_context(
|
||||
messages="hi",
|
||||
session=AgentSession(),
|
||||
tools=None,
|
||||
options={"isolation_key": "iso-key"},
|
||||
compaction_strategy=None,
|
||||
tokenizer=None,
|
||||
function_invocation_kwargs=None,
|
||||
client_kwargs=None,
|
||||
)
|
||||
|
||||
|
||||
def test_foundry_agent_init() -> None:
|
||||
"""Test construction of the full-middleware agent."""
|
||||
|
||||
@@ -483,9 +634,10 @@ async def test_foundry_agent_configure_azure_monitor_import_error() -> None:
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_agent_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
|
||||
async def test_foundry_agent_basic_run() -> None:
|
||||
"""Smoke-test FoundryAgent against a real configured agent."""
|
||||
async with FoundryAgent(credential=AzureCliCredential()) as agent:
|
||||
async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent:
|
||||
response = await agent.run("Please respond with exactly: 'This is a response test.'")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
@@ -496,6 +648,7 @@ async def test_foundry_agent_basic_run() -> None:
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_agent_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
|
||||
async def test_foundry_agent_custom_client_run() -> None:
|
||||
"""Smoke-test FoundryAgent against a real configured agent."""
|
||||
async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent:
|
||||
@@ -504,3 +657,158 @@ async def test_foundry_agent_custom_client_run() -> None:
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert "response test" in response.text.lower()
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
|
||||
"""An oauth_consent_request output item surfaces as Content with consent_link."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = "https://consent-host.example.com/login?data=abc123"
|
||||
mock_item.id = "oauth-item-1"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 1
|
||||
assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123"
|
||||
assert update.role == "assistant"
|
||||
assert update.raw_representation is mock_event
|
||||
|
||||
|
||||
def test_parse_chunk_skips_non_https_oauth_consent() -> None:
|
||||
"""An oauth_consent_request with a non-HTTPS link is rejected."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = "http://insecure.example.com/login"
|
||||
mock_item.id = "oauth-item-2"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_handles_missing_consent_link() -> None:
|
||||
"""An oauth_consent_request without a consent_link produces no content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = None
|
||||
mock_item.id = "oauth-item-3"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_handles_empty_string_consent_link() -> None:
|
||||
"""An oauth_consent_request with empty-string consent_link produces no content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = ""
|
||||
mock_item.id = "oauth-item-4"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
|
||||
"""Non-oauth events are delegated to super()._parse_chunk_from_openai()."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.delta"
|
||||
|
||||
with patch.object(
|
||||
RawOpenAIChatClient,
|
||||
"_parse_chunk_from_openai",
|
||||
return_value=MagicMock(),
|
||||
) as mock_super:
|
||||
client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
mock_super.assert_called_once_with(mock_event, {}, {}, None)
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
|
||||
"""A top-level response.oauth_consent_requested event surfaces as Content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.oauth_consent_requested"
|
||||
mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz"
|
||||
mock_event.id = "consent-event-1"
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 1
|
||||
assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz"
|
||||
assert update.role == "assistant"
|
||||
assert update.raw_representation is mock_event
|
||||
|
||||
@@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework_openai import OpenAIContentFilterException
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -993,3 +994,165 @@ def test_get_mcp_tool_with_connection_id() -> None:
|
||||
description="GitHub MCP via Foundry",
|
||||
)
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
|
||||
"""An oauth_consent_request output item surfaces as Content with consent_link."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = "https://consent-host.example.com/login?data=abc123"
|
||||
mock_item.id = "oauth-item-1"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 1
|
||||
assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123"
|
||||
assert update.role == "assistant"
|
||||
assert update.raw_representation is mock_event
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
def test_parse_chunk_skips_non_https_oauth_consent() -> None:
|
||||
"""An oauth_consent_request with a non-HTTPS link is rejected."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = "http://insecure.example.com/login"
|
||||
mock_item.id = "oauth-item-2"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_handles_missing_consent_link() -> None:
|
||||
"""An oauth_consent_request without a consent_link produces no content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = None
|
||||
mock_item.id = "oauth-item-3"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_handles_empty_string_consent_link() -> None:
|
||||
"""An oauth_consent_request with empty-string consent_link produces no content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "oauth_consent_request"
|
||||
mock_item.consent_link = ""
|
||||
mock_item.id = "oauth-item-4"
|
||||
mock_event.item = mock_item
|
||||
mock_event.output_index = 0
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 0
|
||||
|
||||
|
||||
def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
|
||||
"""Non-oauth events are delegated to super()._parse_chunk_from_openai()."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.delta"
|
||||
|
||||
with patch.object(
|
||||
RawOpenAIChatClient,
|
||||
"_parse_chunk_from_openai",
|
||||
return_value=MagicMock(),
|
||||
) as mock_super:
|
||||
client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
mock_super.assert_called_once_with(mock_event, {}, {}, None)
|
||||
|
||||
|
||||
def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
|
||||
"""A top-level response.oauth_consent_requested event surfaces as Content."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.oauth_consent_requested"
|
||||
mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz"
|
||||
mock_event.id = "consent-event-1"
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
||||
|
||||
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent_contents) == 1
|
||||
assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz"
|
||||
assert update.role == "assistant"
|
||||
assert update.raw_representation is mock_event
|
||||
|
||||
@@ -198,6 +198,7 @@ class TestRawFoundryEmbeddingClient:
|
||||
"FOUNDRY_MODELS_API_KEY": "env-key",
|
||||
"FOUNDRY_EMBEDDING_MODEL": "env-model",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
|
||||
|
||||
# region _validate_consent_link tests
|
||||
|
||||
|
||||
def test_validate_consent_link_accepts_valid_https() -> None:
|
||||
"""A valid HTTPS URL with a netloc passes validation."""
|
||||
link = "https://consent.example.com/auth?code=123"
|
||||
assert _validate_consent_link(link, "item-1") == link
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTP link is rejected and a warning is logged."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-2" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("https:///path", "item-3")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-3" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-URL string is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("not-a-url", "item-4")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region try_parse_oauth_consent_event tests
|
||||
|
||||
|
||||
def _make_output_item_event(
|
||||
*,
|
||||
item_type: str = "oauth_consent_request",
|
||||
consent_link: Any = "https://consent.example.com/auth",
|
||||
item_id: str = "oauth-item-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.output_item.added`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_item.added"
|
||||
item = MagicMock()
|
||||
item.type = item_type
|
||||
item.consent_link = consent_link
|
||||
item.id = item_id
|
||||
event.item = item
|
||||
return event
|
||||
|
||||
|
||||
def _make_top_level_event(
|
||||
*,
|
||||
consent_link: Any = "https://consent.example.com/authorize",
|
||||
event_id: str = "consent-event-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.oauth_consent_requested`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.oauth_consent_requested"
|
||||
event.consent_link = consent_link
|
||||
event.id = event_id
|
||||
return event
|
||||
|
||||
|
||||
def test_returns_none_for_unrelated_event() -> None:
|
||||
"""An event with a non-oauth type returns None."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_text.delta"
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_returns_none_for_event_without_type() -> None:
|
||||
"""An event object missing a 'type' attribute returns None."""
|
||||
event = object() # no type attribute
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_parses_output_item_added_with_valid_link() -> None:
|
||||
"""A response.output_item.added event with a valid HTTPS link produces Content."""
|
||||
event = _make_output_item_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert update.role == "assistant"
|
||||
assert update.model == "test-model"
|
||||
assert update.raw_representation is event
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/auth"
|
||||
|
||||
|
||||
def test_parses_top_level_consent_requested_event() -> None:
|
||||
"""A response.oauth_consent_requested event produces Content."""
|
||||
event = _make_top_level_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/authorize"
|
||||
|
||||
|
||||
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A None consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link=None, item_id="item-none")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="", item_id="item-empty")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
|
||||
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -172,12 +173,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
self._agent = agent
|
||||
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
@staticmethod
|
||||
def _is_streaming_request(request: CreateResponse) -> bool:
|
||||
"""Check if the request is a streaming request."""
|
||||
return request.stream is not None and request.stream is True
|
||||
|
||||
def _handle_response(
|
||||
async def _handle_response(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
@@ -186,11 +182,10 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""Handle the creation of a response."""
|
||||
if self._is_workflow_agent:
|
||||
# Workflow agents are handled differently because they require checkpoint restoration
|
||||
return self._handle_workflow_agent(request, context)
|
||||
return self._handle_inner_workflow(request, context)
|
||||
return self._handle_inner_agent(request, context)
|
||||
|
||||
return self._handle_regular_agent(request, context)
|
||||
|
||||
async def _handle_regular_agent(
|
||||
async def _handle_inner_agent(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
@@ -200,25 +195,24 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
input_messages = _items_to_messages(input_items)
|
||||
|
||||
history = await context.get_history()
|
||||
messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages]
|
||||
run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]}
|
||||
is_streaming_request = request.stream is not None and request.stream is True
|
||||
|
||||
chat_options, are_options_set = _to_chat_options(request)
|
||||
|
||||
is_streaming_request = self._is_streaming_request(request)
|
||||
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
|
||||
|
||||
yield response_event_stream.emit_created()
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if are_options_set and not isinstance(self._agent, RawAgent):
|
||||
logger.warning("Agent doesn't support runtime options. They will be ignored.")
|
||||
else:
|
||||
run_kwargs["options"] = chat_options
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
if isinstance(self._agent, RawAgent):
|
||||
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
|
||||
response = await raw_agent.run(messages, stream=False, options=chat_options)
|
||||
else:
|
||||
if are_options_set:
|
||||
logger.warning("Agent doesn't support runtime options. They will be ignored.")
|
||||
response = await self._agent.run(messages, stream=False)
|
||||
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
@@ -228,20 +222,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Run the agent in streaming mode
|
||||
if isinstance(self._agent, RawAgent):
|
||||
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
|
||||
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
|
||||
else:
|
||||
if are_options_set:
|
||||
logger.warning("Agent doesn't support runtime options. They will be ignored.")
|
||||
response_stream = self._agent.run(messages, stream=True)
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
async for update in response_stream:
|
||||
# Run the agent in streaming mode
|
||||
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
@@ -256,7 +242,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
|
||||
yield response_event_stream.emit_completed()
|
||||
|
||||
async def _handle_workflow_agent(
|
||||
async def _handle_inner_workflow(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
@@ -269,8 +255,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""
|
||||
input_items = await context.get_input_items()
|
||||
input_messages = _items_to_messages(input_items)
|
||||
|
||||
is_streaming_request = self._is_streaming_request(request)
|
||||
is_streaming_request = request.stream is not None and request.stream is True
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
if are_options_set:
|
||||
@@ -311,7 +296,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
|
||||
|
||||
# Create a new checkpoint storage for this response based on the following rules:
|
||||
# - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response
|
||||
# - If no previous response ID or conversation ID is provided,
|
||||
# create a new checkpoint storage for this response
|
||||
# - If a previous response ID is provided, create a new checkpoint storage for this response
|
||||
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
|
||||
context_id = context.conversation_id or context.response_id
|
||||
@@ -333,14 +319,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Run the agent in streaming mode
|
||||
response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
async for update in response_stream:
|
||||
# Run the workflow agent in streaming mode
|
||||
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
@@ -355,7 +339,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None:
|
||||
@@ -1093,6 +1076,31 @@ def _convert_output_message_content(content: OutputMessageContent) -> Content:
|
||||
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
|
||||
|
||||
|
||||
def _convert_file_data(data_uri: str, filename: str | None = None) -> Content:
|
||||
"""Convert a file_data data URI to a Content object.
|
||||
|
||||
For text/* MIME types, decodes the base64 content and returns it as text.
|
||||
For other types, returns a URI-based Content with the filename preserved.
|
||||
"""
|
||||
# Parse data URI: data:<media_type>;base64,<data>
|
||||
if data_uri.startswith("data:") and ";base64," in data_uri:
|
||||
header, encoded = data_uri.split(";base64,", 1)
|
||||
media_type = header[len("data:") :]
|
||||
if media_type.startswith("text/"):
|
||||
try:
|
||||
decoded_text = base64.b64decode(encoded).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
logger.warning(
|
||||
"Failed to decode text/* file_data as UTF-8, falling through to URI passthrough.",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
prefix = f"[File: {filename}]\n" if filename else ""
|
||||
return Content.from_text(f"{prefix}{decoded_text}")
|
||||
additional_properties = {"filename": filename} if filename else None
|
||||
return Content.from_uri(data_uri, additional_properties=additional_properties)
|
||||
|
||||
|
||||
def _convert_message_content(content: MessageContent) -> Content:
|
||||
"""Converts a MessageContent to a Content object.
|
||||
|
||||
@@ -1126,7 +1134,9 @@ def _convert_message_content(content: MessageContent) -> Content:
|
||||
if content.type == "input_image":
|
||||
image = cast(MessageContentInputImageContent, content)
|
||||
if image.image_url:
|
||||
return Content.from_uri(image.image_url)
|
||||
if image.image_url.startswith("data:"):
|
||||
return Content.from_uri(image.image_url)
|
||||
return Content.from_uri(image.image_url, media_type="image/*")
|
||||
if image.file_id:
|
||||
return Content.from_hosted_file(image.file_id)
|
||||
if content.type == "input_file":
|
||||
@@ -1135,6 +1145,8 @@ def _convert_message_content(content: MessageContent) -> Content:
|
||||
return Content.from_uri(file.file_url)
|
||||
if file.file_id:
|
||||
return Content.from_hosted_file(file.file_id, name=file.filename)
|
||||
if file.file_data:
|
||||
return _convert_file_data(file.file_data, file.filename)
|
||||
if content.type == "computer_screenshot":
|
||||
screenshot = cast(ComputerScreenshotContent, content)
|
||||
return Content.from_uri(screenshot.image_url)
|
||||
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"azure-ai-agentserver-core==2.0.0b3",
|
||||
"azure-ai-agentserver-responses==1.0.0b5",
|
||||
"azure-ai-agentserver-invocations==1.0.0b3",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -41,9 +41,10 @@ def _make_agent(
|
||||
*,
|
||||
response: AgentResponse | None = None,
|
||||
stream_updates: list[AgentResponseUpdate] | None = None,
|
||||
raw_agent: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Create a mock agent implementing SupportsAgentRun."""
|
||||
agent = MagicMock(spec=RawAgent)
|
||||
agent = MagicMock(spec=RawAgent) if raw_agent else MagicMock()
|
||||
agent.id = "test-agent"
|
||||
agent.name = "Test Agent"
|
||||
agent.description = "A mock agent for testing"
|
||||
@@ -267,10 +268,18 @@ class TestNonStreaming:
|
||||
|
||||
async def test_chat_options_forwarded(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]),
|
||||
raw_agent=True,
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
|
||||
resp = await _post(
|
||||
server,
|
||||
stream=False,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
max_output_tokens=1024,
|
||||
parallel_tool_calls=True,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
agent.run.assert_awaited_once()
|
||||
@@ -280,6 +289,7 @@ class TestNonStreaming:
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.9
|
||||
assert options["max_tokens"] == 1024
|
||||
assert options["allow_multiple_tool_calls"] is True
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -289,6 +299,31 @@ class TestNonStreaming:
|
||||
|
||||
|
||||
class TestStreaming:
|
||||
async def test_chat_options_forwarded(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[AgentResponseUpdate(contents=[Content.from_text("ok")], role="assistant")],
|
||||
raw_agent=True,
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(
|
||||
server,
|
||||
stream=True,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
max_output_tokens=1024,
|
||||
parallel_tool_calls=True,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
agent.run.assert_called_once()
|
||||
call_kwargs = agent.run.call_args.kwargs
|
||||
assert call_kwargs["stream"] is True
|
||||
options = call_kwargs["options"]
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.9
|
||||
assert options["max_tokens"] == 1024
|
||||
assert options["allow_multiple_tool_calls"] is True
|
||||
|
||||
async def test_basic_text_streaming(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
@@ -1426,7 +1461,7 @@ class TestMultiTurnMixedContent:
|
||||
assert body["status"] == "completed"
|
||||
|
||||
# Verify agent received text + image
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "user"
|
||||
assert len(messages[0].contents) == 2
|
||||
@@ -1464,7 +1499,7 @@ class TestMultiTurnMixedContent:
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 2
|
||||
assert messages[0].contents[0].type == "text"
|
||||
@@ -1472,6 +1507,121 @@ class TestMultiTurnMixedContent:
|
||||
assert messages[0].contents[1].type == "uri"
|
||||
assert messages[0].contents[1].uri == "https://example.com/doc.pdf"
|
||||
|
||||
async def test_text_and_file_data_input_single_turn(self) -> None:
|
||||
"""Agent receives a message with text and file content via inline file_data."""
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("File received")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Summarize this document"},
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
"filename": "doc.pdf",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 2
|
||||
assert messages[0].contents[0].type == "text"
|
||||
assert messages[0].contents[0].text == "Summarize this document"
|
||||
assert messages[0].contents[1].type == "data"
|
||||
assert messages[0].contents[1].uri == "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
|
||||
async def test_text_mime_file_data_decoded(self) -> None:
|
||||
"""Agent receives a text/* file_data that is base64-decoded to plain text."""
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(b"Hello, world!").decode()
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_data": f"data:text/plain;base64,{encoded}",
|
||||
"filename": "greeting.txt",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].type == "text"
|
||||
assert messages[0].contents[0].text == "[File: greeting.txt]\nHello, world!"
|
||||
|
||||
async def test_text_mime_file_data_invalid_base64_falls_through(self) -> None:
|
||||
"""Invalid base64 in a text/* file_data falls through to URI passthrough."""
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_data": "data:text/plain;base64,!!!invalid!!!",
|
||||
"filename": "bad.txt",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].type == "data"
|
||||
assert messages[0].contents[0].uri == "data:text/plain;base64,!!!invalid!!!"
|
||||
|
||||
async def test_mixed_text_and_image_input(self) -> None:
|
||||
"""Agent receives a single message with both text and image content."""
|
||||
agent = _make_agent(
|
||||
@@ -1501,7 +1651,7 @@ class TestMultiTurnMixedContent:
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 2
|
||||
assert messages[0].contents[0].type == "text"
|
||||
@@ -1542,7 +1692,7 @@ class TestMultiTurnMixedContent:
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == "user"
|
||||
assert messages[0].contents[0].type == "text"
|
||||
@@ -1591,7 +1741,7 @@ class TestMultiTurnMixedContent:
|
||||
assert body2["status"] == "completed"
|
||||
|
||||
# Verify second call receives history from turn 1 + text+image input
|
||||
second_call_messages = agent.run.call_args_list[1].args[0]
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
# History: output message from turn 1 ("Send me an image")
|
||||
# Input: message with text + image
|
||||
assert len(second_call_messages) >= 2
|
||||
@@ -1652,7 +1802,7 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
# Verify turn 2 received history including function call/result
|
||||
second_call_messages = agent.run.call_args_list[1].args[0]
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
roles = [m.role for m in second_call_messages]
|
||||
assert "assistant" in roles
|
||||
assert "tool" in roles
|
||||
@@ -1703,7 +1853,7 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
# Verify history includes the reasoning and text from turn 1
|
||||
second_call_messages = agent.run.call_args_list[1].args[0]
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
assert len(second_call_messages) >= 2 # history + new input
|
||||
|
||||
async def test_multi_turn_with_mixed_content_and_streaming(self) -> None:
|
||||
@@ -1795,7 +1945,7 @@ class TestMultiTurnMixedContent:
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == "user"
|
||||
assert messages[0].contents[0].type == "text"
|
||||
@@ -1867,7 +2017,7 @@ class TestMultiTurnMixedContent:
|
||||
assert resp3.json()["status"] == "completed"
|
||||
|
||||
# Verify turn 3 received full history from turns 1+2 plus new image input
|
||||
third_call_messages = agent.run.call_args_list[2].args[0]
|
||||
third_call_messages = agent.run.call_args_list[2].kwargs["messages"]
|
||||
# Should have: history from turn 1 (assistant text) + history from turn 2
|
||||
# (function_call, function_call_output, text) + new input (text + image)
|
||||
assert len(third_call_messages) >= 5
|
||||
@@ -1918,7 +2068,7 @@ class TestMultiTurnMixedContent:
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
messages = agent.run.call_args.args[0]
|
||||
messages = agent.run.call_args.kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 2
|
||||
assert messages[0].contents[0].type == "text"
|
||||
@@ -1982,7 +2132,7 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
# Verify turn 2 received history from turn 1 + new text+file input
|
||||
second_call_messages = agent.run.call_args_list[1].args[0]
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
assert len(second_call_messages) >= 2
|
||||
|
||||
# History should include the assistant response from turn 1
|
||||
@@ -2050,7 +2200,7 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
# Verify turn 2 received history with function call + new text+image
|
||||
second_call_messages = agent.run.call_args_list[1].args[0]
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
# History should contain function_call and function_result from turn 1
|
||||
fc_contents = [
|
||||
c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call"
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for ResponsesHostServer with a real Foundry endpoint.
|
||||
|
||||
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
|
||||
ASGITransport — no real server process is started. The agent talks to a real
|
||||
Foundry project endpoint so every test requires valid credentials.
|
||||
|
||||
Required environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT - The Azure AI Foundry project endpoint URL.
|
||||
FOUNDRY_MODEL - The model deployment name (e.g. gpt-4o).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip / marker helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
skip_if_foundry_hosting_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
|
||||
or os.getenv("FOUNDRY_MODEL", "") == "",
|
||||
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server() -> ResponsesHostServer:
|
||||
"""Create a ResponsesHostServer backed by a real Foundry agent."""
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a concise assistant. Keep answers very short (one or two sentences).",
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
return ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
|
||||
|
||||
@tool
|
||||
async def get_weather(location: Annotated[str, "The city name"]) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
return f"The weather in {location} is 72°F and sunny."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_with_tools() -> ResponsesHostServer:
|
||||
"""Create a ResponsesHostServer whose agent has a tool."""
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a concise assistant. Use the provided tools when appropriate. Keep answers very short.",
|
||||
tools=[get_weather],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
return ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _post_json(
|
||||
server: ResponsesHostServer,
|
||||
payload: dict[str, Any],
|
||||
) -> httpx.Response:
|
||||
"""Send a POST /responses request with a raw JSON payload."""
|
||||
transport = httpx.ASGITransport(app=server)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.post("/responses", json=payload, timeout=120)
|
||||
|
||||
|
||||
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
|
||||
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
|
||||
events: list[dict[str, Any]] = []
|
||||
current_event: str | None = None
|
||||
current_data_lines: list[str] = []
|
||||
|
||||
for line in body.split("\n"):
|
||||
if line.startswith("event: "):
|
||||
current_event = line[len("event: ") :]
|
||||
elif line.startswith("data: "):
|
||||
current_data_lines.append(line[len("data: ") :])
|
||||
elif line.strip() == "" and current_event is not None:
|
||||
data_str = "\n".join(current_data_lines)
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
data = data_str
|
||||
events.append({"event": current_event, "data": data})
|
||||
current_event = None
|
||||
current_data_lines = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
|
||||
"""Extract event type strings from parsed SSE events."""
|
||||
return [e["event"] for e in events]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — basic text input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBasicText:
|
||||
"""Simple text-in / text-out round trips."""
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_simple_text_non_streaming(self, server: ResponsesHostServer) -> None:
|
||||
"""Non-streaming: send a text prompt and get a completed response."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "Say hello in exactly three words.",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
# There should be exactly one output item with text
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
text_parts = [c for c in output_messages[0]["content"] if c["type"] == "output_text"]
|
||||
assert len(text_parts) >= 1
|
||||
assert len(text_parts[0]["text"]) > 0
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_simple_text_streaming(self, server: ResponsesHostServer) -> None:
|
||||
"""Streaming: send a text prompt and verify SSE lifecycle events."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "Say hello in exactly three words.",
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "text/event-stream" in resp.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[1] == "response.in_progress"
|
||||
assert types[-1] == "response.completed"
|
||||
assert "response.output_text.delta" in types
|
||||
assert "response.output_text.done" in types
|
||||
|
||||
# The done event should have accumulated text
|
||||
done_events = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert len(done_events) >= 1
|
||||
assert len(done_events[0]["data"]["text"]) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — structured content input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStructuredContentInput:
|
||||
"""Structured content arrays: text + images, text + files."""
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_text_array_input(self, server: ResponsesHostServer) -> None:
|
||||
"""Multiple input_text parts in one message."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "My name is Alice."},
|
||||
{"type": "input_text", "text": "What is my name?"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
# The response should mention Alice
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"]
|
||||
assert "alice" in output_text.lower()
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_input_image_url(self, server: ResponsesHostServer) -> None:
|
||||
"""Send an image via URL and ask the model about it."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What animal is in this image? Reply in one word."},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "https://cdn.pixabay.com/photo/2024/02/28/07/42/european-shorthair-8601492_640.jpg",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "cat" in output_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_input_image_file_data(self, server: ResponsesHostServer) -> None:
|
||||
"""Send a local image file as inline base64 data URI."""
|
||||
image_path = Path(__file__).resolve().parent / "test_assets" / "sample_image.jpg" # noqa: ASYNC240
|
||||
image_bytes = image_path.read_bytes()
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
data_uri = f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What animal is in this image? Reply in one word."},
|
||||
{"type": "input_image", "image_url": data_uri},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "cat" in output_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_input_file_data(self, server: ResponsesHostServer) -> None:
|
||||
"""Send a small text file as inline file_data (base64 data URI)."""
|
||||
text_content = "The capital of France is Paris."
|
||||
b64 = base64.b64encode(text_content.encode()).decode()
|
||||
data_uri = f"data:text/plain;base64,{b64}"
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What is the capital mentioned in the attached file?"},
|
||||
{"type": "input_file", "file_data": data_uri, "filename": "info.txt"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "paris" in output_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_input_pdf_file_data(self, server: ResponsesHostServer) -> None:
|
||||
"""Send a real PDF file as inline file_data (base64 data URI)."""
|
||||
pdf_path = Path(__file__).resolve().parent / "test_assets" / "sample.pdf" # noqa: ASYNC240
|
||||
pdf_bytes = pdf_path.read_bytes()
|
||||
b64 = base64.b64encode(pdf_bytes).decode()
|
||||
data_uri = f"data:application/pdf;base64,{b64}"
|
||||
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Summarize this PDF in one sentence."},
|
||||
{"type": "input_file", "file_data": data_uri, "filename": "sample.pdf"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"]
|
||||
assert "microsoft" in output_text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — multi-turn conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiTurn:
|
||||
"""Multi-round conversations using previous_response_id."""
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_two_turn_conversation(self, server: ResponsesHostServer) -> None:
|
||||
"""Turn 1: introduce context. Turn 2: ask about it using previous_response_id."""
|
||||
# Turn 1
|
||||
resp1 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "My favorite color is blue. Remember that.",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp1.status_code == 200
|
||||
body1 = resp1.json()
|
||||
assert body1["status"] == "completed"
|
||||
response_id_1 = body1["id"]
|
||||
|
||||
# Turn 2 — references turn 1
|
||||
resp2 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "What is my favorite color?",
|
||||
"stream": False,
|
||||
"previous_response_id": response_id_1,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp2.status_code == 200
|
||||
body2 = resp2.json()
|
||||
assert body2["status"] == "completed"
|
||||
output_messages = [o for o in body2["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "blue" in output_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_three_turn_conversation(self, server: ResponsesHostServer) -> None:
|
||||
"""Three sequential turns to verify history accumulates correctly."""
|
||||
# Turn 1
|
||||
resp1 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "I have a pet dog named Max.",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
id1 = resp1.json()["id"]
|
||||
|
||||
# Turn 2
|
||||
resp2 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "I also have a cat named Luna.",
|
||||
"stream": False,
|
||||
"previous_response_id": id1,
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
id2 = resp2.json()["id"]
|
||||
|
||||
# Turn 3 — should remember both pets
|
||||
resp3 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "What are my pets' names?",
|
||||
"stream": False,
|
||||
"previous_response_id": id2,
|
||||
},
|
||||
)
|
||||
assert resp3.status_code == 200
|
||||
body3 = resp3.json()
|
||||
output_messages = [o for o in body3["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "max" in output_text
|
||||
assert "luna" in output_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None:
|
||||
"""Multi-turn conversation with streaming on the second turn."""
|
||||
# Turn 1 — non-streaming
|
||||
resp1 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "My favorite number is 42.",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
id1 = resp1.json()["id"]
|
||||
|
||||
# Turn 2 — streaming
|
||||
resp2 = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "What is my favorite number?",
|
||||
"stream": True,
|
||||
"previous_response_id": id1,
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert "text/event-stream" in resp2.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(resp2.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
assert "response.output_text.done" in types
|
||||
|
||||
done_events = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert "42" in done_events[0]["data"]["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — tool calling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolCalling:
|
||||
"""Tests that verify function-tool round trips through the hosting layer."""
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_tool_call_non_streaming(self, server_with_tools: ResponsesHostServer) -> None:
|
||||
"""Agent invokes a tool and returns a final answer (non-streaming)."""
|
||||
resp = await _post_json(
|
||||
server_with_tools,
|
||||
{
|
||||
"input": "What is the weather in Seattle?",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
# The output should contain the final text referencing the weather
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
final_text = output_messages[0]["content"][0]["text"].lower()
|
||||
assert "72" in final_text or "sunny" in final_text or "seattle" in final_text
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_tool_call_streaming(self, server_with_tools: ResponsesHostServer) -> None:
|
||||
"""Agent invokes a tool and returns a final answer (streaming)."""
|
||||
resp = await _post_json(
|
||||
server_with_tools,
|
||||
{
|
||||
"input": "What is the weather in Seattle?",
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "text/event-stream" in resp.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
# Should have text output with the weather info
|
||||
done_events = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert len(done_events) >= 1
|
||||
final_text = done_events[-1]["data"]["text"].lower()
|
||||
assert "72" in final_text or "sunny" in final_text or "seattle" in final_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — options passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOptions:
|
||||
"""Verify chat options are passed through to the model."""
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_temperature_and_max_tokens(self, server: ResponsesHostServer) -> None:
|
||||
"""Set temperature and max_output_tokens and verify the response succeeds."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "Say hello briefly.",
|
||||
"stream": False,
|
||||
"max_output_tokens": 50,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"]
|
||||
assert len(output_text) > 0
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260423"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2.0",
|
||||
"agent-framework-core>=1.2.0,<2.0",
|
||||
"google-genai>=1.0.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -285,8 +285,10 @@ def test_vertex_ai_requires_project_and_location_together(monkeypatch: pytest.Mo
|
||||
GeminiChatClient(model="gemini-2.5-flash")
|
||||
|
||||
|
||||
async def test_missing_model_raises_on_get_response() -> None:
|
||||
async def test_missing_model_raises_on_get_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Raises ValueError at call time when no model is set on the client or in options."""
|
||||
monkeypatch.delenv("GEMINI_MODEL", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_MODEL", raising=False)
|
||||
client, mock = _make_gemini_client(model=None) # type: ignore[arg-type]
|
||||
mock.aio.models.generate_content = AsyncMock()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -207,9 +207,7 @@ class TestGitHubCopilotAgentInit:
|
||||
|
||||
def test_default_options_returns_independent_copy(self) -> None:
|
||||
"""Test that mutating the returned dict does not affect internal state."""
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"model": "gpt-5.1-mini"}
|
||||
)
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(default_options={"model": "gpt-5.1-mini"})
|
||||
opts = agent.default_options
|
||||
opts["model"] = "mutated"
|
||||
assert agent._settings.get("model") == "gpt-5.1-mini"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260423"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"hyperlight-sandbox>=0.3.0,<0.4",
|
||||
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -355,6 +355,7 @@ async def test_integration_web_search() -> None:
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
|
||||
async def test_integration_client_file_search() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=credential)
|
||||
@@ -380,6 +381,7 @@ async def test_integration_client_file_search() -> None:
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
|
||||
async def test_integration_client_file_search_streaming() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=credential)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"azure-core>=1.30.0,<2",
|
||||
"httpx>=0.27.0,<0.29",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260423"
|
||||
version = "1.0.0b260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.1,<2",
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"redis>=6.4.0,<7.2.1",
|
||||
"redisvl>=0.11.0,<0.16",
|
||||
"numpy>=2.2.6,<3"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.1.1",
|
||||
"agent-framework-core[all]==1.2.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Functional Workflow with Agents — Call agents inside @workflow
|
||||
|
||||
This sample shows how to call agents inside a functional workflow.
|
||||
Agent calls are just regular async function calls — no special wrappers needed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent, workflow
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# <create_agents>
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
writer = Agent(
|
||||
name="WriterAgent",
|
||||
instructions="Write a short poem (4 lines max) about the given topic.",
|
||||
client=client,
|
||||
)
|
||||
|
||||
reviewer = Agent(
|
||||
name="ReviewerAgent",
|
||||
instructions="Review the given poem in one sentence. Is it good?",
|
||||
client=client,
|
||||
)
|
||||
# </create_agents>
|
||||
|
||||
|
||||
# <create_workflow>
|
||||
@workflow
|
||||
async def poem_workflow(topic: str) -> str:
|
||||
"""Write a poem, then review it."""
|
||||
poem = (await writer.run(f"Write a poem about: {topic}")).text
|
||||
review = (await reviewer.run(f"Review this poem: {poem}")).text
|
||||
return f"Poem:\n{poem}\n\nReview: {review}"
|
||||
# </create_workflow>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
result = await poem_workflow.run("a cat learning to code")
|
||||
print(result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Functional Workflow Basics — Orchestrate async functions with @workflow
|
||||
|
||||
The functional API lets you write workflows as plain Python async functions.
|
||||
No graph concepts, no edges, no executor classes — just call functions
|
||||
and use native control flow (if/else, loops, asyncio.gather).
|
||||
|
||||
This sample builds a minimal pipeline with two steps:
|
||||
1. Convert text to uppercase
|
||||
2. Reverse the text
|
||||
|
||||
No external services are required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import workflow
|
||||
|
||||
|
||||
# Plain async functions — no decorators needed
|
||||
async def to_upper_case(text: str) -> str:
|
||||
"""Convert input to uppercase."""
|
||||
return text.upper()
|
||||
|
||||
|
||||
async def reverse_text(text: str) -> str:
|
||||
"""Reverse the string."""
|
||||
return text[::-1]
|
||||
|
||||
|
||||
# <create_workflow>
|
||||
@workflow
|
||||
async def text_workflow(text: str) -> str:
|
||||
"""Uppercase the text, then reverse it."""
|
||||
upper = await to_upper_case(text)
|
||||
return await reverse_text(upper)
|
||||
# </create_workflow>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# <run_workflow>
|
||||
result = await text_workflow.run("hello world")
|
||||
print(f"Output: {result.get_outputs()}")
|
||||
print(f"Final state: {result.get_final_state()}")
|
||||
# </run_workflow>
|
||||
|
||||
"""
|
||||
Expected output:
|
||||
Output: ['DLROW OLLEH']
|
||||
Final state: WorkflowRunState.IDLE
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+5
-2
@@ -12,9 +12,12 @@ from agent_framework import (
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
First Workflow — Chain executors with edges
|
||||
First Graph Workflow — Chain executors with edges
|
||||
|
||||
This sample builds a minimal workflow with two steps:
|
||||
The graph API gives you full control over execution topology: edges,
|
||||
fan-out/fan-in, switch/case, and superstep-based checkpointing.
|
||||
|
||||
This sample builds a minimal graph workflow with two steps:
|
||||
1. Convert text to uppercase (class-based executor)
|
||||
2. Reverse the text (function-based executor)
|
||||
|
||||
@@ -24,8 +24,10 @@ export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o
|
||||
| 2 | [02_add_tools.py](02_add_tools.py) | Define a function tool with `@tool` and attach it to an agent. |
|
||||
| 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentSession`. |
|
||||
| 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. |
|
||||
| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. |
|
||||
| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Host a single agent with Azure Functions. |
|
||||
| 5 | [05_functional_workflow_with_agents.py](05_functional_workflow_with_agents.py) | Call agents inside a functional workflow. |
|
||||
| 6 | [06_functional_workflow_basics.py](06_functional_workflow_basics.py) | Write a workflow as a plain async function. |
|
||||
| 7 | [07_first_graph_workflow.py](07_first_graph_workflow.py) | Chain executors into a graph workflow with edges. |
|
||||
| 8 | [08_host_your_agent.py](08_host_your_agent.py) | Host a single agent with Azure Functions. |
|
||||
|
||||
Run any sample with:
|
||||
|
||||
|
||||
@@ -75,11 +75,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]:
|
||||
if client_name == "azure_openai_chat_completion":
|
||||
return OpenAIChatCompletionClient(credential=AzureCliCredential())
|
||||
if client_name == "foundry_chat":
|
||||
return FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
return FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
raise ValueError(f"Unsupported client name: {client_name}")
|
||||
|
||||
@@ -93,21 +89,6 @@ async def main(client_name: ClientName = "openai_chat") -> None:
|
||||
print(f"Client: {client_name}")
|
||||
print(f"User: {message.text}")
|
||||
|
||||
if isinstance(client, FoundryChatClient):
|
||||
async with client:
|
||||
if stream:
|
||||
response_stream = client.get_response([message], stream=True, options={"tools": get_weather})
|
||||
print("Assistant: ", end="")
|
||||
async for chunk in response_stream:
|
||||
if chunk.text:
|
||||
print(chunk.text, end="")
|
||||
print("")
|
||||
else:
|
||||
print(
|
||||
f"Assistant: {await client.get_response([message], stream=False, options={'tools': get_weather})}"
|
||||
)
|
||||
return
|
||||
|
||||
if stream:
|
||||
response_stream = client.get_response([message], stream=True, options={"tools": get_weather})
|
||||
print("Assistant: ", end="")
|
||||
|
||||
@@ -30,6 +30,20 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
|
||||
## Samples Overview (by directory)
|
||||
|
||||
### functional
|
||||
|
||||
Write workflows as plain Python async functions — no graph concepts, no executor classes, no edges. Use native control flow (`if`/`else`, loops, `asyncio.gather`) for branching and parallelism.
|
||||
|
||||
| Sample | File | Concepts |
|
||||
|---|---|---|
|
||||
| Basic Pipeline | [functional/basic_pipeline.py](./functional/basic_pipeline.py) | Sequential steps as plain async functions |
|
||||
| Basic Streaming Pipeline | [functional/basic_streaming_pipeline.py](./functional/basic_streaming_pipeline.py) | Stream workflow events in real time with `run(stream=True)` |
|
||||
| Parallel Pipeline | [functional/parallel_pipeline.py](./functional/parallel_pipeline.py) | Fan-out/fan-in with `asyncio.gather` |
|
||||
| Steps and Checkpointing | [functional/steps_and_checkpointing.py](./functional/steps_and_checkpointing.py) | `@step` decorator for per-step checkpointing and observability |
|
||||
| Human-in-the-Loop Review | [functional/hitl_review.py](./functional/hitl_review.py) | HITL with `ctx.request_info()` and replay |
|
||||
| Agent Integration | [functional/agent_integration.py](./functional/agent_integration.py) | Calling agents inside workflow steps |
|
||||
| Naive Group Chat | [functional/naive_group_chat.py](./functional/naive_group_chat.py) | Simple round-robin group chat as a plain loop |
|
||||
|
||||
### agents
|
||||
|
||||
| Sample | File | Concepts |
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Calling agents inside functional workflows.
|
||||
|
||||
Agent calls work inside @workflow as plain function calls — no decorator needed.
|
||||
Just call the agent and use the result.
|
||||
|
||||
If you want per-step caching (so agent calls don't re-execute on HITL resume
|
||||
or crash recovery), add @step. Since each agent call hits an LLM API (time +
|
||||
money), @step is often worth it. But it's always opt-in.
|
||||
|
||||
This sample shows both approaches side-by-side so you can see the difference.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent, step, workflow
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
classifier_agent = Agent(
|
||||
name="ClassifierAgent",
|
||||
instructions=(
|
||||
"Classify documents into one category: Technical, Legal, Marketing, or Scientific. "
|
||||
"Reply with only the category name."
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
writer_agent = Agent(
|
||||
name="WriterAgent",
|
||||
instructions="Summarize the given content in one sentence.",
|
||||
client=client,
|
||||
)
|
||||
|
||||
reviewer_agent = Agent(
|
||||
name="ReviewerAgent",
|
||||
instructions="Review the given summary in one sentence. Is it accurate and complete?",
|
||||
client=client,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simplest approach: call agents directly inside the workflow.
|
||||
# No @step, no wrappers — just plain function calls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@workflow
|
||||
async def simple_pipeline(document: str) -> str:
|
||||
"""Process a document — agents called inline, no @step."""
|
||||
classification = (await classifier_agent.run(f"Classify this document: {document}")).text
|
||||
summary = (await writer_agent.run(f"Summarize: {document}")).text
|
||||
review = (await reviewer_agent.run(f"Review this summary: {summary}")).text
|
||||
|
||||
return f"Classification: {classification}\nSummary: {summary}\nReview: {review}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# With @step: agent results are cached. On HITL resume or checkpoint
|
||||
# recovery, completed steps return their saved result instead of calling
|
||||
# the LLM again. Worth it for expensive operations.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@step
|
||||
async def classify_document(doc: str) -> str:
|
||||
return (await classifier_agent.run(f"Classify this document: {doc}")).text
|
||||
|
||||
|
||||
@step
|
||||
async def generate_summary(doc: str) -> str:
|
||||
return (await writer_agent.run(f"Summarize: {doc}")).text
|
||||
|
||||
|
||||
@step
|
||||
async def review_summary(summary: str) -> str:
|
||||
return (await reviewer_agent.run(f"Review this summary: {summary}")).text
|
||||
|
||||
|
||||
@workflow
|
||||
async def cached_pipeline(document: str) -> str:
|
||||
"""Same pipeline, but @step caches each agent call."""
|
||||
classification = await classify_document(document)
|
||||
summary = await generate_summary(document)
|
||||
review = await review_summary(summary)
|
||||
|
||||
return f"Classification: {classification}\nSummary: {summary}\nReview: {review}"
|
||||
|
||||
|
||||
async def main():
|
||||
# Simple version — agents called inline
|
||||
result = await simple_pipeline.run("This is a technical document about machine learning...")
|
||||
print(result.get_outputs()[0])
|
||||
|
||||
# Cached version — same result, but steps won't re-execute on resume
|
||||
result = await cached_pipeline.run("This is a technical document about machine learning...")
|
||||
print(f"\nCached: {result.get_outputs()[0]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Basic sequential pipeline using the functional workflow API.
|
||||
|
||||
The simplest possible workflow: plain async functions orchestrated by @workflow.
|
||||
No @step decorator needed — just write Python.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import workflow
|
||||
|
||||
|
||||
# These are plain async functions — no decorators needed.
|
||||
# They run normally inside the workflow, just like any other Python function.
|
||||
async def fetch_data(url: str) -> dict[str, str | int]:
|
||||
"""Simulate fetching data from a URL."""
|
||||
return {"url": url, "content": f"Data from {url}", "status": 200}
|
||||
|
||||
|
||||
async def transform_data(data: dict[str, str | int]) -> str:
|
||||
"""Transform raw data into a summary string."""
|
||||
return f"[{data['status']}] {data['content']}"
|
||||
|
||||
|
||||
# @workflow turns this async function into a FunctionalWorkflow object.
|
||||
# Without it, this is just a normal async function. With it, you get:
|
||||
# - .run() that returns a WorkflowRunResult with events and outputs
|
||||
# - .run(stream=True) for streaming events in real time
|
||||
# - .as_agent() to use this workflow anywhere an agent is expected
|
||||
#
|
||||
# The function's first parameter receives the input from .run("...").
|
||||
# Add a `ctx: RunContext` parameter only if you need HITL, state, or custom events.
|
||||
@workflow
|
||||
async def data_pipeline(url: str) -> str:
|
||||
"""A simple sequential data pipeline."""
|
||||
raw = await fetch_data(url)
|
||||
summary = await transform_data(raw)
|
||||
|
||||
# This is just a function — plain Python works between calls.
|
||||
# No need to wrap every operation in a separate async function.
|
||||
is_valid = len(summary) > 0 and "[200]" in summary
|
||||
tag = "VALID" if is_valid else "INVALID"
|
||||
|
||||
# Returning a value automatically emits it as an output.
|
||||
# Callers retrieve it via result.get_outputs().
|
||||
return f"[{tag}] {summary}"
|
||||
|
||||
|
||||
async def main():
|
||||
# .run() is provided by @workflow — a plain async function wouldn't have it
|
||||
result = await data_pipeline.run("https://example.com/api/data")
|
||||
print("Output:", result.get_outputs()[0])
|
||||
print("State:", result.get_final_state())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Basic streaming pipeline using the functional workflow API.
|
||||
|
||||
Stream workflow events in real time with run(stream=True).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import workflow
|
||||
|
||||
|
||||
# Plain async functions — no decorators needed for simple helpers.
|
||||
async def fetch_data(url: str) -> dict[str, str | int]:
|
||||
"""Simulate fetching data from a URL."""
|
||||
return {"url": url, "content": f"Data from {url}", "status": 200}
|
||||
|
||||
|
||||
async def transform_data(data: dict[str, str | int]) -> str:
|
||||
"""Transform raw data into a summary string."""
|
||||
return f"[{data['status']}] {data['content']}"
|
||||
|
||||
|
||||
async def validate_result(summary: str) -> bool:
|
||||
"""Validate the transformed result."""
|
||||
return len(summary) > 0 and "[200]" in summary
|
||||
|
||||
|
||||
# @workflow enables .run(stream=True), which returns a ResponseStream
|
||||
# you can iterate over with `async for`. Without @workflow, you'd just
|
||||
# have a normal async function with no streaming capability.
|
||||
@workflow
|
||||
async def data_pipeline(url: str) -> str:
|
||||
"""A simple sequential data pipeline."""
|
||||
raw = await fetch_data(url)
|
||||
summary = await transform_data(raw)
|
||||
is_valid = await validate_result(summary)
|
||||
|
||||
return f"{summary} (valid={is_valid})"
|
||||
|
||||
|
||||
async def main():
|
||||
# run(stream=True) returns a ResponseStream that yields events as they
|
||||
# are produced. The raw stream includes lifecycle events (started, status)
|
||||
# alongside application events — filter by event.type to find what you need.
|
||||
stream = data_pipeline.run("https://example.com/api/data", stream=True)
|
||||
async for event in stream:
|
||||
if event.type == "output":
|
||||
print(f"Output: {event.data}")
|
||||
|
||||
# After iteration, get_final_response() returns the WorkflowRunResult
|
||||
result = await stream.get_final_response()
|
||||
print(f"Final state: {result.get_final_state()}")
|
||||
|
||||
"""
|
||||
Expected output:
|
||||
Output: [200] Data from https://example.com/api/data (valid=True)
|
||||
Final state: WorkflowRunState.IDLE
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Human-in-the-loop review pipeline using functional workflows.
|
||||
|
||||
Demonstrates ctx.request_info() for pausing the workflow to wait for
|
||||
external input and resuming with run(responses={...}).
|
||||
|
||||
HITL works with or without @step. The difference is what happens on resume:
|
||||
- Without @step: every function re-executes from the top (fine for cheap calls).
|
||||
- With @step: completed functions return their saved result instantly.
|
||||
|
||||
This sample uses @step on write_draft() because it simulates an expensive
|
||||
operation that shouldn't re-run just because the workflow was paused.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import RunContext, WorkflowRunState, step, workflow
|
||||
|
||||
|
||||
# @step saves the result. When the workflow resumes after the HITL pause,
|
||||
# this returns its saved result instead of running the expensive operation again.
|
||||
#
|
||||
# In a real workflow you might call an agent here instead:
|
||||
# @step
|
||||
# async def write_draft(topic: str) -> str:
|
||||
# return (await writer_agent.run(f"Write a draft about: {topic}")).text
|
||||
@step
|
||||
async def write_draft(topic: str) -> str:
|
||||
"""Simulate writing a draft — expensive, shouldn't re-run on resume."""
|
||||
print(f" write_draft executing for '{topic}'")
|
||||
return f"Draft document about '{topic}': Lorem ipsum dolor sit amet..."
|
||||
|
||||
|
||||
@step
|
||||
async def revise_draft(draft: str, feedback: str) -> str:
|
||||
"""Revise the draft based on feedback."""
|
||||
return f"Revised: {draft[:50]}... [Applied feedback: {feedback}]"
|
||||
|
||||
|
||||
@workflow
|
||||
async def review_pipeline(topic: str, ctx: RunContext) -> str:
|
||||
"""Write a draft, get human review, then revise."""
|
||||
draft = await write_draft(topic)
|
||||
|
||||
# ctx.request_info() suspends the workflow here. The caller gets back
|
||||
# a WorkflowRunResult with state IDLE_WITH_PENDING_REQUESTS and can
|
||||
# inspect the pending request via result.get_request_info_events().
|
||||
feedback = await ctx.request_info(
|
||||
{"draft": draft, "instructions": "Please review this draft"},
|
||||
response_type=str,
|
||||
request_id="review_request",
|
||||
)
|
||||
|
||||
# This only executes after the caller resumes with run(responses={...}).
|
||||
# write_draft above returns its saved result (thanks to @step),
|
||||
# request_info returns the provided response, and we continue here.
|
||||
return await revise_draft(draft, feedback)
|
||||
|
||||
|
||||
async def main():
|
||||
# Phase 1: Run until the workflow pauses for human input
|
||||
print("=== Phase 1: Initial run ===")
|
||||
result1 = await review_pipeline.run("AI Safety")
|
||||
|
||||
# If request_info() was reached, the state is IDLE_WITH_PENDING_REQUESTS.
|
||||
# If the workflow completed without hitting request_info(), it would be IDLE.
|
||||
print(f"State: {(final_state := result1.get_final_state())}")
|
||||
assert final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
requests = result1.get_request_info_events()
|
||||
print(f"Pending request: {requests[0].request_id}")
|
||||
|
||||
# Phase 2: Resume with the human's response
|
||||
print("\n=== Phase 2: Resume with feedback ===")
|
||||
print("(write_draft should NOT execute again — saved by @step)")
|
||||
result2 = await review_pipeline.run(responses={"review_request": "Add more details about alignment research"})
|
||||
|
||||
print(f"State: {result2.get_final_state()}")
|
||||
print(f"Output: {result2.get_outputs()[0]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Naive group chat using the functional workflow API.
|
||||
|
||||
A simple round-robin group chat where agents take turns responding.
|
||||
Because it's just a function, you control the loop, the turn order,
|
||||
and the termination condition with plain Python — no framework abstractions.
|
||||
|
||||
Compare this with the graph-based GroupChat orchestration to see how the
|
||||
functional API lets you start simple and add complexity only when needed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent, Message, workflow
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
expert = Agent(
|
||||
name="PythonExpert",
|
||||
instructions=(
|
||||
"You are a Python expert in a group discussion. "
|
||||
"Answer questions about Python and refine your answer based on feedback. "
|
||||
"Keep responses concise (2-3 sentences)."
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
critic = Agent(
|
||||
name="Critic",
|
||||
instructions=(
|
||||
"You are a constructive critic in a group discussion. "
|
||||
"Point out edge cases, gotchas, or missing nuances in the previous answer. "
|
||||
"If the answer is solid, say so briefly."
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
summarizer = Agent(
|
||||
name="Summarizer",
|
||||
instructions=(
|
||||
"You are a summarizer in a group discussion. "
|
||||
"After the discussion, provide a final concise summary that incorporates "
|
||||
"the expert's answer and the critic's feedback. Keep it to 2-3 sentences."
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A naive group chat is just a loop — no special framework needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@workflow
|
||||
async def group_chat(question: str) -> str:
|
||||
"""Round-robin group chat: expert answers, critic reviews, summarizer wraps up."""
|
||||
participants = [expert, critic, summarizer]
|
||||
# Passing list[Message] keeps roles/authorship intact between turns,
|
||||
# instead of stringifying everything into a single prompt.
|
||||
conversation: list[Message] = [Message("user", [question])]
|
||||
|
||||
# Simple round-robin: each agent sees the full conversation so far
|
||||
for agent in participants:
|
||||
response = await agent.run(conversation)
|
||||
conversation.extend(response.messages)
|
||||
|
||||
return "\n\n".join(f"{m.author_name or m.role}: {m.text}" for m in conversation)
|
||||
|
||||
|
||||
async def main():
|
||||
result = await group_chat.run("What's the difference between a list and a tuple in Python?")
|
||||
print(result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Parallel pipeline using asyncio.gather with functional workflows.
|
||||
|
||||
Fan-out/fan-in uses native Python concurrency via asyncio.gather.
|
||||
No @step needed — still just plain async functions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import workflow
|
||||
|
||||
|
||||
# Plain async functions — asyncio.gather handles the concurrency,
|
||||
# no framework primitives needed for parallelism.
|
||||
async def research_web(topic: str) -> str:
|
||||
"""Simulate web research."""
|
||||
await asyncio.sleep(0.05)
|
||||
return f"Web results for '{topic}': 10 articles found"
|
||||
|
||||
|
||||
async def research_papers(topic: str) -> str:
|
||||
"""Simulate academic paper search."""
|
||||
await asyncio.sleep(0.05)
|
||||
return f"Papers on '{topic}': 3 relevant papers"
|
||||
|
||||
|
||||
async def research_news(topic: str) -> str:
|
||||
"""Simulate news search."""
|
||||
await asyncio.sleep(0.05)
|
||||
return f"News about '{topic}': 5 recent articles"
|
||||
|
||||
|
||||
async def synthesize(sources: list[str]) -> str:
|
||||
"""Combine research results into a summary."""
|
||||
return "Research Summary:\n" + "\n".join(f" - {s}" for s in sources)
|
||||
|
||||
|
||||
# @workflow wraps the orchestration logic so you get .run(), streaming,
|
||||
# and events. The functions it calls are plain Python — no decorators
|
||||
# needed just because they're inside a workflow.
|
||||
@workflow
|
||||
async def research_pipeline(topic: str) -> str:
|
||||
"""Fan-out to three research tasks, then synthesize results."""
|
||||
# asyncio.gather runs all three concurrently — this is standard Python,
|
||||
# not a framework concept. Use it the same way you would anywhere else.
|
||||
#
|
||||
# Tip: if any of these were wrapped with @step (e.g. an expensive agent call),
|
||||
# the pattern is identical — @step composes with asyncio.gather, so each
|
||||
# branch is independently cached on HITL resume or checkpoint restore.
|
||||
web, papers, news = await asyncio.gather(
|
||||
research_web(topic),
|
||||
research_papers(topic),
|
||||
research_news(topic),
|
||||
)
|
||||
|
||||
return await synthesize([web, papers, news])
|
||||
|
||||
|
||||
async def main():
|
||||
result = await research_pipeline.run("AI agents")
|
||||
print(result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Introducing @step: per-step checkpointing and observability.
|
||||
|
||||
The previous samples used plain functions — and that works. Workflows support
|
||||
HITL (ctx.request_info) and checkpointing regardless of whether you use @step.
|
||||
|
||||
The difference: without @step, a resumed workflow re-executes every function
|
||||
call from the top. That's fine for cheap functions. But for expensive operations
|
||||
(API calls, agent runs, etc.) you don't want to pay that cost again.
|
||||
|
||||
@step saves each function's result so it skips re-execution on resume:
|
||||
- On HITL resume, completed steps return their saved result instantly.
|
||||
- On crash recovery from a checkpoint, earlier step results are restored.
|
||||
- Each step emits executor_invoked/executor_completed events for observability.
|
||||
|
||||
@step is opt-in. Plain functions still work alongside @step in the same workflow.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import InMemoryCheckpointStorage, step, workflow
|
||||
|
||||
# Track call counts to show which functions actually execute on resume
|
||||
fetch_calls = 0
|
||||
transform_calls = 0
|
||||
|
||||
|
||||
# @step saves this function's result. On resume, it returns the saved
|
||||
# result instead of re-executing — useful because this is expensive.
|
||||
@step
|
||||
async def fetch_data(url: str) -> dict[str, str | int]:
|
||||
"""Expensive operation — @step prevents re-execution on resume."""
|
||||
global fetch_calls
|
||||
fetch_calls += 1
|
||||
print(f" fetch_data called (call #{fetch_calls})")
|
||||
return {"url": url, "content": f"Data from {url}", "status": 200}
|
||||
|
||||
|
||||
@step
|
||||
async def transform_data(data: dict[str, str | int]) -> str:
|
||||
"""Another expensive operation — @step saves the result."""
|
||||
global transform_calls
|
||||
transform_calls += 1
|
||||
print(f" transform_data called (call #{transform_calls})")
|
||||
return f"[{data['status']}] {data['content']}"
|
||||
|
||||
|
||||
# No @step — this is cheap, so it just re-runs on resume. That's fine.
|
||||
async def validate_result(summary: str) -> bool:
|
||||
"""Cheap validation — no @step needed."""
|
||||
return len(summary) > 0 and "[200]" in summary
|
||||
|
||||
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
|
||||
# checkpoint_storage tells @workflow where to persist step results.
|
||||
# Each @step saves a checkpoint after it completes.
|
||||
@workflow(checkpoint_storage=storage)
|
||||
async def data_pipeline(url: str) -> str:
|
||||
"""Mix of @step functions and plain functions."""
|
||||
raw = await fetch_data(url)
|
||||
summary = await transform_data(raw)
|
||||
is_valid = await validate_result(summary)
|
||||
|
||||
return f"{summary} (valid={is_valid})"
|
||||
|
||||
|
||||
async def main():
|
||||
# --- Run 1: Everything executes normally ---
|
||||
print("=== Run 1: Fresh execution ===")
|
||||
result = await data_pipeline.run("https://example.com/api/data")
|
||||
print(f"Output: {result.get_outputs()[0]}")
|
||||
print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}")
|
||||
|
||||
# @step functions emit executor events; plain functions don't.
|
||||
print("\nEvents:")
|
||||
for event in result:
|
||||
if event.type in ("executor_invoked", "executor_completed"):
|
||||
print(f" {event.type}: {event.executor_id}")
|
||||
|
||||
# --- Run 2: Restore from checkpoint ---
|
||||
# The workflow re-executes, but @step functions return saved results.
|
||||
# Only validate_result() (no @step) actually runs again.
|
||||
print("\n=== Run 2: Restored from checkpoint ===")
|
||||
latest = await storage.get_latest(workflow_name="data_pipeline")
|
||||
assert latest is not None
|
||||
|
||||
result2 = await data_pipeline.run(checkpoint_id=latest.checkpoint_id)
|
||||
print(f"Output: {result2.get_outputs()[0]}")
|
||||
print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}")
|
||||
print("(call counts unchanged — @step results were restored from checkpoint)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,12 +1,139 @@
|
||||
# Foundry Hosted Agents Samples
|
||||
# Foundry Hosted Agent Samples
|
||||
|
||||
This directory contains samples that demonstrate how to use the Agent Framework to host agents on Foundry with different capabilities and configurations. Each sample includes a README with instructions on how to set up, run, and interact with the agent.
|
||||
This directory contains samples that demonstrate how to use hosted [Agent Framework](https://github.com/microsoft/agent-framework) agents with different capabilities and configurations on Foundry using the Foundry Hosting Agent service. Each sample includes a README with instructions on how to set up, run, and interact with the agent.
|
||||
|
||||
Read more about Foundry Hosted Agents [here](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents).
|
||||
## Samples
|
||||
|
||||
## Environment setup
|
||||
### Responses API
|
||||
|
||||
1. Navigate to the sample directory you want to run. For example:
|
||||
| # | Sample | Description |
|
||||
|---|--------|-------------|
|
||||
| 1 | [Basic](responses/01_basic/) | A minimal agent demonstrating basic request/response interaction and multi-turn conversations using `previous_response_id`. |
|
||||
| 2 | [Tools](responses/02_tools/) | An agent with local tools (e.g., weather lookup), demonstrating how to register and invoke custom tool functions alongside the LLM. |
|
||||
| 3 | [MCP](responses/03_mcp/) | An agent connected to a remote MCP server (GitHub), demonstrating external MCP tool provider integration. |
|
||||
| 4 | [Foundry Toolbox](responses/04_foundry_toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. |
|
||||
| 5 | [Workflows](responses/05_workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. |
|
||||
| 6 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
|
||||
|
||||
### Invocations API
|
||||
|
||||
| # | Sample | Description |
|
||||
|---|--------|-------------|
|
||||
| 1 | [Basic](invocations/01_basic/) | A minimal agent demonstrating session state management via `agent_session_id` in URL params/response headers. |
|
||||
| 2 | [Break Glass](invocations/02_break_glass/) | An agent demonstrating a "break glass" scenario where customizations of the API behaviors are needed, allowing for more direct control over how requests and responses are handled by the hosting layer. |
|
||||
|
||||
## Running the Agent Host Locally
|
||||
|
||||
### Using `azd`
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. **Azure Developer CLI (`azd`)**
|
||||
|
||||
- [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents`
|
||||
- Authenticated: `azd auth login`
|
||||
|
||||
2. **Azure Subscription**
|
||||
|
||||
#### Create a new project
|
||||
|
||||
**No cloning required**. Create a new folder, point azd at the manifest on GitHub.
|
||||
|
||||
```bash
|
||||
mkdir hosted-agent-framework-agent && cd hosted-agent-framework-agent
|
||||
|
||||
# Initialize from the manifest
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Follow the instructions from `azd ai agent init` to complete the agent initialization. If you don't have an existing Foundry project and a model deployment, `azd ai agent init` will guide you through creating them.
|
||||
|
||||
#### Provision Azure Resources
|
||||
|
||||
> This step is only needed if you don't have an existing Foundry project and model deployment.
|
||||
|
||||
Run the following command to provision the necessary Azure resources:
|
||||
|
||||
```bash
|
||||
azd provision
|
||||
```
|
||||
|
||||
This will create the following Azure resources:
|
||||
|
||||
- A new resource group named `rg-[project_name]-dev`. In this guide, `[project_name]` will be `hosted-agent-framework-agent`.
|
||||
- Within the resource group, among other resources, the most important ones are:
|
||||
- A new Foundry instance
|
||||
- A new Foundry project, within which a new model deployment will be created
|
||||
- An Application Insights instance
|
||||
- A container registry, which will be used to store the container images for the hosted agent
|
||||
|
||||
#### Set Environment Variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="<your-model-deployment-name>"
|
||||
# And any other environment variables required by the sample
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="<your-model-deployment-name>"
|
||||
# And any other environment variables required by the sample
|
||||
```
|
||||
|
||||
> Note: The environment variables set above are only for the current session. You will need to set them again if you open a new terminal session. if you want to set the environment variables permanently in the azd environment, you can use `azd env set <name> <value>`.
|
||||
|
||||
#### Running the Agent Host
|
||||
|
||||
```bash
|
||||
azd ai agent run
|
||||
```
|
||||
|
||||
Right now, the agent host should be running on `http://localhost:8088`
|
||||
|
||||
#### Invoking the Agent
|
||||
|
||||
Open another terminal, **navigate to the project directory**, and run the following command to invoke the agent:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or you can in another terminal, without navigating to the project directory, run the following command to invoke the agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hello!"}'
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
(Invoke-WebRequest -Uri http://localhost:8088/responses -Method POST -ContentType "application/json" -Body '{"input": "Hello!"}').Content
|
||||
```
|
||||
|
||||
### Using `python`
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. An existing Foundry project
|
||||
2. A deployed model in your Foundry project
|
||||
3. Azure CLI installed and authenticated
|
||||
4. Python 3.10 or later
|
||||
|
||||
#### Running the Agent Host with Python
|
||||
|
||||
Clone the repository containing the sample code:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/microsoft/agent-framework.git
|
||||
cd agent-framework/python/samples/04-hosting/foundry-hosted-agents/responses
|
||||
```
|
||||
|
||||
#### Environment setup
|
||||
|
||||
1. Navigate to the sample directory you want to explore. Create a virtual environment:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
@@ -32,25 +159,58 @@ Read more about Foundry Hosted Agents [here](https://learn.microsoft.com/en-us/a
|
||||
az login
|
||||
```
|
||||
|
||||
## Deploying to a Docker container
|
||||
|
||||
Navigate to the sample directory and build the Docker image:
|
||||
#### Running the Agent Host
|
||||
|
||||
```bash
|
||||
docker build -t hosted-agent-sample .
|
||||
python main.py
|
||||
```
|
||||
|
||||
Run the container, passing in the required environment variables:
|
||||
Right now, the agent host should be running on `http://localhost:8088`
|
||||
|
||||
#### Invoking the Agent
|
||||
|
||||
On another terminal, run the following command to invoke the agent:
|
||||
|
||||
```bash
|
||||
docker run -p 8088:8088 \
|
||||
-e FOUNDRY_PROJECT_ENDPOINT=<your-endpoint> \
|
||||
-e MODEL_DEPLOYMENT_NAME=<your-model> \
|
||||
hosted-agent-sample
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hello!"}'
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:8088`. You can send requests using the same `curl` command shown above.
|
||||
Or in PowerShell:
|
||||
|
||||
## Deploying to Foundry
|
||||
```powershell
|
||||
(Invoke-WebRequest -Uri http://localhost:8088/responses -Method POST -ContentType "application/json" -Body '{"input": "Hello!"}').Content
|
||||
```
|
||||
|
||||
Follow this [guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent?tabs=bash#configure-your-agent) to deploy your agent to Foundry.
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
Once you've tested locally, deploy to Microsoft Foundry.
|
||||
|
||||
### With an Existing Foundry Project
|
||||
|
||||
If you already have a Foundry project and the necessary Azure resources provisioned, you can skip the setup steps and proceed directly to deploying the agent.
|
||||
|
||||
After running `azd ai agent init -m <agent.manifest.yaml>` and following the prompts to configure your agent, you will have a project ready for deployment.
|
||||
|
||||
### Setting Up a New Foundry Project
|
||||
|
||||
Follow the steps in [Using `azd`](#using-azd) to set up the project and provision the necessary Azure resources for your Foundry deployment.
|
||||
|
||||
### Deploying the Agent
|
||||
|
||||
Once the project is setup and resources are provisioned, you can deploy the agent to Foundry by running:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
> The Foundry hosting infrastructure will inject the following environment variables into your agent at runtime:
|
||||
>
|
||||
> - `FOUNDRY_PROJECT_ENDPOINT`: The endpoint URL for the Foundry project where the agent is deployed.
|
||||
> - `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of the model deployment in your Foundry project. This is configured during the agent initialization process with `azd ai agent init`.
|
||||
> - `APPLICATIONINSIGHTS_CONNECTION_STRING`: The connection string for Application Insights to enable telemetry for your agent.
|
||||
|
||||
This will package your agent and deploy it to the Foundry environment, making it accessible through the Foundry project endpoint. Once it's deployed, you can also access the agent through the Foundry UI.
|
||||
|
||||
For the full deployment guide, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
Once deployed, learn more about how to manage deployed agents in the [official management guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-agent).
|
||||
@@ -1,2 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
MODEL_DEPLOYMENT_NAME="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
@@ -1,18 +1,26 @@
|
||||
# Basic example of hosting an agent with the `invocations` API
|
||||
# What this sample demonstrates
|
||||
|
||||
## Running the server locally
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Invocations protocol** with session management. Unlike the Responses protocol, the Invocations protocol does **not** provide built-in server-side conversation history — this agent maintains an in-memory session store keyed by `agent_session_id`. In production, replace it with durable storage (Redis, Cosmos DB, etc.) so history survives restarts.
|
||||
|
||||
### Environment setup
|
||||
## How It Works
|
||||
|
||||
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
|
||||
### Model Integration
|
||||
|
||||
Run the following command to start the server:
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. When a request arrives, the handler looks up (or creates) a session by `session_id`, runs the agent with the user message and session context, and returns the reply. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
### Interacting with the agent
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `InvocationsHostServer`, which provisions a REST API endpoint compatible with the Azure AI Invocations protocol.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
|
||||
|
||||
@@ -22,7 +30,7 @@ curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/
|
||||
|
||||
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
|
||||
|
||||
```bash
|
||||
```
|
||||
HTTP/1.1 200
|
||||
content-length: 34
|
||||
content-type: application/json
|
||||
@@ -42,3 +50,7 @@ To have a multi-turn conversation with the agent, take the session ID from the r
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
|
||||
```
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ template:
|
||||
- protocol: invocations
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: MODEL_DEPLOYMENT_NAME
|
||||
value: "{{MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: MODEL_DEPLOYMENT_NAME
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -6,4 +6,4 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
memory: '0.5Gi'
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry_hosting import InvocationsHostServer
|
||||
from azure.identity import AzureCliCredential
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
@@ -15,8 +15,8 @@ load_dotenv()
|
||||
def main():
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["MODEL_DEPLOYMENT_NAME"],
|
||||
credential=AzureCliCredential(),
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
MODEL_DEPLOYMENT_NAME="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
+21
-11
@@ -1,20 +1,26 @@
|
||||
# Basic example of hosting an agent with the `invocations` API
|
||||
# What this sample demonstrates
|
||||
|
||||
This is the same as the [01_basic](../01_basic/README.md) example, but demonstrates the "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. This is useful when you want to override the default behavior for certain requests or add custom processing logic.
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Invocations protocol** with session management. Unlike the Responses protocol, the Invocations protocol does **not** provide built-in server-side conversation history — this agent maintains an in-memory session store keyed by `agent_session_id`. In production, replace it with durable storage (Redis, Cosmos DB, etc.) so history survives restarts.
|
||||
|
||||
## Running the server locally
|
||||
## How It Works
|
||||
|
||||
### Environment setup
|
||||
### Model Integration
|
||||
|
||||
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. When a request arrives, the handler looks up (or creates) a session by `session_id`, runs the agent with the user message and session context, and returns the reply. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
|
||||
|
||||
Run the following command to start the server:
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
### Agent Hosting
|
||||
|
||||
### Interacting with the agent
|
||||
The agent is hosted using the [Azure AI AgentServer Invocations SDK](https://pypi.org/project/azure-ai-agentserver-invocations/) (`InvocationAgentServerHost`), which provisions a REST API endpoint compatible with the Azure AI Invocations protocol.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
|
||||
|
||||
@@ -24,7 +30,7 @@ curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/
|
||||
|
||||
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
|
||||
|
||||
```bash
|
||||
```
|
||||
HTTP/1.1 200
|
||||
content-length: 34
|
||||
content-type: application/json
|
||||
@@ -44,3 +50,7 @@ To have a multi-turn conversation with the agent, take the session ID from the r
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
|
||||
```
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
|
||||
+3
-3
@@ -15,9 +15,9 @@ template:
|
||||
- protocol: invocations
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: MODEL_DEPLOYMENT_NAME
|
||||
value: "{{MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: MODEL_DEPLOYMENT_NAME
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
+1
-1
@@ -6,4 +6,4 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
memory: '0.5Gi'
|
||||
@@ -22,7 +22,7 @@ _sessions: dict[str, AgentSession] = {}
|
||||
# Create the agent
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["MODEL_DEPLOYMENT_NAME"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Hosting agents with Foundry Hosting and the `invocations` API
|
||||
|
||||
This folder contains a list of samples that show how to host agents using the `invocations` API and deploy them to Foundry Hosting.
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [01_basic](./01_basic) | A basic example of hosting an agent with the `invocations` API and carrying on a multi-turn conversation. |
|
||||
| [02_break_glass](./02_break_glass) | An example of hosting an agent with the `invocations` API and a "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. |
|
||||
@@ -1,2 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
MODEL_DEPLOYMENT_NAME="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
@@ -1,31 +1,39 @@
|
||||
# Basic example of hosting an agent with the `responses` API
|
||||
# What this sample demonstrates
|
||||
|
||||
This agent only contains an instruction (personal). It's the most basic agent with an LLM and no tools.
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Responses protocol**.
|
||||
|
||||
## Running the server locally
|
||||
## How It Works
|
||||
|
||||
### Environment setup
|
||||
### Model Integration
|
||||
|
||||
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
|
||||
|
||||
Run the following command to start the server:
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi"}'
|
||||
```
|
||||
|
||||
## Multi-turn conversation
|
||||
The server will respond with a JSON object containing the response text and a response ID. You can use this response ID to continue the conversation in subsequent requests.
|
||||
|
||||
### Multi-turn conversation
|
||||
|
||||
To have a multi-turn conversation with the agent, include the previous response id in the request body. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How are you?", "previous_response_id": "REPLACE_WITH_PREVIOUS_RESPONSE_ID"}'
|
||||
```
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
name: agent-framework-agent-basic
|
||||
name: agent-framework-agent-basic-responses
|
||||
description: >
|
||||
A basic Agent Framework agent hosted by Foundry.
|
||||
metadata:
|
||||
@@ -9,15 +9,15 @@ metadata:
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
template:
|
||||
name: agent-framework-agent-basic
|
||||
name: agent-framework-agent-basic-responses
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: MODEL_DEPLOYMENT_NAME
|
||||
value: "{{MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: MODEL_DEPLOYMENT_NAME
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -1,8 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: agent-framework-agent-basic
|
||||
name: agent-framework-agent-basic-responses
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.identity import AzureCliCredential
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
@@ -15,8 +15,8 @@ load_dotenv()
|
||||
def main():
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["MODEL_DEPLOYMENT_NAME"],
|
||||
credential=AzureCliCredential(),
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
MODEL_DEPLOYMENT_NAME="..."
|
||||
@@ -1,27 +0,0 @@
|
||||
# Basic example of hosting an agent with the `responses` API and local tools
|
||||
|
||||
This agent is equipped with a function tool and a local shell tool.
|
||||
|
||||
> We recommend deploying this sample on a local container or to Foundry Hosting because the agent has access to a local shell tool, which can run arbitrary commands on the machine.
|
||||
|
||||
## Running the server locally
|
||||
|
||||
### Environment setup
|
||||
|
||||
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
|
||||
|
||||
Run the following command to start the server:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}'
|
||||
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List the files in the current directory."}'
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
@@ -0,0 +1,33 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent with **locally-defined Python tools** hosted using the **Responses protocol**. It shows how to define custom tools with the `@tool` decorator and register them with the agent so the model can call them during a conversation.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Model Integration
|
||||
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
|
||||
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}'
|
||||
```
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
name: agent-framework-agent-with-local-tools
|
||||
name: agent-framework-agent-with-local-tools-responses
|
||||
description: >
|
||||
An Agent Framework agent with local tools hosted by Foundry.
|
||||
metadata:
|
||||
@@ -9,15 +9,15 @@ metadata:
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
template:
|
||||
name: agent-framework-agent-with-local-tools
|
||||
name: agent-framework-agent-with-local-tools-responses
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: MODEL_DEPLOYMENT_NAME
|
||||
value: "{{MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: MODEL_DEPLOYMENT_NAME
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
kind: hosted
|
||||
name: agent-framework-agent-with-local-tools
|
||||
name: agent-framework-agent-with-local-tools-responses
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
+3
-3
@@ -8,7 +8,7 @@ from typing import Annotated
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.identity import AzureCliCredential
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
@@ -52,8 +52,8 @@ def run_bash(command: str) -> str:
|
||||
def main():
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["MODEL_DEPLOYMENT_NAME"],
|
||||
credential=AzureCliCredential(),
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
MODEL_DEPLOYMENT_NAME="..."
|
||||
TOOLBOX_NAME="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
GITHUB_PAT="..."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user