[BREAKING] Python: Schema changes for azure functions package (#2151)

* Python: Add Scaffolding for Durable AzureFunctions package to Agent Framework (#1823)

* Add scafolding

* update readme

* add code owners and label

* update owners

* .NET: Durable extension: initial src and unit tests (#1900)

* Python: Add Durable Agent Wrapper code (#1913)

* add initial changes

* Move code and add single sample

* Update logger

* Remove unused code

* address PR comments

* cleanup code and address comments

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Azure Functions .NET samples (#1939)

* Python: Add Unit tests for Azurefunctions package (#1976)

* Add Unit tests for Azurefunctions

* remove duplicate import

* .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979)

* Python: Add more samples for Azure Functions (#1980)

* Move all samples

* fix comments

* remove dead lines

* Make samples simpler

* .NET: [Feature Branch] Durable Task extension integration tests (#2017)

* .NET: [Feature Branch] Update OpenAI config for integration tests (#2063)

* Python: Add Integration tests for AzureFunctions  (#2020)

* Add Integration tests

* Remove DTS extension

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Add pyi file for type safety

* Add samples in readme

* Updated all readme instructions

* Address comments

* Update readmes

* Fix requirements

* Address comments

---------

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

* .NET: [Feature Branch] Update dotnet-build-and-test.yml to support integration tests (#2070)

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

* Fix DTS startup issue and improve logging (#2103)

* .NET: [Feature Branch] Introduce Azure OpenAI config for .NET pipeline (#2106)

Also fixes an issue where we were trying to start docker containers for integration tests on Windows, which doesn't work.

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

* Fix uv.lock after merge

* Python: Add README for Azure Functions samples setup (#2100)

* Add README for Azure Functions samples setup

Added setup instructions for Azure Functions samples, including environment setup, virtual environment creation, and running samples.

* Update python/samples/getting_started/azure_functions/README.md

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

* Apply suggestions from code review

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

* Apply suggestion from @Copilot

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

* Apply suggestions from code review

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

---------

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

* Fix or remove broken markdown file links (#2115)

* .NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118)

* Python: Fix AzureFunctions Integration Tests (#2116)

* Add Identity Auth to samples

* Update python/samples/getting_started/azure_functions/README.md

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

* Update python/samples/getting_started/azure_functions/01_single_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/02_multi_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md

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

---------

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

* Python: Fix Http Schema (#2112)

* Rename to threadid

* Respond in plain text

* Make snake-case

* Add http prefix

* rename to wait-for-response

* Add query param check

* address comments

* .NET: Remove IsPackable=false in preparation for nuget release (#2142)

* 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>

* Update python/packages/azurefunctions/pyproject.toml

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

* Update python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py

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

* Fix imports

* Address PR feedback from westey-m (#2150)

- Adds a link from the /dotnet/samples/README.md to /dotnet/samples/AzureFunctions
- Make DurableAgentThread deserialization internal for future-proofing
- Update JSON serialization logic to address recently discovered issues with source generator serialization

* Schema changes for azure functions

* Fixed serialization bug

* update to camel case

* Adding logs

* merge with main

* sync uv.lock

* Updated schema

* Fixed tests

* Addressed comments

* Fixed mypy errors

* Fixed bug in responsetype and authorName

* Addressed feedback

* Addressed more feedback

* Python: Addressing comments for #2151 (#2315)

* Initial fixes

* Address more comments

* Address remaining comments

* Fixed remaining snake_case properties

* Fixed remaining snake_case properties

* Fixed mypy errors

* Minor changes

* revert tool names

* Fixed mypy errors

---------

Co-authored-by: Laveesh Rohra <larohra@microsoft.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Anirudh Garg <anirudhg@microsoft.com>
Co-authored-by: Victoria Hall <victoriahall@microsoft.com>
This commit is contained in:
Gavin Aguiar
2025-11-20 10:24:34 -06:00
committed by GitHub
Unverified
parent 039e49f353
commit 6ae32f007d
15 changed files with 1700 additions and 563 deletions
@@ -16,27 +16,29 @@ import azure.functions as func
from agent_framework import AgentProtocol, get_logger
from ._callbacks import 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,
)
from ._durable_agent_state import DurableAgentState
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")
THREAD_ID_FIELD: str = "thread_id"
RESPONSE_FORMAT_JSON: str = "json"
RESPONSE_FORMAT_TEXT: str = "text"
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
EntityHandler = Callable[[df.DurableEntityContext], None]
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
if TYPE_CHECKING:
class DFAppBase:
@@ -317,11 +319,11 @@ class AgentFunctionApp(DFAppBase):
"""
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
response_format: str = RESPONSE_FORMAT_JSON
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
thread_id: str | None = None
try:
req_body, message, response_format = self._parse_incoming_request(req)
req_body, message, request_response_format = self._parse_incoming_request(req)
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
@@ -334,7 +336,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": "Message is required"},
status_code=400,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -351,6 +353,7 @@ class AgentFunctionApp(DFAppBase):
message,
thread_id,
correlation_id,
request_response_format,
)
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
await client.signal_entity(entity_instance_id, "run_agent", run_request)
@@ -370,7 +373,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload=result,
status_code=200 if result.get("status") == "success" else 500,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -383,7 +386,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload=accepted_response,
status_code=202,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -392,7 +395,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": str(exc)},
status_code=exc.status_code,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
except ValueError as exc:
@@ -400,7 +403,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": "Invalid JSON"},
status_code=400,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
except Exception as exc:
@@ -408,7 +411,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": str(exc)},
status_code=500,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -466,7 +469,7 @@ class AgentFunctionApp(DFAppBase):
return func.HttpResponse(
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
status_code=200,
mimetype="application/json",
mimetype=MIMETYPE_APPLICATION_JSON,
)
_ = health_check
@@ -491,7 +494,7 @@ class AgentFunctionApp(DFAppBase):
self,
client: df.DurableOrchestrationClient,
entity_instance_id: df.EntityId,
) -> AgentState | None:
) -> DurableAgentState | None:
state_response = await client.read_entity_state(entity_instance_id)
if not state_response or not state_response.entity_exists:
return None
@@ -502,9 +505,7 @@ class AgentFunctionApp(DFAppBase):
typed_state_payload = cast(dict[str, Any], state_payload)
agent_state = AgentState()
agent_state.restore_state(typed_state_payload)
return agent_state
return DurableAgentState.from_dict(typed_state_payload)
async def _get_response_from_entity(
self,
@@ -580,31 +581,58 @@ class AgentFunctionApp(DFAppBase):
return result
def _build_response_payload(
self,
*,
response: str | None,
message: str,
thread_id: str,
status: str,
correlation_id: str,
extra_fields: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a consistent response structure and allow optional extra fields."""
payload = {
"response": response,
"message": message,
THREAD_ID_FIELD: thread_id,
"status": status,
"correlation_id": correlation_id,
}
if extra_fields:
payload.update(extra_fields)
return payload
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
"""Create the timeout response."""
return {
"response": "Agent is still processing or timed out...",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "timeout",
"correlation_id": correlation_id,
}
return self._build_response_payload(
response="Agent is still processing or timed out...",
message=message,
thread_id=thread_id,
status="timeout",
correlation_id=correlation_id,
)
def _build_success_result(
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
) -> dict[str, Any]:
"""Build the success result returned to the HTTP caller."""
return {
"response": response_data.get("content"),
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "success",
"message_count": response_data.get("message_count", state.message_count),
"correlation_id": correlation_id,
}
return self._build_response_payload(
response=response_data.get("content"),
message=message,
thread_id=thread_id,
status="success",
correlation_id=correlation_id,
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
)
def _build_request_data(
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
self,
req_body: dict[str, Any],
message: str,
thread_id: str,
correlation_id: str,
request_response_format: str,
) -> dict[str, Any]:
"""Create the durable entity request payload."""
enable_tool_calls_value = req_body.get("enable_tool_calls")
@@ -613,6 +641,7 @@ class AgentFunctionApp(DFAppBase):
return RunRequest(
message=message,
role=req_body.get("role"),
request_response_format=request_response_format,
response_format=req_body.get("response_format"),
enable_tool_calls=enable_tool_calls,
thread_id=thread_id,
@@ -621,23 +650,23 @@ class AgentFunctionApp(DFAppBase):
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
"""Build the response returned when not waiting for completion."""
return {
"response": "Agent request accepted",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "accepted",
"correlation_id": correlation_id,
}
return self._build_response_payload(
response="Agent request accepted",
message=message,
thread_id=thread_id,
status="accepted",
correlation_id=correlation_id,
)
def _create_http_response(
self,
payload: dict[str, Any] | str,
status_code: int,
response_format: str,
request_response_format: str,
thread_id: str | None,
) -> func.HttpResponse:
"""Create the HTTP response using helper serializers for clarity."""
if response_format == RESPONSE_FORMAT_TEXT:
if request_response_format == REQUEST_RESPONSE_FORMAT_TEXT:
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
return self._build_json_response(payload=payload, status_code=status_code)
@@ -650,13 +679,13 @@ class AgentFunctionApp(DFAppBase):
) -> func.HttpResponse:
"""Return a plain-text response with optional thread identifier header."""
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
headers = {THREAD_ID_HEADER: thread_id} if thread_id is not None else None
return func.HttpResponse(body_text, status_code=status_code, mimetype=MIMETYPE_TEXT_PLAIN, headers=headers)
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
"""Return the JSON response, serializing dictionaries as needed."""
body_json = payload if isinstance(payload, str) else json.dumps(payload)
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
return func.HttpResponse(body_json, status_code=status_code, mimetype=MIMETYPE_APPLICATION_JSON)
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
"""Convert a structured payload into a human-readable text response."""
@@ -702,18 +731,19 @@ class AgentFunctionApp(DFAppBase):
normalized_content_type = self._extract_content_type(headers)
body_parser, body_format = self._select_body_parser(normalized_content_type)
prefers_json = self._accepts_json_response(headers)
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
request_response_format = self._select_request_response_format(
body_format=body_format, prefers_json=prefers_json
)
req_body, message = body_parser(req)
return req_body, message, response_format
return req_body, message, request_response_format
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
"""Create a lowercase header mapping from the incoming request."""
headers: dict[str, str] = {}
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
for key, value in header_mapping.items():
for key, value in raw_headers.items():
if value is not None:
headers[str(key).lower()] = str(value)
return headers
@@ -729,9 +759,9 @@ class AgentFunctionApp(DFAppBase):
normalized_content_type: str,
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
"""Choose the body parser and declared body format."""
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
return self._parse_json_body, RESPONSE_FORMAT_JSON
return self._parse_text_body, RESPONSE_FORMAT_TEXT
if normalized_content_type in {MIMETYPE_APPLICATION_JSON} or normalized_content_type.endswith("+json"):
return self._parse_json_body, REQUEST_RESPONSE_FORMAT_JSON
return self._parse_text_body, REQUEST_RESPONSE_FORMAT_TEXT
@staticmethod
def _accepts_json_response(headers: dict[str, str]) -> bool:
@@ -742,16 +772,16 @@ class AgentFunctionApp(DFAppBase):
for value in accept_header.split(","):
media_type = value.split(";")[0].strip().lower()
if media_type == "application/json":
if media_type == MIMETYPE_APPLICATION_JSON:
return True
return False
@staticmethod
def _select_response_format(body_format: str, prefers_json: bool) -> str:
def _select_request_response_format(body_format: str, prefers_json: bool) -> str:
"""Combine body format and accept preference to determine response format."""
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
return RESPONSE_FORMAT_JSON
return RESPONSE_FORMAT_TEXT
if body_format == REQUEST_RESPONSE_FORMAT_JSON or prefers_json:
return REQUEST_RESPONSE_FORMAT_JSON
return REQUEST_RESPONSE_FORMAT_TEXT
@staticmethod
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
"""Constants for Azure Functions Agent Framework integration."""
# Supported request/response formats and MIME types
REQUEST_RESPONSE_FORMAT_JSON: str = "json"
REQUEST_RESPONSE_FORMAT_TEXT: str = "text"
MIMETYPE_APPLICATION_JSON: str = "application/json"
MIMETYPE_TEXT_PLAIN: str = "text/plain"
# Field and header names
THREAD_ID_FIELD: str = "thread_id"
THREAD_ID_HEADER: str = "x-ms-thread-id"
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
# Polling configuration
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
@@ -11,14 +11,30 @@ import asyncio
import inspect
import json
from collections.abc import AsyncIterable, Callable
from datetime import datetime, timezone
from typing import Any, cast
import azure.durable_functions as df
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
ChatMessage,
ErrorContent,
Role,
get_logger,
)
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateEntry,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import AgentResponse, RunRequest
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions.entities")
@@ -38,11 +54,11 @@ class AgentEntity:
Attributes:
agent: The AgentProtocol instance
state: The AgentState managing conversation history
state: The DurableAgentState managing conversation history
"""
agent: AgentProtocol
state: AgentState
state: DurableAgentState
def __init__(
self,
@@ -56,11 +72,27 @@ class AgentEntity:
callback: Optional callback invoked during streaming updates and final responses
"""
self.agent = agent
self.state = AgentState()
self.state = DurableAgentState()
self.callback = callback
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
"""Check if a conversation history entry is an error response.
Error responses should be kept in history for tracking but not sent to the agent
since Azure OpenAI doesn't support 'error' content type.
Args:
entry: A conversation history entry (DurableAgentStateEntry or dict)
Returns:
True if the entry is a response containing error content, False otherwise
"""
if isinstance(entry, DurableAgentStateResponse):
return entry.is_error
return False
async def run_agent(
self,
context: df.DurableEntityContext,
@@ -94,26 +126,27 @@ class AgentEntity:
raise ValueError("RunRequest must include a thread_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
role = run_request.role or Role.USER
response_format = run_request.response_format
enable_tool_calls = run_request.enable_tool_calls
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
# Store message in history with role
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
logger.debug("[AgentEntity.run_agent] Executing agent...")
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
try:
logger.debug("[AgentEntity.run_agent] Starting agent invocation")
run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()}
# Build messages from conversation history, excluding error responses
# Error responses are kept in history for tracking but not sent to the agent
chat_messages: list[ChatMessage] = [
m.to_chat_message()
for entry in self.state.data.conversation_history
if not self._is_error_response(entry)
for m in entry.messages
]
run_kwargs: dict[str, Any] = {"messages": chat_messages}
if not enable_tool_calls:
run_kwargs["tools"] = None
if response_format:
@@ -133,8 +166,8 @@ class AgentEntity:
response_text = None
structured_response = None
response_str: str | None = None
try:
if response_format:
try:
@@ -156,18 +189,19 @@ class AgentEntity:
)
response_text = "Error extracting response"
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
agent_response = AgentResponse(
response=response_text,
message=str(message),
thread_id=str(thread_id),
status="success",
message_count=self.state.message_count,
message_count=len(self.state.data.conversation_history),
structured_response=structured_response,
)
result = agent_response.to_dict()
content = json.dumps(structured_response) if structured_response else (response_text or "")
self.state.add_assistant_message(content, agent_run_response, correlation_id)
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
return result
@@ -181,12 +215,28 @@ class AgentEntity:
logger.error(f"Error type: {type(exc).__name__}")
logger.error(f"Full traceback:\n{error_traceback}")
# Create error message
error_message = DurableAgentStateMessage.from_chat_message(
ChatMessage(
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
)
)
# Create and store error response in conversation history
error_state_response = DurableAgentStateResponse(
correlation_id=correlation_id,
created_at=datetime.now(tz=timezone.utc),
messages=[error_message],
is_error=True,
)
self.state.data.conversation_history.append(error_state_response)
error_response = AgentResponse(
response=f"Error: {exc!s}",
message=str(message),
thread_id=str(thread_id),
status="error",
message_count=self.state.message_count,
message_count=len(self.state.data.conversation_history),
error=str(exc),
error_type=type(exc).__name__,
)
@@ -333,7 +383,7 @@ class AgentEntity:
def reset(self, context: df.DurableEntityContext) -> None:
"""Reset the entity state (clear conversation history)."""
logger.debug("[AgentEntity.reset] Resetting entity state")
self.state.reset()
self.state.data = DurableAgentStateData(conversation_history=[])
logger.debug("[AgentEntity.reset] State reset complete")
@@ -362,7 +412,7 @@ def create_agent_entity(
entity = AgentEntity(agent, callback)
if current_state is not None:
entity.state.restore_state(current_state)
entity.state = DurableAgentState.from_dict(current_state)
logger.debug(
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
)
@@ -392,8 +442,9 @@ def create_agent_entity(
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {operation}"})
logger.debug("State dict: %s", entity.state.to_dict())
context.set_state(entity.state.to_dict())
logger.debug(f"[entity_function] Operation {operation} completed successfully")
logger.info(f"[entity_function] Operation {operation} completed successfully")
except Exception as exc:
import traceback
@@ -17,6 +17,8 @@ from typing import TYPE_CHECKING, Any, cast
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
@@ -278,35 +280,43 @@ class RunRequest:
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
"""
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
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,
) -> 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
@staticmethod
def coerce_role(value: Role | str | None) -> Role:
@@ -326,13 +336,17 @@ class RunRequest:
"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["correlation_id"] = self.correlation_id
result["correlationId"] = self.correlation_id
if self.created_at:
result["created_at"] = self.created_at
return result
@classmethod
@@ -340,11 +354,13 @@ class 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("correlation_id"),
correlation_id=data.get("correlationId"),
created_at=data.get("created_at"),
)
@@ -1,179 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent State Management.
This module defines the AgentState class for managing conversation state and
serializing agent framework responses.
"""
from collections.abc import MutableMapping
from datetime import datetime, timezone
from typing import Any, cast
from agent_framework import AgentRunResponse, ChatMessage, Role, get_logger
logger = get_logger("agent_framework.azurefunctions.state")
class AgentState:
"""Manages agent conversation state using agent_framework types (ChatMessage, AgentRunResponse).
This class handles:
- Conversation history tracking using ChatMessage objects
- Agent response storage using AgentRunResponse objects with correlation IDs
- State persistence and restoration
- Message counting
"""
def __init__(self) -> None:
"""Initialize empty agent state."""
self.conversation_history: list[ChatMessage] = []
self.last_response: str | None = None
self.message_count: int = 0
def _current_timestamp(self) -> str:
"""Return an ISO 8601 UTC timestamp."""
return datetime.now(timezone.utc).isoformat()
def add_user_message(
self,
content: str,
role: Role = Role.USER,
correlation_id: str | None = None,
) -> None:
"""Add a user message to the conversation history as a ChatMessage object.
Args:
content: The message content
role: The message role (user, system, etc.)
correlation_id: Optional correlation identifier associated with the user message
"""
self.message_count += 1
timestamp = self._current_timestamp()
additional_props: MutableMapping[str, Any] = {"timestamp": timestamp}
if correlation_id is not None:
additional_props["correlation_id"] = correlation_id
chat_message = ChatMessage(role=role, text=content, additional_properties=additional_props)
self.conversation_history.append(chat_message)
logger.debug(f"Added {role} ChatMessage to history (message #{self.message_count})")
def add_assistant_message(
self, content: str, agent_response: AgentRunResponse, correlation_id: str | None = None
) -> None:
"""Add an assistant message to the conversation history with full agent response.
Args:
content: The text content of the response
agent_response: The AgentRunResponse object from the agent framework
correlation_id: Optional correlation ID for tracking this response
"""
self.last_response = content
timestamp = self._current_timestamp()
serialized_response = self.serialize_response(agent_response)
# Create a ChatMessage for the assistant response
# The agent_response already contains messages, but we store it as a custom ChatMessage
# with the agent_response stored in additional_properties for full metadata preservation
additional_props: dict[str, Any] = {
"agent_response": serialized_response,
"correlation_id": correlation_id,
"timestamp": timestamp,
"message_count": self.message_count,
}
chat_message = ChatMessage(role="assistant", text=content, additional_properties=additional_props)
self.conversation_history.append(chat_message)
logger.debug(
f"Added assistant ChatMessage to history with AgentRunResponse metadata (correlation_id: {correlation_id})"
)
def get_chat_messages(self) -> list[ChatMessage]:
"""Return a copy of the full conversation history."""
return list(self.conversation_history)
def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None:
"""Get an agent response by correlation ID.
Args:
correlation_id: The correlation ID to look up
Returns:
The agent response data if found, None otherwise
"""
for message in reversed(self.conversation_history):
metadata = getattr(message, "additional_properties", {}) or {}
if metadata.get("correlation_id") == correlation_id:
return self._build_agent_response_payload(message, metadata)
return None
def serialize_response(self, response: AgentRunResponse) -> dict[str, Any]:
"""Serialize an ``AgentRunResponse`` to a dictionary.
Args:
response: The agent framework response object
Returns:
Dictionary containing all response fields
"""
try:
return response.to_dict()
except Exception as exc: # pragma: no cover - defensive logging path
logger.warning(f"Error serializing response: {exc}")
return {"response": str(response), "serialization_error": str(exc)}
def to_dict(self) -> dict[str, Any]:
"""Get the current state as a dictionary for persistence.
Returns:
Dictionary containing conversation_history (as serialized ChatMessages),
last_response, and message_count
"""
return {
"conversation_history": [msg.to_dict() for msg in self.conversation_history],
"last_response": self.last_response,
"message_count": self.message_count,
}
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore state from a dictionary, reconstructing ChatMessage objects.
Args:
state: Dictionary containing conversation_history, last_response, and message_count
"""
# Restore conversation history as ChatMessage objects
history_data = state.get("conversation_history", [])
restored_history: list[ChatMessage] = []
for raw_message in history_data:
if isinstance(raw_message, dict):
restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message)))
else:
restored_history.append(cast(ChatMessage, raw_message))
self.conversation_history = restored_history
self.last_response = state.get("last_response")
self.message_count = state.get("message_count", 0)
logger.debug("Restored state: %s ChatMessages in history", len(self.conversation_history))
def reset(self) -> None:
"""Reset the state to empty."""
self.conversation_history = []
self.last_response = None
self.message_count = 0
logger.debug("State reset to empty")
def __repr__(self) -> str:
"""String representation of the state."""
return f"AgentState(messages={self.message_count}, history_length={len(self.conversation_history)})"
def _build_agent_response_payload(self, message: ChatMessage, metadata: dict[str, Any]) -> dict[str, Any]:
"""Construct the agent response payload returned to callers."""
return {
"content": message.text,
"agent_response": metadata.get("agent_response"),
"message_count": metadata.get("message_count", self.message_count),
"timestamp": metadata.get("timestamp"),
"correlation_id": metadata.get("correlation_id"),
}
@@ -27,6 +27,11 @@ dependencies = [
"azure-functions-durable",
]
[dependency-groups]
dev = [
"types-python-dateutil>=2.9.0",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
@@ -16,6 +16,8 @@ Usage:
import pytest
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
# Module-level markers - applied to all tests in this file
@@ -67,7 +69,7 @@ class TestSampleSingleAgent:
# Agent responded with plain text when the request body was text/plain.
assert response.text.strip()
assert response.headers.get("x-ms-thread-id") is not None
assert response.headers.get(THREAD_ID_HEADER) is not None
def test_thread_id_in_query(self) -> None:
"""Test using thread_id in query parameter."""
@@ -77,7 +79,7 @@ class TestSampleSingleAgent:
assert response.status_code in [200, 202]
assert response.text.strip()
assert response.headers.get("x-ms-thread-id") == "test-query-thread"
assert response.headers.get(THREAD_ID_HEADER) == "test-query-thread"
def test_conversation_continuity(self) -> None:
"""Test conversation context is maintained across requests."""
@@ -92,7 +94,7 @@ class TestSampleSingleAgent:
if response1.status_code == 200:
data1 = response1.json()
assert data1["message_count"] == 1
assert data1["message_count"] == 2 # Initial + reply
# Second message in same session
response2 = SampleTestHelper.post_json(
@@ -100,7 +102,7 @@ class TestSampleSingleAgent:
)
assert response2.status_code == 200
data2 = response2.json()
assert data2["message_count"] == 2
assert data2["message_count"] == 4
else:
# In async mode, we can't easily test message count
# Just verify we can make multiple calls
+101 -38
View File
@@ -13,7 +13,13 @@ from agent_framework import AgentRunResponse, ChatMessage
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
from agent_framework_azurefunctions._entities import AgentEntity, AgentState, create_agent_entity
from agent_framework_azurefunctions._constants import (
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
THREAD_ID_HEADER,
)
from agent_framework_azurefunctions._durable_agent_state import DurableAgentState
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
@@ -333,14 +339,14 @@ class TestAgentEntityOperations:
result = await entity.run_agent(
mock_context,
{"message": "Test message", "thread_id": "test-conv-123", "correlation_id": "corr-app-entity-1"},
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
)
assert result["status"] == "success"
assert result["response"] == "Test response"
assert result["message"] == "Test message"
assert result["thread_id"] == "test-conv-123"
assert entity.state.message_count == 1
assert entity.state.message_count == 2
async def test_entity_stores_conversation_history(self) -> None:
"""Test that the entity stores conversation history."""
@@ -354,18 +360,29 @@ class TestAgentEntityOperations:
# Send first message
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-2"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
)
history = entity.state.conversation_history
assert len(history) == 2 # User + assistant
# Each conversation turn creates 2 entries: request and response
history = entity.state.data.conversation_history[0].messages # Request entry
assert len(history) == 1 # Just the user message
# Send second message
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
)
# Now we have 4 entries total (2 requests + 2 responses)
# Access the first request entry
history2 = entity.state.data.conversation_history[2].messages # Second request entry
assert len(history2) == 1 # Just the user message
user_msg = history[0]
user_role = getattr(user_msg.role, "value", user_msg.role)
assert user_role == "user"
assert user_msg.text == "Message 1"
assistant_msg = history[1]
assistant_msg = entity.state.data.conversation_history[1].messages[0]
assistant_role = getattr(assistant_msg.role, "value", assistant_msg.role)
assert assistant_role == "assistant"
assert assistant_msg.text == "Response 1"
@@ -380,17 +397,17 @@ class TestAgentEntityOperations:
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
)
assert entity.state.message_count == 1
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
)
assert entity.state.message_count == 2
assert len(entity.state.data.conversation_history) == 4
def test_entity_reset(self) -> None:
"""Test that entity reset clears state."""
@@ -398,19 +415,13 @@ class TestAgentEntityOperations:
entity = AgentEntity(mock_agent)
# Set some state
entity.state.message_count = 10
entity.state.last_response = "Some response"
entity.state.conversation_history = [
ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"})
]
entity.state = DurableAgentState()
# Reset
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.message_count == 0
assert entity.state.last_response is None
assert len(entity.state.conversation_history) == 0
assert len(entity.state.data.conversation_history) == 0
class TestAgentEntityFactory:
@@ -438,7 +449,7 @@ class TestAgentEntityFactory:
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlation_id": "corr-app-factory-1",
"correlationId": "corr-app-factory-1",
}
mock_context.get_state.return_value = None
@@ -458,9 +469,27 @@ class TestAgentEntityFactory:
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = {
"message_count": 5,
"conversation_history": [{"role": "user", "content": "test"}],
"last_response": "Test",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-reset-test",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "test",
}
],
}
],
}
],
},
}
# Execute entity function
@@ -497,19 +526,53 @@ class TestAgentEntityFactory:
# Mock context with existing state
existing_state = {
"message_count": 3,
"conversation_history": [{"role": "user", "content": "msg1"}, {"role": "assistant", "content": "resp1"}],
"last_response": "resp1",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "msg1",
}
],
}
],
},
{
"$type": "response",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:05:00Z",
"messages": [
{
"role": "assistant",
"contents": [
{
"$type": "text",
"text": "resp1",
}
],
}
],
},
],
},
}
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(AgentState, "restore_state") as restore_state_mock:
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
entity_function(mock_context)
restore_state_mock.assert_called_once_with(existing_state)
from_dict_mock.assert_called_once_with(existing_state)
class TestErrorHandling:
@@ -524,7 +587,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlation_id": "corr-app-error-1"}
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
)
assert result["status"] == "error"
@@ -600,7 +663,7 @@ class TestIncomingRequestParsing:
app = self._create_app()
request = Mock()
request.headers = {"accept": "application/json"}
request.headers = {"accept": MIMETYPE_APPLICATION_JSON}
request.params = {}
request.get_json.side_effect = ValueError("Invalid JSON")
request.get_body.return_value = b"Plain text message"
@@ -674,8 +737,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 202
assert response.mimetype == "text/plain"
assert response.headers.get("x-ms-thread-id") is not None
assert response.mimetype == MIMETYPE_TEXT_PLAIN
assert response.headers.get(THREAD_ID_HEADER) is not None
assert response.get_body().decode("utf-8") == "Agent request accepted"
signal_args = client.signal_entity.call_args[0]
@@ -693,7 +756,7 @@ class TestHttpRunRoute:
handler = self._get_run_handler(mock_agent)
request = Mock()
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": "application/json"}
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": MIMETYPE_APPLICATION_JSON}
request.params = {}
request.route_params = {}
request.get_json.side_effect = ValueError("Invalid JSON")
@@ -704,8 +767,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 202
assert response.mimetype == "application/json"
assert response.headers.get("x-ms-thread-id") is None
assert response.mimetype == MIMETYPE_APPLICATION_JSON
assert response.headers.get(THREAD_ID_HEADER) is None
body = response.get_body().decode("utf-8")
assert '"status": "accepted"' in body
@@ -728,8 +791,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 400
assert response.mimetype == "text/plain"
assert response.headers.get("x-ms-thread-id") is not None
assert response.mimetype == MIMETYPE_TEXT_PLAIN
assert response.headers.get(THREAD_ID_HEADER) is not None
assert response.get_body().decode("utf-8") == "Message is required"
client.signal_entity.assert_not_called()
@@ -15,14 +15,20 @@ import pytest
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role
from pydantic import BaseModel
from agent_framework_azurefunctions._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateTextContent,
)
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
from agent_framework_azurefunctions._models import RunRequest
from agent_framework_azurefunctions._state import AgentState
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
def _role_value(chat_message: ChatMessage) -> str:
def _role_value(chat_message: DurableAgentStateMessage) -> str:
"""Helper to extract the string role from a ChatMessage."""
role = getattr(chat_message, "role", None)
role_value = getattr(role, "value", role)
@@ -71,9 +77,9 @@ class TestAgentEntityInit:
entity = AgentEntity(mock_agent)
assert entity.agent == mock_agent
assert entity.state.conversation_history == []
assert entity.state.last_response is None
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
assert entity.state.data.extension_data is None
assert entity.state.schema_version == "1.0.0"
def test_init_stores_agent_reference(self) -> None:
"""Test that the agent reference is stored correctly."""
@@ -112,7 +118,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-1"}
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
)
# Verify agent.run was called
@@ -123,8 +129,8 @@ class TestAgentEntityRunAgent:
assert len(sent_messages) == 1
sent_message = sent_messages[0]
assert isinstance(sent_message, ChatMessage)
assert sent_message.text == "Test message"
assert _role_value(sent_message) == "user"
assert getattr(sent_message, "text", None) == "Test message"
assert getattr(sent_message.role, "value", sent_message.role) == "user"
# Verify result
assert result["status"] == "success"
@@ -158,7 +164,7 @@ class TestAgentEntityRunAgent:
{
"message": "Tell me something",
"thread_id": "session-1",
"correlation_id": "corr-stream-1",
"correlationId": "corr-stream-1",
},
)
@@ -205,7 +211,7 @@ class TestAgentEntityRunAgent:
{
"message": "Hi",
"thread_id": "session-2",
"correlation_id": "corr-final-1",
"correlationId": "corr-final-1",
},
)
@@ -233,19 +239,20 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "User message", "thread_id": "conv-1", "correlation_id": "corr-entity-2"}
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
)
# Should have 2 entries: user message + assistant response
history = entity.state.conversation_history
# Should have 1 entry: user message + assistant response
user_history = entity.state.data.conversation_history[0].messages
assistant_history = entity.state.data.conversation_history[1].messages
assert len(history) == 2
assert len(user_history) == 1
user_msg = history[0]
user_msg = user_history[0]
assert _role_value(user_msg) == "user"
assert user_msg.text == "User message"
assistant_msg = history[1]
assistant_msg = assistant_history[0]
assert _role_value(assistant_msg) == "assistant"
assert assistant_msg.text == "Agent response"
@@ -257,41 +264,22 @@ class TestAgentEntityRunAgent:
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-3a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
)
assert entity.state.message_count == 1
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-3b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
)
assert entity.state.message_count == 2
assert len(entity.state.data.conversation_history) == 4
await entity.run_agent(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-3c"}
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
)
assert entity.state.message_count == 3
async def test_run_agent_stores_last_response(self) -> None:
"""Test that run_agent stores the last response."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-4a"}
)
assert entity.state.last_response == "Response 1"
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-4b"}
)
assert entity.state.last_response == "Response 2"
assert len(entity.state.data.conversation_history) == 6
async def test_run_agent_with_none_thread_id(self) -> None:
"""Test run_agent with a None thread identifier."""
@@ -303,7 +291,7 @@ class TestAgentEntityRunAgent:
with pytest.raises(ValueError, match="thread_id"):
await entity.run_agent(
mock_context, {"message": "Message", "thread_id": None, "correlation_id": "corr-entity-5"}
mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"}
)
async def test_run_agent_handles_response_without_text_attribute(self) -> None:
@@ -322,7 +310,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-6"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-6"}
)
# Should handle gracefully
@@ -338,7 +326,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-7"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-7"}
)
assert result["status"] == "success"
@@ -354,18 +342,18 @@ class TestAgentEntityRunAgent:
# Send multiple messages
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-8a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
)
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-8b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
)
await entity.run_agent(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-8c"}
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
)
history = entity.state.conversation_history
history = entity.state.data.conversation_history
assert len(history) == 6
assert entity.state.message_count == 3
assert entity.state.message_count == 6
class TestAgentEntityReset:
@@ -376,40 +364,47 @@ class TestAgentEntityReset:
mock_agent = Mock()
entity = AgentEntity(mock_agent)
# Add some history
entity.state.conversation_history = [
ChatMessage(role="user", text="msg1"),
ChatMessage(role="assistant", text="resp1"),
# Add some history with proper DurableAgentStateEntry objects
entity.state.data.conversation_history = [
DurableAgentStateRequest(
correlation_id="test-1",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="msg1")],
)
],
),
]
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.conversation_history == []
assert entity.state.data.conversation_history == []
def test_reset_clears_last_response(self) -> None:
"""Test that reset clears the last response."""
def test_reset_with_extension_data(self) -> None:
"""Test that reset works when entity has extension data."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
entity.state.last_response = "Some response"
# Set up some initial state with conversation history
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.last_response is None
assert len(entity.state.data.conversation_history) == 0
def test_reset_clears_message_count(self) -> None:
"""Test that reset clears the message count."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
entity.state.message_count = 10
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
async def test_reset_after_conversation(self) -> None:
"""Test reset after a full conversation."""
@@ -421,23 +416,22 @@ class TestAgentEntityReset:
# Have a conversation
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-10a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
)
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-10b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
)
# Verify state before reset
assert entity.state.message_count == 2
assert len(entity.state.conversation_history) == 4
assert entity.state.message_count == 4
assert len(entity.state.data.conversation_history) == 4
# Reset
entity.reset(mock_context)
# Verify state after reset
assert entity.state.message_count == 0
assert len(entity.state.conversation_history) == 0
assert entity.state.last_response is None
assert len(entity.state.data.conversation_history) == 0
class TestCreateAgentEntity:
@@ -464,7 +458,7 @@ class TestCreateAgentEntity:
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlation_id": "corr-entity-factory",
"correlationId": "corr-entity-factory",
}
mock_context.get_state.return_value = None
@@ -485,13 +479,22 @@ class TestCreateAgentEntity:
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = {
"message_count": 5,
"conversation_history": [
ChatMessage(
role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
).to_dict()
],
"last_response": "Test",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "test-correlation-id",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [{"$type": "text", "text": "test"}],
}
],
}
]
},
}
# Execute
@@ -505,9 +508,7 @@ class TestCreateAgentEntity:
# Verify state was cleared
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["message_count"] == 0
assert state["conversation_history"] == []
assert state["last_response"] is None
assert state["data"]["conversationHistory"] == []
def test_entity_function_handles_unknown_operation(self) -> None:
"""Test that the entity function handles unknown operations."""
@@ -547,8 +548,7 @@ class TestCreateAgentEntity:
assert result["status"] == "reset"
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["message_count"] == 0
assert state["conversation_history"] == []
assert state["data"] == {"conversationHistory": []}
def test_entity_function_restores_existing_state(self) -> None:
"""Test that the entity function restores existing state."""
@@ -557,26 +557,53 @@ class TestCreateAgentEntity:
entity_function = create_agent_entity(mock_agent)
existing_state = {
"message_count": 5,
"conversation_history": [
ChatMessage(
role="user", text="msg1", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
).to_dict(),
ChatMessage(
role="assistant", text="resp1", additional_properties={"timestamp": "2024-01-01T00:05:00Z"}
).to_dict(),
],
"last_response": "resp1",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "msg1",
}
],
}
],
},
{
"$type": "response",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:05:00Z",
"messages": [
{
"role": "assistant",
"contents": [
{
"$type": "text",
"text": "resp1",
}
],
}
],
},
],
},
}
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(AgentState, "restore_state") as restore_state_mock:
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
entity_function(mock_context)
restore_state_mock.assert_called_once_with(existing_state)
from_dict_mock.assert_called_once_with(existing_state)
class TestErrorHandling:
@@ -591,7 +618,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-1"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
)
assert result["status"] == "error"
@@ -608,7 +635,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-2"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
)
assert result["status"] == "error"
@@ -624,7 +651,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-3"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
)
assert result["status"] == "error"
@@ -659,7 +686,7 @@ class TestErrorHandling:
result = await entity.run_agent(
mock_context,
{"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-error-4"},
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
)
# Even on error, message info should be preserved
@@ -680,15 +707,15 @@ class TestConversationHistory:
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-history-1"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
)
# Check both user and assistant messages have timestamps
for entry in entity.state.conversation_history:
timestamp = entry.additional_properties.get("timestamp")
for entry in entity.state.data.conversation_history:
timestamp = entry.created_at
assert timestamp is not None
# Verify timestamp is in ISO format
datetime.fromisoformat(timestamp)
datetime.fromisoformat(str(timestamp))
async def test_conversation_history_ordering(self) -> None:
"""Test that conversation history maintains the correct order."""
@@ -701,29 +728,30 @@ class TestConversationHistory:
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
await entity.run_agent(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2a"},
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2b"},
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
await entity.run_agent(
mock_context,
{"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2c"},
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
)
# Verify order
history = entity.state.conversation_history
assert history[0].text == "Message 1"
assert history[1].text == "Response 1"
assert history[2].text == "Message 2"
assert history[3].text == "Response 2"
assert history[4].text == "Message 3"
assert history[5].text == "Response 3"
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].text == "Message 1" # Request 1
assert history[1].messages[0].text == "Response 1" # Response 1
assert history[2].messages[0].text == "Message 2" # Request 2
assert history[3].messages[0].text == "Response 2" # Response 2
assert history[4].messages[0].text == "Message 3" # Request 3
assert history[5].messages[0].text == "Response 3" # Response 3
async def test_conversation_history_role_alternation(self) -> None:
"""Test that conversation history alternates between user and assistant roles."""
@@ -735,19 +763,20 @@ class TestConversationHistory:
await entity.run_agent(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3a"},
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
)
await entity.run_agent(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3b"},
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
)
# Check role alternation
history = entity.state.conversation_history
assert _role_value(history[0]) == "user"
assert _role_value(history[1]) == "assistant"
assert _role_value(history[2]) == "user"
assert _role_value(history[3]) == "assistant"
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].role == "user" # Request 1
assert history[1].messages[0].role == "assistant" # Response 1
assert history[2].messages[0].role == "user" # Request 2
assert history[3].messages[0].role == "assistant" # Response 2
class TestRunRequestSupport:
@@ -789,7 +818,7 @@ class TestRunRequestSupport:
"thread_id": "conv-456",
"role": "system",
"enable_tool_calls": False,
"correlation_id": "corr-runreq-2",
"correlationId": "corr-runreq-2",
}
result = await entity.run_agent(mock_context, request_dict)
@@ -828,9 +857,9 @@ class TestRunRequestSupport:
await entity.run_agent(mock_context, request)
# Check that system role was stored
history = entity.state.conversation_history
assert _role_value(history[0]) == "system"
assert history[0].text == "System message"
history = entity.state.data.conversation_history
assert history[0].messages[0].role == "system"
assert history[0].messages[0].text == "System message"
async def test_run_agent_with_response_format(self) -> None:
"""Test run_agent with a JSON response format."""
@@ -887,7 +916,7 @@ class TestRunRequestSupport:
"thread_id": "conv-789",
"role": "user",
"enable_tool_calls": True,
"correlation_id": "corr-runreq-6",
"correlationId": "corr-runreq-6",
}
mock_context.get_state.return_value = None
@@ -295,32 +295,32 @@ class TestRunRequest:
restored = RunRequest.from_dict(data)
assert restored.response_format is ModuleStructuredResponse
def test_init_with_correlation_id(self) -> None:
"""Test RunRequest initialization with correlation_id."""
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_correlation_id(self) -> None:
"""Test to_dict includes correlation_id."""
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["correlation_id"] == "corr-456"
assert data["correlationId"] == "corr-456"
def test_from_dict_with_correlation_id(self) -> None:
"""Test from_dict with correlation_id."""
data = {"message": "Test", "correlation_id": "corr-789", "thread_id": "thread-corr-from-dict"}
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_correlation_id(self) -> None:
"""Test round-trip to_dict and from_dict with correlation_id."""
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",
@@ -136,8 +136,8 @@ class TestDurableAIAgent:
assert operation == "run_agent"
assert request["message"] == "Test message"
assert request["enable_tool_calls"] is True
assert "correlation_id" in request
assert request["correlation_id"] == "correlation-guid"
assert "correlationId" in request
assert request["correlationId"] == "correlation-guid"
assert "thread_id" in request
assert request["thread_id"] == "thread-guid"
@@ -145,7 +145,7 @@ class TestDurableAIAgent:
"""Test that run() works without explicit thread (creates unique session key)."""
mock_context = Mock()
mock_context.instance_id = "test-instance-002"
# Two calls to new_uuid: one for session_key, one for correlation_id
# Two calls to new_uuid: one for session_key, one for correlationId
mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"])
mock_task = Mock()
@@ -164,7 +164,7 @@ class TestDurableAIAgent:
entity_id = call_args[0][0]
assert entity_id.name == "dafx-TestAgent"
assert entity_id.key == "auto-generated-guid"
# Should be called twice: once for session_key, once for correlation_id
# Should be called twice: once for session_key, once for correlationId
assert mock_context.new_uuid.call_count == 2
def test_run_with_response_format(self) -> None:
@@ -307,8 +307,8 @@ class TestOrchestrationIntegration:
mock_context.instance_id = "test-orchestration-001"
# new_uuid will be called 3 times:
# 1. thread creation
# 2. correlation_id for first call
# 3. correlation_id for second call
# 2. correlationId for first call
# 3. correlationId for second call
mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"])
# Track entity calls
@@ -1,110 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for AgentState correlation ID tracking."""
from unittest.mock import Mock
import pytest
from agent_framework import AgentRunResponse
from agent_framework_azurefunctions._state import AgentState
class TestAgentStateCorrelationId:
"""Test suite for AgentState correlation ID tracking."""
def _create_mock_response(self, text: str = "Response") -> Mock:
"""Create a mock AgentRunResponse with the provided text."""
mock_response = Mock(spec=AgentRunResponse)
mock_response.to_dict.return_value = {"text": text, "messages": []}
return mock_response
def test_add_assistant_message_with_correlation_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-123-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
message_metadata = state.conversation_history[-1].additional_properties or {}
assert message_metadata.get("correlation_id") == "corr-123"
response_data = state.try_get_agent_response("corr-123")
assert response_data is not None
assert response_data["content"] == "Response"
assert response_data["agent_response"] == {"text": "Response", "messages": []}
def test_try_get_agent_response_returns_response(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-200-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-456")
response_data = state.try_get_agent_response("corr-456")
assert response_data is not None
assert response_data["content"] == "Response"
def test_try_get_agent_response_returns_none_for_missing_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-300-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
assert state.try_get_agent_response("non-existent") is None
def test_multiple_responses_tracked_separately(self) -> None:
state = AgentState()
for index in range(3):
state.add_user_message(f"Message {index}", correlation_id=f"corr-{index}-request")
state.add_assistant_message(
f"Response {index}",
self._create_mock_response(text=f"Response {index}"),
correlation_id=f"corr-{index}",
)
for index in range(3):
payload = state.try_get_agent_response(f"corr-{index}")
assert payload is not None
assert payload["content"] == f"Response {index}"
def test_add_assistant_message_without_correlation_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-400-request")
state.add_assistant_message("Response", self._create_mock_response())
assert state.try_get_agent_response("missing") is None
assert state.last_response == "Response"
def test_to_dict_does_not_duplicate_agent_responses(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-500-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
state_snapshot = state.to_dict()
assert "agent_responses" not in state_snapshot
metadata = state_snapshot["conversation_history"][-1]["additional_properties"]
assert metadata["correlation_id"] == "corr-123"
def test_restore_state_preserves_agent_response_lookup(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-600-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
restored_state = AgentState()
restored_state.restore_state(state.to_dict())
payload = restored_state.try_get_agent_response("corr-123")
assert payload is not None
assert payload["content"] == "Response"
def test_reset_clears_conversation_history(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-700-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
state.reset()
assert len(state.conversation_history) == 0
assert state.try_get_agent_response("corr-123") is None
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
+18 -1
View File
@@ -237,6 +237,11 @@ dependencies = [
{ name = "azure-functions-durable", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.dev-dependencies]
dev = [
{ name = "types-python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
@@ -244,6 +249,9 @@ requires-dist = [
{ name = "azure-functions-durable" },
]
[package.metadata.requires-dev]
dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }]
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b251114"
@@ -1749,7 +1757,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -6168,6 +6176,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" },
]
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20251115"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/36/06d01fb52c0d57e9ad0c237654990920fa41195e4b3d640830dabf9eeb2f/types_python_dateutil-2.9.0.20251115.tar.gz", hash = "sha256:8a47f2c3920f52a994056b8786309b43143faa5a64d4cbb2722d6addabdf1a58", size = 16363, upload-time = "2025-11-15T03:00:13.717Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/0b/56961d3ba517ed0df9b3a27bfda6514f3d01b28d499d1bce9068cfe4edd1/types_python_dateutil-2.9.0.20251115-py3-none-any.whl", hash = "sha256:9cf9c1c582019753b8639a081deefd7e044b9fa36bd8217f565c6c4e36ee0624", size = 18251, upload-time = "2025-11-15T03:00:12.317Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"