Skip to content

Commit 6ae32f0

Browse files
gavin-aguiarlarohradmytrostrukcgillumCopilot
authored
[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>
1 parent 039e49f commit 6ae32f0

File tree

15 files changed

+1698
-561
lines changed

15 files changed

+1698
-561
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ agents.md
204204
# AI
205205
.claude/
206206
WARP.md
207+
**/memory-bank/
208+
**/projectBrief.md
207209

208210
# Azurite storage emulator files
209211
*/__azurite_db_blob__.json

python/packages/azurefunctions/agent_framework_azurefunctions/_app.py

Lines changed: 93 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,29 @@
1616
from agent_framework import AgentProtocol, get_logger
1717

1818
from ._callbacks import AgentResponseCallbackProtocol
19+
from ._constants import (
20+
DEFAULT_MAX_POLL_RETRIES,
21+
DEFAULT_POLL_INTERVAL_SECONDS,
22+
MIMETYPE_APPLICATION_JSON,
23+
MIMETYPE_TEXT_PLAIN,
24+
REQUEST_RESPONSE_FORMAT_JSON,
25+
REQUEST_RESPONSE_FORMAT_TEXT,
26+
THREAD_ID_FIELD,
27+
THREAD_ID_HEADER,
28+
WAIT_FOR_RESPONSE_FIELD,
29+
WAIT_FOR_RESPONSE_HEADER,
30+
)
31+
from ._durable_agent_state import DurableAgentState
1932
from ._entities import create_agent_entity
2033
from ._errors import IncomingRequestError
2134
from ._models import AgentSessionId, RunRequest
2235
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
23-
from ._state import AgentState
2436

2537
logger = get_logger("agent_framework.azurefunctions")
2638

27-
THREAD_ID_FIELD: str = "thread_id"
28-
RESPONSE_FORMAT_JSON: str = "json"
29-
RESPONSE_FORMAT_TEXT: str = "text"
30-
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
31-
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
32-
33-
3439
EntityHandler = Callable[[df.DurableEntityContext], None]
3540
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
3641

37-
DEFAULT_MAX_POLL_RETRIES: int = 30
38-
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
39-
4042
if TYPE_CHECKING:
4143

4244
class DFAppBase:
@@ -317,11 +319,11 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
317319
"""
318320
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
319321

320-
response_format: str = RESPONSE_FORMAT_JSON
322+
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
321323
thread_id: str | None = None
322324

323325
try:
324-
req_body, message, response_format = self._parse_incoming_request(req)
326+
req_body, message, request_response_format = self._parse_incoming_request(req)
325327
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
326328
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
327329

@@ -334,7 +336,7 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
334336
return self._create_http_response(
335337
payload={"error": "Message is required"},
336338
status_code=400,
337-
response_format=response_format,
339+
request_response_format=request_response_format,
338340
thread_id=thread_id,
339341
)
340342

@@ -351,6 +353,7 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
351353
message,
352354
thread_id,
353355
correlation_id,
356+
request_response_format,
354357
)
355358
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
356359
await client.signal_entity(entity_instance_id, "run_agent", run_request)
@@ -370,7 +373,7 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
370373
return self._create_http_response(
371374
payload=result,
372375
status_code=200 if result.get("status") == "success" else 500,
373-
response_format=response_format,
376+
request_response_format=request_response_format,
374377
thread_id=thread_id,
375378
)
376379

@@ -383,7 +386,7 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
383386
return self._create_http_response(
384387
payload=accepted_response,
385388
status_code=202,
386-
response_format=response_format,
389+
request_response_format=request_response_format,
387390
thread_id=thread_id,
388391
)
389392

@@ -392,23 +395,23 @@ async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClien
392395
return self._create_http_response(
393396
payload={"error": str(exc)},
394397
status_code=exc.status_code,
395-
response_format=response_format,
398+
request_response_format=request_response_format,
396399
thread_id=thread_id,
397400
)
398401
except ValueError as exc:
399402
logger.error(f"[HTTP Trigger] Invalid JSON: {exc!s}")
400403
return self._create_http_response(
401404
payload={"error": "Invalid JSON"},
402405
status_code=400,
403-
response_format=response_format,
406+
request_response_format=request_response_format,
404407
thread_id=thread_id,
405408
)
406409
except Exception as exc:
407410
logger.error(f"[HTTP Trigger] Error: {exc!s}", exc_info=True)
408411
return self._create_http_response(
409412
payload={"error": str(exc)},
410413
status_code=500,
411-
response_format=response_format,
414+
request_response_format=request_response_format,
412415
thread_id=thread_id,
413416
)
414417

@@ -466,7 +469,7 @@ def health_check(req: func.HttpRequest) -> func.HttpResponse:
466469
return func.HttpResponse(
467470
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
468471
status_code=200,
469-
mimetype="application/json",
472+
mimetype=MIMETYPE_APPLICATION_JSON,
470473
)
471474

472475
_ = health_check
@@ -491,7 +494,7 @@ async def _read_cached_state(
491494
self,
492495
client: df.DurableOrchestrationClient,
493496
entity_instance_id: df.EntityId,
494-
) -> AgentState | None:
497+
) -> DurableAgentState | None:
495498
state_response = await client.read_entity_state(entity_instance_id)
496499
if not state_response or not state_response.entity_exists:
497500
return None
@@ -502,9 +505,7 @@ async def _read_cached_state(
502505

503506
typed_state_payload = cast(dict[str, Any], state_payload)
504507

505-
agent_state = AgentState()
506-
agent_state.restore_state(typed_state_payload)
507-
return agent_state
508+
return DurableAgentState.from_dict(typed_state_payload)
508509

509510
async def _get_response_from_entity(
510511
self,
@@ -580,31 +581,58 @@ async def _poll_entity_for_response(
580581

581582
return result
582583

583-
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
584-
"""Create the timeout response."""
585-
return {
586-
"response": "Agent is still processing or timed out...",
584+
def _build_response_payload(
585+
self,
586+
*,
587+
response: str | None,
588+
message: str,
589+
thread_id: str,
590+
status: str,
591+
correlation_id: str,
592+
extra_fields: dict[str, Any] | None = None,
593+
) -> dict[str, Any]:
594+
"""Create a consistent response structure and allow optional extra fields."""
595+
payload = {
596+
"response": response,
587597
"message": message,
588598
THREAD_ID_FIELD: thread_id,
589-
"status": "timeout",
599+
"status": status,
590600
"correlation_id": correlation_id,
591601
}
602+
if extra_fields:
603+
payload.update(extra_fields)
604+
return payload
605+
606+
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
607+
"""Create the timeout response."""
608+
return self._build_response_payload(
609+
response="Agent is still processing or timed out...",
610+
message=message,
611+
thread_id=thread_id,
612+
status="timeout",
613+
correlation_id=correlation_id,
614+
)
592615

593616
def _build_success_result(
594-
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
617+
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
595618
) -> dict[str, Any]:
596619
"""Build the success result returned to the HTTP caller."""
597-
return {
598-
"response": response_data.get("content"),
599-
"message": message,
600-
THREAD_ID_FIELD: thread_id,
601-
"status": "success",
602-
"message_count": response_data.get("message_count", state.message_count),
603-
"correlation_id": correlation_id,
604-
}
620+
return self._build_response_payload(
621+
response=response_data.get("content"),
622+
message=message,
623+
thread_id=thread_id,
624+
status="success",
625+
correlation_id=correlation_id,
626+
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
627+
)
605628

606629
def _build_request_data(
607-
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
630+
self,
631+
req_body: dict[str, Any],
632+
message: str,
633+
thread_id: str,
634+
correlation_id: str,
635+
request_response_format: str,
608636
) -> dict[str, Any]:
609637
"""Create the durable entity request payload."""
610638
enable_tool_calls_value = req_body.get("enable_tool_calls")
@@ -613,6 +641,7 @@ def _build_request_data(
613641
return RunRequest(
614642
message=message,
615643
role=req_body.get("role"),
644+
request_response_format=request_response_format,
616645
response_format=req_body.get("response_format"),
617646
enable_tool_calls=enable_tool_calls,
618647
thread_id=thread_id,
@@ -621,23 +650,23 @@ def _build_request_data(
621650

622651
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
623652
"""Build the response returned when not waiting for completion."""
624-
return {
625-
"response": "Agent request accepted",
626-
"message": message,
627-
THREAD_ID_FIELD: thread_id,
628-
"status": "accepted",
629-
"correlation_id": correlation_id,
630-
}
653+
return self._build_response_payload(
654+
response="Agent request accepted",
655+
message=message,
656+
thread_id=thread_id,
657+
status="accepted",
658+
correlation_id=correlation_id,
659+
)
631660

632661
def _create_http_response(
633662
self,
634663
payload: dict[str, Any] | str,
635664
status_code: int,
636-
response_format: str,
665+
request_response_format: str,
637666
thread_id: str | None,
638667
) -> func.HttpResponse:
639668
"""Create the HTTP response using helper serializers for clarity."""
640-
if response_format == RESPONSE_FORMAT_TEXT:
669+
if request_response_format == REQUEST_RESPONSE_FORMAT_TEXT:
641670
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
642671

643672
return self._build_json_response(payload=payload, status_code=status_code)
@@ -650,13 +679,13 @@ def _build_plain_text_response(
650679
) -> func.HttpResponse:
651680
"""Return a plain-text response with optional thread identifier header."""
652681
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
653-
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
654-
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
682+
headers = {THREAD_ID_HEADER: thread_id} if thread_id is not None else None
683+
return func.HttpResponse(body_text, status_code=status_code, mimetype=MIMETYPE_TEXT_PLAIN, headers=headers)
655684

656685
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
657686
"""Return the JSON response, serializing dictionaries as needed."""
658687
body_json = payload if isinstance(payload, str) else json.dumps(payload)
659-
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
688+
return func.HttpResponse(body_json, status_code=status_code, mimetype=MIMETYPE_APPLICATION_JSON)
660689

661690
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
662691
"""Convert a structured payload into a human-readable text response."""
@@ -702,18 +731,19 @@ def _parse_incoming_request(self, req: func.HttpRequest) -> tuple[dict[str, Any]
702731
normalized_content_type = self._extract_content_type(headers)
703732
body_parser, body_format = self._select_body_parser(normalized_content_type)
704733
prefers_json = self._accepts_json_response(headers)
705-
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
734+
request_response_format = self._select_request_response_format(
735+
body_format=body_format, prefers_json=prefers_json
736+
)
706737

707738
req_body, message = body_parser(req)
708-
return req_body, message, response_format
739+
return req_body, message, request_response_format
709740

710741
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
711742
"""Create a lowercase header mapping from the incoming request."""
712743
headers: dict[str, str] = {}
713744
raw_headers = req.headers
714745
if isinstance(raw_headers, Mapping):
715-
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
716-
for key, value in header_mapping.items():
746+
for key, value in raw_headers.items():
717747
if value is not None:
718748
headers[str(key).lower()] = str(value)
719749
return headers
@@ -729,9 +759,9 @@ def _select_body_parser(
729759
normalized_content_type: str,
730760
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
731761
"""Choose the body parser and declared body format."""
732-
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
733-
return self._parse_json_body, RESPONSE_FORMAT_JSON
734-
return self._parse_text_body, RESPONSE_FORMAT_TEXT
762+
if normalized_content_type in {MIMETYPE_APPLICATION_JSON} or normalized_content_type.endswith("+json"):
763+
return self._parse_json_body, REQUEST_RESPONSE_FORMAT_JSON
764+
return self._parse_text_body, REQUEST_RESPONSE_FORMAT_TEXT
735765

736766
@staticmethod
737767
def _accepts_json_response(headers: dict[str, str]) -> bool:
@@ -742,16 +772,16 @@ def _accepts_json_response(headers: dict[str, str]) -> bool:
742772

743773
for value in accept_header.split(","):
744774
media_type = value.split(";")[0].strip().lower()
745-
if media_type == "application/json":
775+
if media_type == MIMETYPE_APPLICATION_JSON:
746776
return True
747777
return False
748778

749779
@staticmethod
750-
def _select_response_format(body_format: str, prefers_json: bool) -> str:
780+
def _select_request_response_format(body_format: str, prefers_json: bool) -> str:
751781
"""Combine body format and accept preference to determine response format."""
752-
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
753-
return RESPONSE_FORMAT_JSON
754-
return RESPONSE_FORMAT_TEXT
782+
if body_format == REQUEST_RESPONSE_FORMAT_JSON or prefers_json:
783+
return REQUEST_RESPONSE_FORMAT_JSON
784+
return REQUEST_RESPONSE_FORMAT_TEXT
755785

756786
@staticmethod
757787
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Constants for Azure Functions Agent Framework integration."""
4+
5+
# Supported request/response formats and MIME types
6+
REQUEST_RESPONSE_FORMAT_JSON: str = "json"
7+
REQUEST_RESPONSE_FORMAT_TEXT: str = "text"
8+
MIMETYPE_APPLICATION_JSON: str = "application/json"
9+
MIMETYPE_TEXT_PLAIN: str = "text/plain"
10+
11+
# Field and header names
12+
THREAD_ID_FIELD: str = "thread_id"
13+
THREAD_ID_HEADER: str = "x-ms-thread-id"
14+
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
15+
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
16+
17+
# Polling configuration
18+
DEFAULT_MAX_POLL_RETRIES: int = 30
19+
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0

0 commit comments

Comments
 (0)