Python: Complete durableagent package (#3058)

* Add worker and clients

* Clean code and refactor common code

* Implement sample

* Add sample

* Update readmes

* Fix tests

* Fix tests

* Update requirements

* Fix typo

* Address comments

* use response.text
This commit is contained in:
Laveesh Rohra
2026-01-07 13:53:21 -08:00
committed by GitHub
Unverified
parent a5b36dc379
commit e3eff65a6b
46 changed files with 4477 additions and 1644 deletions
@@ -10,6 +10,7 @@ Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_
import json
import logging
from collections.abc import Generator
from typing import Any
import azure.functions as func
@@ -44,7 +45,7 @@ app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True
# 4. Orchestration that runs the agent sequentially on a shared thread for chaining behaviour.
@app.orchestration_trigger(context_name="context")
def single_agent_orchestration(context: DurableOrchestrationContext):
def single_agent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]:
"""Run the writer agent twice on the same thread to mirror chaining behaviour."""
writer = app.get_agent(context, WRITER_AGENT_NAME)
@@ -116,12 +117,6 @@ async def get_orchestration_status(
)
status = await client.get_status(instance_id)
if status is None:
return func.HttpResponse(
body=json.dumps({"error": "Instance not found"}),
status_code=404,
mimetype="application/json",
)
response_data: dict[str, Any] = {
"instanceId": status.instance_id,
@@ -10,6 +10,7 @@ Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_
import json
import logging
from collections.abc import Generator
from typing import Any, cast
from agent_framework import AgentRunResponse
@@ -51,7 +52,7 @@ app.add_agent(agents[1])
# 4. Durable Functions orchestration that runs both agents in parallel.
@app.orchestration_trigger(context_name="context")
def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext):
def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, dict[str, str]]:
"""Fan out to two domain-specific agents and aggregate their responses."""
prompt = context.get_input()
@@ -137,12 +138,6 @@ async def get_orchestration_status(
)
status = await client.get_status(instance_id)
if status is None:
return func.HttpResponse(
body=json.dumps({"error": "Instance not found"}),
status_code=404,
mimetype="application/json",
)
response_data: dict[str, Any] = {
"instanceId": status.instance_id,
@@ -11,7 +11,7 @@ Functions host."""
import json
import logging
from collections.abc import Mapping
from collections.abc import Generator, Mapping
from typing import Any, cast
import azure.functions as func
@@ -74,7 +74,7 @@ def send_email(message: str) -> str:
# 4. Orchestration validates input, runs agents, and branches on spam results.
@app.orchestration_trigger(context_name="context")
def spam_detection_orchestration(context: DurableOrchestrationContext):
def spam_detection_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]:
payload_raw = context.get_input()
if not isinstance(payload_raw, Mapping):
raise ValueError("Email data is required")
@@ -105,7 +105,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext):
spam_result = cast(SpamDetectionResult, spam_result_raw.value)
if spam_result.is_spam:
result = yield context.call_activity("handle_spam_email", spam_result.reason)
result = yield context.call_activity("handle_spam_email", spam_result.reason) # type: ignore[misc]
return result
email_thread = email_agent.get_new_thread()
@@ -125,7 +125,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext):
email_result = cast(EmailResponse, email_result_raw.value)
result = yield context.call_activity("send_email", email_result.response)
result = yield context.call_activity("send_email", email_result.response) # type: ignore[misc]
return result
@@ -196,12 +196,6 @@ async def get_orchestration_status(
)
status = await client.get_status(instance_id)
if status is None:
return func.HttpResponse(
body=json.dumps({"error": "Instance not found"}),
status_code=404,
mimetype="application/json",
)
response_data: dict[str, Any] = {
"instanceId": status.instance_id,
@@ -10,7 +10,7 @@ either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running `func sta
import json
import logging
from collections.abc import Mapping
from collections.abc import Generator, Mapping
from datetime import timedelta
from typing import Any
@@ -62,7 +62,7 @@ app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True
# 3. Activities encapsulate external work for review notifications and publishing.
@app.activity_trigger(input_name="content")
def notify_user_for_approval(content: dict) -> None:
def notify_user_for_approval(content: Any) -> None:
model = GeneratedContent.model_validate(content)
logger.info("NOTIFICATION: Please review the following content for approval:")
logger.info("Title: %s", model.title or "(untitled)")
@@ -71,7 +71,7 @@ def notify_user_for_approval(content: dict) -> None:
@app.activity_trigger(input_name="content")
def publish_content(content: dict) -> None:
def publish_content(content: Any) -> None:
model = GeneratedContent.model_validate(content)
logger.info("PUBLISHING: Content has been published successfully:")
logger.info("Title: %s", model.title or "(untitled)")
@@ -80,7 +80,7 @@ def publish_content(content: dict) -> None:
# 4. Orchestration loops until the human approves, times out, or attempts are exhausted.
@app.orchestration_trigger(context_name="context")
def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
def content_generation_hitl_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, dict[str, str]]:
payload_raw = context.get_input()
if not isinstance(payload_raw, Mapping):
raise ValueError("Content generation input is required")
@@ -102,7 +102,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
)
content = initial_raw.value
logger.info("Type of content after extraction: %s", type(content))
logger.info("Type of content after extraction: %s", type(content)) # type: ignore[misc]
if content is None or not isinstance(content, GeneratedContent):
raise ValueError("Agent returned no content after extraction.")
@@ -114,7 +114,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
f"Requesting human feedback. Iteration #{attempt}. Timeout: {payload.approval_timeout_hours} hour(s)."
)
yield context.call_activity("notify_user_for_approval", content.model_dump())
yield context.call_activity("notify_user_for_approval", content.model_dump()) # type: ignore[misc]
approval_task = context.wait_for_external_event(HUMAN_APPROVAL_EVENT)
timeout_task = context.create_timer(
@@ -129,7 +129,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
if approval_payload.approved:
context.set_custom_status("Content approved by human reviewer. Publishing content...")
yield context.call_activity("publish_content", content.model_dump())
yield context.call_activity("publish_content", content.model_dump()) # type: ignore[misc]
context.set_custom_status(
f"Content published successfully at {context.current_utc_datetime:%Y-%m-%dT%H:%M:%S}"
)
@@ -287,7 +287,7 @@ async def get_orchestration_status(
)
# Check if status is None or if the instance doesn't exist (runtime_status is None)
if status is None or getattr(status, "runtime_status", None) is None:
if getattr(status, "runtime_status", None) is None:
return func.HttpResponse(
body=json.dumps({"error": "Instance not found."}),
status_code=404,