Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions python/packages/azurefunctions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@

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",
]
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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
Expand All @@ -19,6 +19,7 @@
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")
Expand All @@ -30,18 +31,46 @@
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.
Comment thread
larohra marked this conversation as resolved.

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
Comment thread
larohra marked this conversation as resolved.
from agent_framework.azure import AzureOpenAIAssistantsClient

# Create agents with unique names
Expand All @@ -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
Expand Down Expand Up @@ -197,6 +235,30 @@ def add_agent(

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)
Comment thread
larohra marked this conversation as resolved.

def _setup_agent_functions(
self,
agent: AgentProtocol,
Expand Down Expand Up @@ -232,9 +294,13 @@ def _setup_http_run_route(self, agent_name: str) -> None:
"""
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.

Expand Down Expand Up @@ -379,8 +445,9 @@ def entity_function(context: df.DurableEntityContext) -> None:

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 = [
Expand Down Expand Up @@ -643,8 +710,7 @@ def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
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
Expand Down Expand Up @@ -708,8 +774,7 @@ def _should_wait_for_response(self, req: func.HttpRequest, req_body: dict[str, A
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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
Expand Down Expand Up @@ -340,7 +340,7 @@ def reset(self, context: df.DurableEntityContext) -> None:
def create_agent_entity(
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
):
) -> Callable[[df.DurableEntityContext], None]:
"""Factory function to create an agent entity class.

Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,7 +104,7 @@ def run(
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)
"""
Expand Down Expand Up @@ -209,27 +209,3 @@ def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[Cha
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions python/packages/azurefunctions/tests/test_orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@
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."""

Expand Down Expand Up @@ -266,20 +275,28 @@ def test_entity_id_format(self) -> None:
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."""
Expand Down Expand Up @@ -307,8 +324,8 @@ def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dic

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()
Expand Down Expand Up @@ -347,9 +364,9 @@ def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dic

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()
Expand Down
4 changes: 4 additions & 0 deletions python/packages/core/agent_framework/azure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
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"),
}

Expand Down
Loading