mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[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:
committed by
GitHub
Unverified
parent
039e49f353
commit
6ae32f007d
@@ -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
|
||||
+1192
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
}
|
||||
Reference in New Issue
Block a user