mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add initial scaffold for durabletask package (#2761)
* Add initial scaffold * Update design * Fix mypy and update design * add additional style considered * Address comments * Fix test * Update readmes
This commit is contained in:
committed by
GitHub
Unverified
parent
638fbb5f03
commit
a48a8dd524
@@ -2,8 +2,9 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from agent_framework_durabletask import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
|
||||
from ._app import AgentFunctionApp
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._orchestration import DurableAIAgent
|
||||
|
||||
try:
|
||||
|
||||
@@ -10,14 +10,13 @@ import json
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._constants import (
|
||||
from agent_framework_durabletask import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
@@ -28,11 +27,14 @@ from ._constants import (
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
AgentResponseCallbackProtocol,
|
||||
DurableAgentState,
|
||||
RunRequest,
|
||||
)
|
||||
from ._durable_agent_state import DurableAgentState
|
||||
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._models import AgentSessionId, RunRequest
|
||||
from ._models import AgentSessionId
|
||||
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions")
|
||||
@@ -858,6 +860,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
).to_dict()
|
||||
|
||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -22,16 +22,16 @@ from agent_framework import (
|
||||
Role,
|
||||
get_logger,
|
||||
)
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._durable_agent_state import (
|
||||
from agent_framework_durabletask import (
|
||||
AgentCallbackContext,
|
||||
AgentResponseCallbackProtocol,
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
RunRequest,
|
||||
)
|
||||
from ._models import RunRequest
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||
|
||||
@@ -121,11 +121,11 @@ class AgentEntity:
|
||||
response_format = run_request.response_format
|
||||
enable_tool_calls = run_request.enable_tool_calls
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received Message: {run_request}")
|
||||
|
||||
state_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
self.state.data.conversation_history.append(state_request)
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
|
||||
|
||||
try:
|
||||
# Build messages from conversation history, excluding error responses
|
||||
# Error responses are kept in history for tracking but not sent to the agent
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Data models for Durable Agent Framework.
|
||||
"""Azure Functions-specific data models for Durable Agent Framework.
|
||||
|
||||
This module defines the request and response models used by the framework.
|
||||
This module contains Azure Functions-specific models:
|
||||
- AgentSessionId: Entity ID management for Azure Durable Entities
|
||||
- DurableAgentThread: Thread implementation that tracks AgentSessionId
|
||||
|
||||
Common models like RunRequest have been moved to agent-framework-durabletask.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread, Role
|
||||
|
||||
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking imports only
|
||||
from pydantic import BaseModel
|
||||
|
||||
_PydanticBaseModel: type[BaseModel] | None
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel as _RuntimeBaseModel
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
_PydanticBaseModel = None
|
||||
else:
|
||||
_PydanticBaseModel = _RuntimeBaseModel
|
||||
from agent_framework import AgentThread
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -211,161 +199,3 @@ class DurableAgentThread(AgentThread):
|
||||
|
||||
thread.attach_session(AgentSessionId.parse(session_id_value))
|
||||
return thread
|
||||
|
||||
|
||||
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||
"""Serialize response format for transport across durable function boundaries."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is None:
|
||||
raise RuntimeError("pydantic is required to use structured response formats")
|
||||
|
||||
if not inspect.isclass(response_format) or not issubclass(response_format, _PydanticBaseModel):
|
||||
raise TypeError("response_format must be a Pydantic BaseModel type")
|
||||
|
||||
return {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": response_format.__module__,
|
||||
"qualname": response_format.__qualname__,
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None:
|
||||
"""Deserialize response format back into actionable type if possible."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
_PydanticBaseModel is not None
|
||||
and inspect.isclass(response_format)
|
||||
and issubclass(response_format, _PydanticBaseModel)
|
||||
):
|
||||
return response_format
|
||||
|
||||
if not isinstance(response_format, dict):
|
||||
return None
|
||||
|
||||
response_dict = cast(dict[str, Any], response_format)
|
||||
|
||||
if response_dict.get("__response_schema_type__") != "pydantic_model":
|
||||
return None
|
||||
|
||||
module_name = response_dict.get("module")
|
||||
qualname = response_dict.get("qualname")
|
||||
if not module_name or not qualname:
|
||||
return None
|
||||
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
except ImportError: # pragma: no cover - user provided module missing
|
||||
return None
|
||||
|
||||
attr: Any = module
|
||||
for part in qualname.split("."):
|
||||
try:
|
||||
attr = getattr(attr, part)
|
||||
except AttributeError: # pragma: no cover - invalid qualname
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is not None and inspect.isclass(attr) and issubclass(attr, _PydanticBaseModel):
|
||||
return attr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRequest:
|
||||
"""Represents a request to run an agent with a specific message and configuration.
|
||||
|
||||
Attributes:
|
||||
message: The message to send to the agent
|
||||
request_response_format: The desired response format (e.g., "text" or "json")
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
created_at: Optional timestamp when the request was created
|
||||
orchestration_id: Optional ID of the orchestration that initiated this request
|
||||
"""
|
||||
|
||||
message: str
|
||||
request_response_format: str
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: str | None = None
|
||||
orchestration_id: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
created_at: str | None = None,
|
||||
orchestration_id: str | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.request_response_format = request_response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
self.created_at = created_at
|
||||
self.orchestration_id = orchestration_id
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
"""Normalize various role representations into a Role instance."""
|
||||
if isinstance(value, Role):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return Role.USER
|
||||
return Role(value=normalized.lower())
|
||||
return Role.USER
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
"message": self.message,
|
||||
"enable_tool_calls": self.enable_tool_calls,
|
||||
"role": self.role.value,
|
||||
"request_response_format": self.request_response_format,
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = serialize_response_format(self.response_format)
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlationId"] = self.correlation_id
|
||||
if self.created_at:
|
||||
result["created_at"] = self.created_at
|
||||
if self.orchestration_id:
|
||||
result["orchestrationId"] = self.orchestration_id
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlationId"),
|
||||
created_at=data.get("created_at"),
|
||||
orchestration_id=data.get("orchestrationId"),
|
||||
)
|
||||
|
||||
@@ -17,11 +17,12 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
get_logger,
|
||||
)
|
||||
from agent_framework_durabletask import RunRequest
|
||||
from azure.durable_functions.models import TaskBase
|
||||
from azure.durable_functions.models.Task import CompoundTask, TaskState
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
||||
from ._models import AgentSessionId, DurableAgentThread
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.orchestration")
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions",
|
||||
"azure-functions-durable",
|
||||
]
|
||||
|
||||
@@ -15,8 +15,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
|
||||
from agent_framework_durabletask import THREAD_ID_HEADER
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, ChatMessage, ErrorContent
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
|
||||
from agent_framework_azurefunctions._constants import (
|
||||
from agent_framework_durabletask import (
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
MIMETYPE_TEXT_PLAIN,
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
DurableAgentState,
|
||||
)
|
||||
from agent_framework_azurefunctions._durable_agent_state import DurableAgentState
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
@@ -13,17 +13,17 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, ErrorContent, Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._durable_agent_state import (
|
||||
from agent_framework_durabletask import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
RunRequest,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
from agent_framework_azurefunctions._models import RunRequest
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import azure.durable_functions as df
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from agent_framework_durabletask import RunRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._models import AgentSessionId, RunRequest
|
||||
from agent_framework_azurefunctions._models import AgentSessionId
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# Design: Durable Task Provider for Agent Framework
|
||||
|
||||
## Overview
|
||||
|
||||
This package, `agent-framework-durabletask`, provides a durability layer for the Microsoft Agent Framework using the `durabletask` Python SDK. It enables stateful, reliable, and distributed agent execution on any platform (Bring Your Own Platform), decoupling the agent's durability from the Azure Functions platform.
|
||||
|
||||
## Design Decision
|
||||
|
||||
**Selected Approach: Object-Oriented Wrappers with Symmetric Factory Pattern**
|
||||
|
||||
We will use a symmetric Object-Oriented design where both the Client (external) and Orchestrator (internal) expose a consistent interface for retrieving and interacting with durable agents.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
* **Native `DurableEntity` Support**: We will leverage the `DurableEntity` support introduced in `durabletask` v1.0.0.
|
||||
* **Symmetric Factories**: `DurableAIAgentClient` (for external use) and `DurableAIAgentOrchestrator` (for internal use) both provide a `get_agent` method.
|
||||
* **Unified Interface**: `DurableAIAgent` serves as the common interface for executing agents, regardless of the context (Client vs Orchestration).
|
||||
* **Consistent Return Type**: `DurableAIAgent.run` always returns a `Task` (or compatible awaitable), ensuring consistent usage patterns.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Package Structure
|
||||
|
||||
```text
|
||||
packages/durabletask/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── agent_framework_durabletask/
|
||||
│ ├── __init__.py
|
||||
│ ├── _worker.py # DurableAIAgentWorker
|
||||
│ ├── _client.py # DurableAIAgentClient
|
||||
│ ├── _orchestrator.py # DurableAIAgentOrchestrator
|
||||
│ ├── _entities.py # AgentEntity implementation
|
||||
│ ├── _models.py # Data models (RunRequest, AgentResponse, etc.)
|
||||
│ ├── _durable_agent_state.py # State schema (Ported from azurefunctions)
|
||||
│ ├── _shim.py # DurableAIAgent implementation (will be ported from azurefunctions)
|
||||
│ └── _utils.py # Mixins and helpers
|
||||
└── tests/
|
||||
```
|
||||
|
||||
### 2. State Management (`_durable_agent_state.py`)
|
||||
|
||||
**Important**: This will be the state maintained in the durable entities for both `durabletask` and `azurefunctions` package.
|
||||
|
||||
### 3. The Agent Entity (`_entities.py`)
|
||||
|
||||
We will implement a class `AgentEntity` that inherits from `durabletask.entities.DurableEntity`.
|
||||
|
||||
**Important**: This will be ported from `azurefunctions` package too but with slight modifications, details TBD.
|
||||
|
||||
### 4. The Worker Wrapper (`_worker.py`)
|
||||
|
||||
The `DurableAIAgentWorker` wraps an existing `durabletask` worker instance.
|
||||
|
||||
```python
|
||||
class DurableAIAgentWorker:
|
||||
def __init__(self, worker: TaskHubGrpcWorker):
|
||||
self._worker = worker
|
||||
self._registered_agents: dict[str, AgentProtocol] = {}
|
||||
|
||||
def add_agent(self, agent: AgentProtocol) -> None:
|
||||
"""Registers an agent with the worker.
|
||||
|
||||
Uses the factory pattern to create an AgentEntity class with the agent
|
||||
instance injected, then registers it with the durabletask worker.
|
||||
"""
|
||||
# Store the agent reference
|
||||
self._registered_agents[agent.name] = agent
|
||||
|
||||
# Create a configured entity class using the factory
|
||||
entity_class = create_agent_entity(agent)
|
||||
|
||||
# Register the entity class with the worker
|
||||
# The worker.add_entity method takes a class or function
|
||||
self._worker.add_entity(entity_class)
|
||||
|
||||
def start(self):
|
||||
"""Start the worker to begin processing tasks."""
|
||||
self._worker.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the worker gracefully."""
|
||||
self._worker.stop()
|
||||
```
|
||||
|
||||
### 5. The Mixin (`_utils.py`)
|
||||
|
||||
```python
|
||||
class GetDurableAgentMixin:
|
||||
"""Mixin to provide get_agent interface."""
|
||||
|
||||
def get_agent(self, agent_name: str) -> 'DurableAIAgent':
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
### 6. The Client Wrapper (`_client.py`)
|
||||
|
||||
The `DurableAIAgentClient` is for external clients (e.g., FastAPI, CLI).
|
||||
|
||||
```python
|
||||
class DurableAIAgentClient(GetDurableAgentMixin):
|
||||
def __init__(self, client: TaskHubGrpcClient):
|
||||
self._client = client
|
||||
|
||||
async def get_agent(self, agent_name: str) -> 'DurableAIAgent':
|
||||
"""Retrieves a DurableAIAgent shim.
|
||||
|
||||
Validates existence by attempting to fetch entity state/metadata.
|
||||
"""
|
||||
# Validation logic using self._client.get_entity(...)
|
||||
# ...
|
||||
return DurableAIAgent(self, agent_name)
|
||||
|
||||
def run_agent(self, agent_name: str, message: str, **kwargs) -> 'Task':
|
||||
"""Runs agent via signal + poll and returns a Task wrapper."""
|
||||
# Returns a ClientTask (wrapper around asyncio.Task)
|
||||
pass
|
||||
```
|
||||
|
||||
### 7. The Orchestration Context Wrapper (`_orchestration_context.py`)
|
||||
|
||||
The `DurableAIAgentOrchestrationContext` is for use *inside* orchestrations to get access to agents that were registered in the workers.
|
||||
|
||||
```python
|
||||
class DurableAIAgentOrchestrationContext(GetDurableAgentMixin):
|
||||
def __init__(self, context: OrchestrationContext):
|
||||
self._context = context
|
||||
|
||||
def get_agent(self, agent_name: str) -> 'DurableAIAgent':
|
||||
"""Retrieves a DurableAIAgent shim.
|
||||
|
||||
Validation is deferred or performed via call_entity if needed.
|
||||
"""
|
||||
return DurableAIAgent(self, agent_name)
|
||||
|
||||
def run_agent(self, agent_name: str, message: str, **kwargs) -> 'Task':
|
||||
"""Runs agent via call_entity and returns the Task."""
|
||||
# Returns the native durabletask.Task
|
||||
pass
|
||||
```
|
||||
|
||||
### 8. The Durable Agent Shim (`_shim.py`)
|
||||
|
||||
The `DurableAIAgent` implements `AgentProtocol` but delegates execution to the provider. This will be ported from `azurefunctions` package and updated accordingly.
|
||||
|
||||
```python
|
||||
class DurableAIAgent(AgentProtocol):
|
||||
"""A shim that delegates execution to the provider (Client or Orchestrator)."""
|
||||
|
||||
def __init__(self, provider: GetDurableAgentMixin, name: str):
|
||||
self._provider = provider
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def run(self, message: str, **kwargs) -> 'Task':
|
||||
"""Executes the agent.
|
||||
|
||||
Returns:
|
||||
Task: A yieldable/awaitable task object.
|
||||
"""
|
||||
return self._provider.run_agent(
|
||||
agent_name=self.name,
|
||||
message=message,
|
||||
**kwargs
|
||||
)
|
||||
```
|
||||
|
||||
## Usage Experience
|
||||
|
||||
**Scenario A: Worker Side**
|
||||
```python
|
||||
# 1. Define your agent
|
||||
# The agent can be any implementation of AgentProtocol.
|
||||
# For example, a standard Agent with a model and instructions.
|
||||
my_agent = Agent(
|
||||
name="my_agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
model=openai_model
|
||||
)
|
||||
|
||||
# 2. Create the worker and the agent worker wrapper
|
||||
with DurableTaskSchedulerWorker(...) as worker:
|
||||
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
# 3. Register the agent
|
||||
agent_worker.add_agent(my_agent)
|
||||
|
||||
# 4. Start the worker
|
||||
worker.start()
|
||||
|
||||
# ... keep running ...
|
||||
```
|
||||
|
||||
**Scenario B: Client Side**
|
||||
```python
|
||||
# 1. Configure the Durable Task client
|
||||
client = DurableTaskSchedulerClient(...)
|
||||
|
||||
# 2. Create the Agent Client wrapper
|
||||
agent_client = DurableAIAgentClient(client)
|
||||
|
||||
# 3. Get a reference to the agent
|
||||
agent = await agent_client.get_agent("my_agent")
|
||||
|
||||
# 4. Run the agent
|
||||
# The returned object is designed to be compatible with both `await` (Client)
|
||||
# and `yield` (Orchestrator). Implementation details on this unified return type will follow.
|
||||
response = await agent.run("Hello")
|
||||
```
|
||||
|
||||
**Scenario C: Orchestration Side**
|
||||
```python
|
||||
def orchestrator(context: OrchestrationContext):
|
||||
# 1. Create the Agent Orchestrator wrapper
|
||||
agent_orch = DurableAIAgentOrchestrator(context)
|
||||
|
||||
# 2. Get a reference to the agent
|
||||
agent = agent_orch.get_agent("my_agent")
|
||||
|
||||
# 3. Run the agent (returns a Task, so we yield it)
|
||||
result = yield agent.run("Hello")
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
## Additional Styles Considered
|
||||
|
||||
### Inheritance Pattern for worker and client (like `DurableAIAgentWorker`, `DurableAIAgentClient`, etc)
|
||||
|
||||
We investigated inheriting `DurableAIAgentWorker` directly from `TaskHubGrpcWorker` (or `DurableTaskSchedulerWorker`) to provide a unified API where the agent worker *is* a durable task worker (and similarly the client).
|
||||
|
||||
**Why we chose Composition over Inheritance:**
|
||||
|
||||
1. **Initialization Divergence:** The `durabletask` package has two distinct worker classes with incompatible `__init__` signatures:
|
||||
* `TaskHubGrpcWorker`: Requires `host_address`, `metadata`, etc.
|
||||
* `DurableTaskSchedulerWorker`: Requires `host_address`, `taskhub`, `token_credential`, etc.
|
||||
|
||||
To support both via inheritance, we would need to maintain two separate classes (e.g., `DurableAIAgentGrpcWorker` and `DurableAIAgentSchedulerWorker`) or use a complex Mixin approach. This increases the API surface area and maintenance burden.
|
||||
|
||||
2. **Encapsulation:** The logic for Azure Managed DTS (authentication, routing) is currently encapsulated in an internal interceptor class within `durabletask`. Without changes to the upstream package to expose this logic, we cannot create a single "Universal" worker class that inherits from the base worker but supports Azure features.
|
||||
|
||||
3. **Flexibility:** The Composition pattern allows `DurableAIAgentWorker` to accept *any* instance of a worker that satisfies the required interface. This makes it forward-compatible with future worker implementations or custom subclasses without requiring code changes in our package.
|
||||
|
||||
4. **Simplicity:** While Composition requires a two-step setup (instantiate worker, then wrap it), it keeps the `agent-framework-durabletask` package simple, focused, and loosely coupled from the implementation details of the underlying `durabletask` workers.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,31 @@
|
||||
# Get Started with Microsoft Agent Framework Durable Task
|
||||
|
||||
[](https://pypi.org/project/agent-framework-durabletask/)
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-durabletask --pre
|
||||
```
|
||||
|
||||
## Durable Task Integration
|
||||
|
||||
The durable task integration lets you host Microsoft Agent Framework agents using the [Durable Task](https://github.com/microsoft/durabletask-python) framework so they can persist state, replay conversation history, and recover from failures automatically.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
```python
|
||||
from durabletask import TaskHubGrpcWorker
|
||||
from agent_framework_durabletask import AgentWorker
|
||||
|
||||
# Create the worker
|
||||
with DurableTaskSchedulerWorker(...) as worker:
|
||||
|
||||
# Register the agent worker wrapper
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
# Register the agent
|
||||
agent_worker.add_agent(my_agent)
|
||||
```
|
||||
|
||||
For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Durable Task integration for Microsoft Agent Framework."""
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._constants import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
MIMETYPE_TEXT_PLAIN,
|
||||
REQUEST_RESPONSE_FORMAT_JSON,
|
||||
REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
THREAD_ID_FIELD,
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
ApiResponseFields,
|
||||
ContentTypes,
|
||||
DurableStateFields,
|
||||
)
|
||||
from ._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateContent,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateDataContent,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateEntryJsonType,
|
||||
DurableAgentStateErrorContent,
|
||||
DurableAgentStateFunctionCallContent,
|
||||
DurableAgentStateFunctionResultContent,
|
||||
DurableAgentStateHostedFileContent,
|
||||
DurableAgentStateHostedVectorStoreContent,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
DurableAgentStateTextContent,
|
||||
DurableAgentStateTextReasoningContent,
|
||||
DurableAgentStateUnknownContent,
|
||||
DurableAgentStateUriContent,
|
||||
DurableAgentStateUsage,
|
||||
DurableAgentStateUsageContent,
|
||||
)
|
||||
from ._models import RunRequest, serialize_response_format
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_POLL_RETRIES",
|
||||
"DEFAULT_POLL_INTERVAL_SECONDS",
|
||||
"MIMETYPE_APPLICATION_JSON",
|
||||
"MIMETYPE_TEXT_PLAIN",
|
||||
"REQUEST_RESPONSE_FORMAT_JSON",
|
||||
"REQUEST_RESPONSE_FORMAT_TEXT",
|
||||
"THREAD_ID_FIELD",
|
||||
"THREAD_ID_HEADER",
|
||||
"WAIT_FOR_RESPONSE_FIELD",
|
||||
"WAIT_FOR_RESPONSE_HEADER",
|
||||
"AgentCallbackContext",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"ApiResponseFields",
|
||||
"ContentTypes",
|
||||
"DurableAgentState",
|
||||
"DurableAgentStateContent",
|
||||
"DurableAgentStateData",
|
||||
"DurableAgentStateDataContent",
|
||||
"DurableAgentStateEntry",
|
||||
"DurableAgentStateEntryJsonType",
|
||||
"DurableAgentStateErrorContent",
|
||||
"DurableAgentStateFunctionCallContent",
|
||||
"DurableAgentStateFunctionResultContent",
|
||||
"DurableAgentStateHostedFileContent",
|
||||
"DurableAgentStateHostedVectorStoreContent",
|
||||
"DurableAgentStateMessage",
|
||||
"DurableAgentStateRequest",
|
||||
"DurableAgentStateResponse",
|
||||
"DurableAgentStateTextContent",
|
||||
"DurableAgentStateTextReasoningContent",
|
||||
"DurableAgentStateUnknownContent",
|
||||
"DurableAgentStateUriContent",
|
||||
"DurableAgentStateUsage",
|
||||
"DurableAgentStateUsageContent",
|
||||
"DurableStateFields",
|
||||
"RunRequest",
|
||||
"serialize_response_format",
|
||||
]
|
||||
+5
-2
@@ -56,7 +56,7 @@ from dateutil import parser as date_parser
|
||||
from ._constants import ApiResponseFields, ContentTypes, DurableStateFields
|
||||
from ._models import RunRequest, serialize_response_format
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.durable_agent_state")
|
||||
logger = get_logger("agent_framework.durabletask.durable_agent_state")
|
||||
|
||||
|
||||
class DurableAgentStateEntryJsonType(str, Enum):
|
||||
@@ -82,7 +82,10 @@ def _parse_created_at(value: Any) -> datetime:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.warning("Invalid or missing created_at value in durable agent state; defaulting to current UTC time.")
|
||||
logger.error(
|
||||
f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}",
|
||||
stack_info=True,
|
||||
)
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Data models for Durable Agent Framework.
|
||||
|
||||
This module defines the request and response models used by the framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agent_framework import Role
|
||||
|
||||
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking imports only
|
||||
from pydantic import BaseModel
|
||||
|
||||
_PydanticBaseModel: type[BaseModel] | None
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel as _RuntimeBaseModel
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
_PydanticBaseModel = None
|
||||
else:
|
||||
_PydanticBaseModel = _RuntimeBaseModel
|
||||
|
||||
|
||||
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||
"""Serialize response format for transport across durable function boundaries."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is None:
|
||||
raise RuntimeError("pydantic is required to use structured response formats")
|
||||
|
||||
if not inspect.isclass(response_format) or not issubclass(response_format, _PydanticBaseModel):
|
||||
raise TypeError("response_format must be a Pydantic BaseModel type")
|
||||
|
||||
return {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": response_format.__module__,
|
||||
"qualname": response_format.__qualname__,
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None:
|
||||
"""Deserialize response format back into actionable type if possible."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
_PydanticBaseModel is not None
|
||||
and inspect.isclass(response_format)
|
||||
and issubclass(response_format, _PydanticBaseModel)
|
||||
):
|
||||
return response_format
|
||||
|
||||
if not isinstance(response_format, dict):
|
||||
return None
|
||||
|
||||
response_dict = cast(dict[str, Any], response_format)
|
||||
|
||||
if response_dict.get("__response_schema_type__") != "pydantic_model":
|
||||
return None
|
||||
|
||||
module_name = response_dict.get("module")
|
||||
qualname = response_dict.get("qualname")
|
||||
if not module_name or not qualname:
|
||||
return None
|
||||
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
except ImportError: # pragma: no cover - user provided module missing
|
||||
return None
|
||||
|
||||
attr: Any = module
|
||||
for part in qualname.split("."):
|
||||
try:
|
||||
attr = getattr(attr, part)
|
||||
except AttributeError: # pragma: no cover - invalid qualname
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is not None and inspect.isclass(attr) and issubclass(attr, _PydanticBaseModel):
|
||||
return attr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRequest:
|
||||
"""Represents a request to run an agent with a specific message and configuration.
|
||||
|
||||
Attributes:
|
||||
message: The message to send to the agent
|
||||
request_response_format: The desired response format (e.g., "text" or "json")
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
created_at: Optional timestamp when the request was created
|
||||
orchestration_id: Optional ID of the orchestration that initiated this request
|
||||
"""
|
||||
|
||||
message: str
|
||||
request_response_format: str
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
orchestration_id: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
created_at: datetime | None = None,
|
||||
orchestration_id: str | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.request_response_format = request_response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
self.created_at = created_at
|
||||
self.orchestration_id = orchestration_id
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
"""Normalize various role representations into a Role instance."""
|
||||
if isinstance(value, Role):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return Role.USER
|
||||
return Role(value=normalized.lower())
|
||||
return Role.USER
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
"message": self.message,
|
||||
"enable_tool_calls": self.enable_tool_calls,
|
||||
"role": self.role.value,
|
||||
"request_response_format": self.request_response_format,
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = serialize_response_format(self.response_format)
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlationId"] = self.correlation_id
|
||||
if self.created_at:
|
||||
result["created_at"] = self.created_at.isoformat()
|
||||
if self.orchestration_id:
|
||||
result["orchestrationId"] = self.orchestration_id
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
created_at = data.get("created_at")
|
||||
if isinstance(created_at, str):
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at)
|
||||
except ValueError:
|
||||
created_at = None
|
||||
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlationId"),
|
||||
created_at=created_at,
|
||||
orchestration_id=data.get("orchestrationId"),
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
[project]
|
||||
name = "agent-framework-durabletask"
|
||||
description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "0.0.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"durabletask>=1.1.0",
|
||||
"durabletask-azuremanaged>=1.1.0"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"types-python-dateutil>=2.9.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_durabletask"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask"
|
||||
test = "pytest --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,216 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAgentState and related classes."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_durabletask._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
)
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
|
||||
|
||||
class TestDurableAgentStateRequestOrchestrationId:
|
||||
"""Test suite for DurableAgentStateRequest orchestration_id field."""
|
||||
|
||||
def test_request_with_orchestration_id(self) -> None:
|
||||
"""Test creating a request with an orchestration_id."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
|
||||
assert request.orchestration_id == "orch-456"
|
||||
|
||||
def test_request_to_dict_includes_orchestration_id(self) -> None:
|
||||
"""Test that to_dict includes orchestrationId when set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-789",
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" in data
|
||||
assert data["orchestrationId"] == "orch-789"
|
||||
|
||||
def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test that to_dict excludes orchestrationId when not set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_request_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict correctly parses orchestrationId."""
|
||||
data = {
|
||||
"$type": "request",
|
||||
"correlationId": "corr-123",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}],
|
||||
"orchestrationId": "orch-from-dict",
|
||||
}
|
||||
|
||||
request = DurableAgentStateRequest.from_dict(data)
|
||||
|
||||
assert request.orchestration_id == "orch-from-dict"
|
||||
|
||||
def test_request_from_run_request_with_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly transfers orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
orchestration_id="orch-from-run-request",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id == "orch-from-run-request"
|
||||
|
||||
def test_request_from_run_request_without_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly handles missing orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id is None
|
||||
|
||||
|
||||
class TestDurableAgentStateMessageCreatedAt:
|
||||
"""Test suite for DurableAgentStateMessage created_at field handling."""
|
||||
|
||||
def test_message_from_run_request_without_created_at_preserves_none(self) -> None:
|
||||
"""Test from_run_request preserves None created_at instead of defaulting to current time.
|
||||
|
||||
When a RunRequest has no created_at value, the resulting DurableAgentStateMessage
|
||||
should also have None for created_at, not default to current UTC time.
|
||||
"""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at=None, # Explicitly None
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
assert durable_message.created_at is None
|
||||
|
||||
def test_message_from_run_request_with_created_at_parses_correctly(self) -> None:
|
||||
"""Test from_run_request correctly parses a valid created_at timestamp."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
assert durable_message.created_at is not None
|
||||
assert durable_message.created_at.year == 2024
|
||||
assert durable_message.created_at.month == 1
|
||||
assert durable_message.created_at.day == 15
|
||||
|
||||
|
||||
class TestDurableAgentState:
|
||||
"""Test suite for DurableAgentState."""
|
||||
|
||||
def test_schema_version(self) -> None:
|
||||
"""Test that schema version is set correctly."""
|
||||
state = DurableAgentState()
|
||||
assert state.schema_version == "1.1.0"
|
||||
|
||||
def test_to_dict_serialization(self) -> None:
|
||||
"""Test that to_dict produces correct structure."""
|
||||
state = DurableAgentState()
|
||||
data = state.to_dict()
|
||||
|
||||
assert "schemaVersion" in data
|
||||
assert "data" in data
|
||||
assert data["schemaVersion"] == "1.1.0"
|
||||
assert "conversationHistory" in data["data"]
|
||||
|
||||
def test_from_dict_deserialization(self) -> None:
|
||||
"""Test that from_dict restores state correctly."""
|
||||
original_data = {
|
||||
"schemaVersion": "1.1.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "test-123",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [{"$type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
state = DurableAgentState.from_dict(original_data)
|
||||
|
||||
assert state.schema_version == "1.1.0"
|
||||
assert len(state.data.conversation_history) == 1
|
||||
assert isinstance(state.data.conversation_history[0], DurableAgentStateRequest)
|
||||
|
||||
def test_round_trip_serialization(self) -> None:
|
||||
"""Test that round-trip serialization preserves data."""
|
||||
state = DurableAgentState()
|
||||
state.data.conversation_history.append(
|
||||
DurableAgentStateRequest(
|
||||
correlation_id="test-456",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="Test message")],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
restored = DurableAgentState.from_dict(data)
|
||||
|
||||
assert restored.schema_version == state.schema_version
|
||||
assert len(restored.data.conversation_history) == len(state.data.conversation_history)
|
||||
assert restored.data.conversation_history[0].correlation_id == "test-456"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for data models (RunRequest)."""
|
||||
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class TestRunRequest:
|
||||
"""Test suite for RunRequest."""
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test RunRequest initialization with defaults."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-default")
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.response_format is None
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-default"
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test RunRequest initialization with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is schema
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-123"
|
||||
|
||||
def test_init_coerces_string_role(self) -> None:
|
||||
"""Ensure string role values are coerced into Role instances."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type]
|
||||
|
||||
assert request.role == Role.SYSTEM
|
||||
|
||||
def test_to_dict_with_defaults(self) -> None:
|
||||
"""Test to_dict with default values."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-to-dict")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test message"
|
||||
assert data["enable_tool_calls"] is True
|
||||
assert data["role"] == "user"
|
||||
assert "response_format" not in data or data["response_format"] is None
|
||||
assert data["thread_id"] == "thread-to-dict"
|
||||
|
||||
def test_to_dict_with_all_fields(self) -> None:
|
||||
"""Test to_dict with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-456",
|
||||
role=Role.ASSISTANT,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Hello"
|
||||
assert data["role"] == "assistant"
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == schema.__module__
|
||||
assert data["response_format"]["qualname"] == schema.__qualname__
|
||||
assert data["enable_tool_calls"] is False
|
||||
assert data["thread_id"] == "thread-456"
|
||||
|
||||
def test_from_dict_with_defaults(self) -> None:
|
||||
"""Test from_dict with minimal data."""
|
||||
data = {"message": "Hello", "thread_id": "thread-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-from-dict"
|
||||
|
||||
def test_from_dict_with_all_fields(self) -> None:
|
||||
"""Test from_dict with all fields."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"role": "system",
|
||||
"response_format": {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": ModuleStructuredResponse.__module__,
|
||||
"qualname": ModuleStructuredResponse.__qualname__,
|
||||
},
|
||||
"enable_tool_calls": False,
|
||||
"thread_id": "thread-789",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is ModuleStructuredResponse
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-789"
|
||||
|
||||
def test_from_dict_with_unknown_role_preserves_value(self) -> None:
|
||||
"""Test from_dict keeps custom roles intact."""
|
||||
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.role.value == "reviewer"
|
||||
assert request.role != Role.USER
|
||||
|
||||
def test_from_dict_empty_message(self) -> None:
|
||||
"""Test from_dict with empty message."""
|
||||
data = {"thread_id": "thread-empty"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == ""
|
||||
assert request.role == Role.USER
|
||||
assert request.thread_id == "thread-empty"
|
||||
|
||||
def test_round_trip_dict_conversion(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
assert restored.enable_tool_calls == original.enable_tool_calls
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_round_trip_with_pydantic_response_format(self) -> None:
|
||||
"""Ensure Pydantic response formats serialize and deserialize properly."""
|
||||
original = RunRequest(
|
||||
message="Structured",
|
||||
thread_id="thread-pydantic",
|
||||
response_format=ModuleStructuredResponse,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
|
||||
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
|
||||
|
||||
restored = RunRequest.from_dict(data)
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
|
||||
def test_init_with_correlationId(self) -> None:
|
||||
"""Test RunRequest initialization with correlationId."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.correlation_id == "corr-123"
|
||||
|
||||
def test_to_dict_with_correlationId(self) -> None:
|
||||
"""Test to_dict includes correlationId."""
|
||||
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["correlationId"] == "corr-456"
|
||||
|
||||
def test_from_dict_with_correlationId(self) -> None:
|
||||
"""Test from_dict with correlationId."""
|
||||
data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-789"
|
||||
assert request.thread_id == "thread-corr-from-dict"
|
||||
|
||||
def test_round_trip_with_correlationId(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with correlationId."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_init_with_orchestration_id(self) -> None:
|
||||
"""Test RunRequest initialization with orchestration_id."""
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-orch-init",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.orchestration_id == "orch-123"
|
||||
|
||||
def test_to_dict_with_orchestration_id(self) -> None:
|
||||
"""Test to_dict includes orchestrationId."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
thread_id="thread-orch-to-dict",
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["orchestrationId"] == "orch-456"
|
||||
|
||||
def test_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test to_dict excludes orchestrationId when not set."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
thread_id="thread-orch-none",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict with orchestrationId."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"orchestrationId": "orch-789",
|
||||
"thread_id": "thread-orch-from-dict",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.orchestration_id == "orch-789"
|
||||
assert request.thread_id == "thread-orch-from-dict"
|
||||
|
||||
def test_round_trip_with_orchestration_id(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with orchestration_id."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.orchestration_id == original.orchestration_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -94,6 +94,7 @@ agent-framework-chatkit = { workspace = true }
|
||||
agent-framework-copilotstudio = { workspace = true }
|
||||
agent-framework-declarative = { workspace = true }
|
||||
agent-framework-devui = { workspace = true }
|
||||
agent-framework-durabletask = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
agent-framework-mem0 = { workspace = true }
|
||||
agent-framework-purview = { workspace = true }
|
||||
|
||||
Generated
+3735
-3589
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user