Python: Move azurefunctions to azure for import (#2141)

* Move import to Azure

* fix mypy

* Update python/packages/azurefunctions/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add missing types

* Address comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Laveesh Rohra
2025-11-12 12:13:25 -08:00
committed by GitHub
Unverified
parent 601b75a418
commit f3bf488735
20 changed files with 156 additions and 126 deletions
+6
View File
@@ -16,6 +16,12 @@ The durable agent extension lets you host Microsoft Agent Framework agents on Az
See the durable functions integration sample in the repository to learn how to:
```python
from agent_framework.azure import AgentFunctionApp
_app = AgentFunctionApp()
```
- Register agents with `AgentFunctionApp`
- Post messages using the generated `/api/agents/{agent_name}/run` endpoint
@@ -7,12 +7,11 @@ enabling durable, stateful AI agents deployed as Azure Function Apps.
from ._app import AgentFunctionApp
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._orchestration import DurableAIAgent, get_agent
from ._orchestration import DurableAIAgent
__all__ = [
"AgentCallbackContext",
"AgentFunctionApp",
"AgentResponseCallbackProtocol",
"DurableAIAgent",
"get_agent",
]
@@ -9,7 +9,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
import json
import re
from collections.abc import Callable, Mapping
from typing import Any, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
@@ -19,6 +19,7 @@ from ._callbacks import AgentResponseCallbackProtocol
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._models import AgentSessionId, RunRequest
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions")
@@ -30,18 +31,46 @@ WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
class AgentFunctionApp(df.DFApp):
EntityHandler = Callable[[df.DurableEntityContext], None]
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
if TYPE_CHECKING:
class DFAppBase:
def __init__(self, http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION) -> None: ...
def function_name(self, name: str) -> Callable[[HandlerT], HandlerT]: ...
def route(self, route: str, methods: list[str]) -> Callable[[HandlerT], HandlerT]: ...
def durable_client_input(self, client_name: str) -> Callable[[HandlerT], HandlerT]: ...
def entity_trigger(self, context_name: str, entity_name: str) -> Callable[[EntityHandler], EntityHandler]: ...
def orchestration_trigger(self, context_name: str) -> Callable[[HandlerT], HandlerT]: ...
def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ...
else:
DFAppBase = df.DFApp # type: ignore[assignment]
class AgentFunctionApp(DFAppBase):
"""Main application class for creating durable agent function apps using Durable Entities.
This class uses Durable Entities pattern for agent execution, providing:
- Stateful agent conversations
- Conversation history management
- Signal-based operation invocation
- Better state management than orchestrations
Usage:
```python
from agent_framework.azurefunctions import AgentFunctionApp
Example:
-------
.. code-block:: python
from agent_framework.azure import AgentFunctionApp
from agent_framework.azure import AzureOpenAIAssistantsClient
# Create agents with unique names
@@ -64,9 +93,18 @@ class AgentFunctionApp(df.DFApp):
app = AgentFunctionApp()
app.add_agent(weather_agent)
app.add_agent(math_agent)
```
@app.orchestration_trigger(context_name="context")
def my_orchestration(context):
writer = app.get_agent(context, "WeatherAgent")
thread = writer.get_new_thread()
forecast_task = writer.run("What's the forecast?", thread=thread)
forecast = yield forecast_task
return forecast
This creates:
- HTTP trigger endpoint for each agent's requests (if enabled)
- Durable entity for each agent's state management and execution
- Full access to all Azure Functions capabilities
@@ -197,6 +235,30 @@ class AgentFunctionApp(df.DFApp):
logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully")
def get_agent(
self,
context: AgentOrchestrationContextType,
agent_name: str,
) -> DurableAIAgent:
"""Return a DurableAIAgent proxy for a registered agent.
Args:
context: Durable Functions orchestration context invoking the agent.
agent_name: Name of the agent registered on this app.
Raises:
ValueError: If the requested agent has not been registered.
Returns:
DurableAIAgent wrapper bound to the orchestration context.
"""
normalized_name = str(agent_name)
if normalized_name not in self.agents:
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
return DurableAIAgent(context, normalized_name)
def _setup_agent_functions(
self,
agent: AgentProtocol,
@@ -232,9 +294,13 @@ class AgentFunctionApp(df.DFApp):
"""
run_function_name = self._build_function_name(agent_name, "http")
@self.function_name(run_function_name)
@self.route(route=f"agents/{agent_name}/run", methods=["POST"])
@self.durable_client_input(client_name="client")
function_name_decorator = self.function_name(run_function_name)
route_decorator = self.route(route=f"agents/{agent_name}/run", methods=["POST"])
durable_client_decorator = self.durable_client_input(client_name="client")
@function_name_decorator
@route_decorator
@durable_client_decorator
async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse:
"""HTTP trigger that calls a durable entity to execute the agent and returns the result.
@@ -379,8 +445,9 @@ class AgentFunctionApp(df.DFApp):
def _setup_health_route(self) -> None:
"""Register the optional health check route."""
health_route = self.route(route="health", methods=["GET"])
@self.route(route="health", methods=["GET"])
@health_route
def health_check(req: func.HttpRequest) -> func.HttpResponse:
"""Built-in health check endpoint."""
agent_info = [
@@ -643,8 +710,7 @@ class AgentFunctionApp(df.DFApp):
headers: dict[str, str] = {}
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
headers_mapping = cast(Mapping[Any, Any], raw_headers)
for key, value in headers_mapping.items():
for key, value in raw_headers.items():
if value is not None:
headers[str(key).lower()] = str(value)
return headers
@@ -708,8 +774,7 @@ class AgentFunctionApp(df.DFApp):
header_value = None
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
headers_mapping = cast(Mapping[Any, Any], raw_headers)
for key, value in headers_mapping.items():
for key, value in raw_headers.items():
if str(key).lower() == WAIT_FOR_RESPONSE_HEADER:
header_value = value
break
@@ -10,7 +10,7 @@ allows for long-running agent conversations.
import asyncio
import inspect
import json
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Callable
from typing import Any, cast
import azure.durable_functions as df
@@ -340,7 +340,7 @@ class AgentEntity:
def create_agent_entity(
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
):
) -> Callable[[df.DurableEntityContext], None]:
"""Factory function to create an agent entity class.
Args:
@@ -374,7 +374,7 @@ class AgentResponse:
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
result: dict[str, Any] = {
"message": self.message,
"thread_id": self.thread_id,
"status": self.status,
@@ -37,7 +37,7 @@ class DurableAIAgent(AgentProtocol):
yielded in orchestrations to wait for the entity call to complete.
Example usage in orchestration:
writer = get_agent(context, "WriterAgent")
writer = app.get_agent(context, "WriterAgent")
thread = writer.get_new_thread() # NOT yielded - returns immediately
response = yield writer.run( # Yielded - waits for entity call
@@ -104,7 +104,7 @@ class DurableAIAgent(AgentProtocol):
Example:
@app.orchestration_trigger(context_name="context")
def my_orchestration(context):
agent = get_agent(context, "MyAgent")
agent = app.get_agent(context, "MyAgent")
thread = agent.get_new_thread()
result = yield agent.run("Hello", thread=thread)
"""
@@ -209,27 +209,3 @@ class DurableAIAgent(AgentProtocol):
return "\n".join(cast(list[str], messages))
return self._messages_to_string(cast(list[ChatMessage], messages))
return str(messages)
def get_agent(context: AgentOrchestrationContextType, agent_name: str) -> DurableAIAgent:
"""Return a :class:`DurableAIAgent` proxy scoped to ``agent_name``.
Usage::
from agent_framework.azurefunctions import get_agent
@app.orchestration_trigger(context_name="context")
def my_orchestration(context: DurableOrchestrationContext):
writer = get_agent(context, "WriterAgent")
thread = writer.get_new_thread()
response = yield writer.run("Write a haiku", thread=thread)
Args:
context: The orchestration context provided by Durable Functions.
agent_name: Name of the durable agent entity to call.
Returns:
DurableAIAgent wrapper for the specified agent.
"""
return DurableAIAgent(context, agent_name)
@@ -25,7 +25,7 @@ class AgentState:
- Message counting
"""
def __init__(self):
def __init__(self) -> None:
"""Initialize empty agent state."""
self.conversation_history: list[ChatMessage] = []
self.last_response: str | None = None
@@ -8,10 +8,19 @@ from unittest.mock import Mock
import pytest
from agent_framework import AgentThread
from agent_framework_azurefunctions import DurableAIAgent, get_agent
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp:
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False)
for name in agent_names:
agent = Mock()
agent.name = name
app.add_agent(agent)
return app
class TestDurableAIAgent:
"""Test suite for DurableAIAgent wrapper."""
@@ -266,20 +275,28 @@ class TestDurableAIAgent:
assert str(entity_id) == "@dafx-writeragent@test-guid-789"
class TestGetAgentHelper:
"""Test suite for the get_agent helper function."""
class TestAgentFunctionAppGetAgent:
"""Test suite for AgentFunctionApp.get_agent."""
def test_get_agent_function(self) -> None:
"""Test get_agent function creates DurableAIAgent."""
def test_get_agent_method(self) -> None:
"""Test get_agent method creates DurableAIAgent for registered agent."""
app = _app_with_registered_agents("MyAgent")
mock_context = Mock()
mock_context.instance_id = "test-instance-100"
agent = get_agent(mock_context, "MyAgent")
agent = app.get_agent(mock_context, "MyAgent")
assert isinstance(agent, DurableAIAgent)
assert agent.agent_name == "MyAgent"
assert agent.context == mock_context
def test_get_agent_raises_for_unregistered_agent(self) -> None:
"""Test get_agent raises ValueError when agent is not registered."""
app = _app_with_registered_agents("KnownAgent")
with pytest.raises(ValueError, match=r"Agent 'MissingAgent' is not registered with this app\."):
app.get_agent(Mock(), "MissingAgent")
class TestOrchestrationIntegration:
"""Integration tests for orchestration scenarios."""
@@ -307,8 +324,8 @@ class TestOrchestrationIntegration:
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create agent
agent = get_agent(mock_context, "WriterAgent")
app = _app_with_registered_agents("WriterAgent")
agent = app.get_agent(mock_context, "WriterAgent")
# Create thread
thread = agent.get_new_thread()
@@ -347,9 +364,9 @@ class TestOrchestrationIntegration:
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create multiple agents
writer = get_agent(mock_context, "WriterAgent")
editor = get_agent(mock_context, "EditorAgent")
app = _app_with_registered_agents("WriterAgent", "EditorAgent")
writer = app.get_agent(mock_context, "WriterAgent")
editor = app.get_agent(mock_context, "EditorAgent")
writer_thread = writer.get_new_thread()
editor_thread = editor.get_new_thread()
@@ -5,12 +5,16 @@ import importlib
from typing import Any
_IMPORTS: dict[str, tuple[str, str]] = {
"AgentCallbackContext": ("agent_framework_azurefunctions", "azurefunctions"),
"AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"),
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"),
"AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"),
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"),
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"),
"AzureAISettings": ("agent_framework_azure_ai", "azure-ai"),
"AzureOpenAISettings": ("agent_framework.azure._shared", "core"),
"AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "core"),
"DurableAIAgent": ("agent_framework_azurefunctions", "azurefunctions"),
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "core"),
}
@@ -1,6 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings
from agent_framework_azurefunctions import (
AgentCallbackContext,
AgentFunctionApp,
AgentResponseCallbackProtocol,
DurableAIAgent,
)
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
from agent_framework.azure._chat_client import AzureOpenAIChatClient
@@ -9,11 +15,15 @@ from agent_framework.azure._responses_client import AzureOpenAIResponsesClient
from agent_framework.azure._shared import AzureOpenAISettings
__all__ = [
"AgentCallbackContext",
"AgentFunctionApp",
"AgentResponseCallbackProtocol",
"AzureAIAgentClient",
"AzureAISettings",
"AzureOpenAIAssistantsClient",
"AzureOpenAIChatClient",
"AzureOpenAIResponsesClient",
"AzureOpenAISettings",
"DurableAIAgent",
"get_entra_auth_token",
]
@@ -1,29 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_azurefunctions"
PACKAGE_EXTRA = "azurefunctions"
_IMPORTS = [
"AgentCallbackContext",
"AgentFunctionApp",
"AgentResponseCallbackProtocol",
"DurableAIAgent",
"get_agent",
]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -1,17 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_azurefunctions import (
AgentCallbackContext,
AgentFunctionApp,
AgentResponseCallbackProtocol,
DurableAIAgent,
get_agent,
)
__all__ = [
"AgentCallbackContext",
"AgentFunctionApp",
"AgentResponseCallbackProtocol",
"DurableAIAgent",
"get_agent",
]
+2
View File
@@ -257,6 +257,7 @@ pytest --import-mode=importlib
--cov-report=term-missing:skip-covered
--ignore-glob=packages/lab/**
--ignore-glob=packages/devui/**
-rs
-n logical --dist loadfile --dist worksteal
packages/**/tests
"""
@@ -266,6 +267,7 @@ cmd = """
pytest --import-mode=importlib
--ignore-glob=packages/lab/**
--ignore-glob=packages/devui/**
-rs
-n logical --dist loadfile --dist worksteal
packages/**/tests
"""
@@ -8,8 +8,7 @@ Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAM
from typing import Any
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azurefunctions import AgentFunctionApp
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.identity import AzureCliCredential
# 1. Instantiate the agent with the chosen deployment and instructions.
def _create_agent() -> Any:
@@ -11,8 +11,7 @@ Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAM
import logging
from typing import Any
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azurefunctions import AgentFunctionApp
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.identity import AzureCliCredential
logger = logging.getLogger(__name__)
@@ -16,11 +16,14 @@ from typing import Any, DefaultDict
import azure.functions as func
from agent_framework import AgentRunResponseUpdate
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azure import (
AgentCallbackContext,
AgentFunctionApp,
AgentResponseCallbackProtocol,
AzureOpenAIChatClient,
)
from azure.identity import AzureCliCredential
from agent_framework.azurefunctions import AgentFunctionApp, AgentCallbackContext, AgentResponseCallbackProtocol
logger = logging.getLogger(__name__)
@@ -14,10 +14,9 @@ from typing import Any
import azure.durable_functions as df
import azure.functions as func
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationContext
from azure.identity import AzureCliCredential
from agent_framework.azurefunctions import AgentFunctionApp, get_agent
logger = logging.getLogger(__name__)
@@ -49,7 +48,7 @@ app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True
def single_agent_orchestration(context: DurableOrchestrationContext):
"""Run the writer agent twice on the same thread to mirror chaining behaviour."""
writer = get_agent(context, WRITER_AGENT_NAME)
writer = app.get_agent(context, WRITER_AGENT_NAME)
writer_thread = writer.get_new_thread()
initial = yield writer.run(
@@ -14,10 +14,9 @@ from typing import Any
import azure.durable_functions as df
import azure.functions as func
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationContext
from azure.identity import AzureCliCredential
from agent_framework.azurefunctions import AgentFunctionApp, get_agent
logger = logging.getLogger(__name__)
@@ -59,8 +58,8 @@ def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext):
if not prompt or not str(prompt).strip():
raise ValueError("Prompt is required")
physicist = get_agent(context, PHYSICIST_AGENT_NAME)
chemist = get_agent(context, CHEMIST_AGENT_NAME)
physicist = app.get_agent(context, PHYSICIST_AGENT_NAME)
chemist = app.get_agent(context, CHEMIST_AGENT_NAME)
physicist_thread = physicist.get_new_thread()
chemist_thread = chemist.get_new_thread()
@@ -11,15 +11,14 @@ Functions host."""
import json
import logging
from typing import Any, cast
from collections.abc import Mapping
from typing import Any, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationContext
from azure.identity import AzureCliCredential
from agent_framework.azurefunctions import AgentFunctionApp, get_agent
from pydantic import BaseModel, ValidationError
logger = logging.getLogger(__name__)
@@ -85,8 +84,8 @@ def spam_detection_orchestration(context: DurableOrchestrationContext):
except ValidationError as exc:
raise ValueError(f"Invalid email payload: {exc}") from exc
spam_agent = get_agent(context, SPAM_AGENT_NAME)
email_agent = get_agent(context, EMAIL_AGENT_NAME)
spam_agent = app.get_agent(context, SPAM_AGENT_NAME)
email_agent = app.get_agent(context, EMAIL_AGENT_NAME)
spam_thread = spam_agent.get_new_thread()
@@ -10,16 +10,15 @@ either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running `func sta
import json
import logging
from collections.abc import Mapping
from datetime import timedelta
from typing import Any
from collections.abc import Mapping
import azure.durable_functions as df
import azure.functions as func
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationContext
from azure.identity import AzureCliCredential
from agent_framework.azurefunctions import AgentFunctionApp, get_agent
from pydantic import BaseModel, ValidationError
logger = logging.getLogger(__name__)
@@ -92,7 +91,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
except ValidationError as exc:
raise ValueError(f"Invalid content generation input: {exc}") from exc
writer = get_agent(context, WRITER_AGENT_NAME)
writer = app.get_agent(context, WRITER_AGENT_NAME)
writer_thread = writer.get_new_thread()
context.set_custom_status(f"Starting content generation for topic: {payload.topic}")