Python: [Breaking] Python: Respond with AgentRunResponse with serialized structured output (#2285)

* Respond with AgentRunResponse

* Fix response_Format type

* Address comments

* Fix tests

* Fix log

* Addressed comments

* Code cleanup

* Use AgentTask vs Generator

* Address comments

* use lazy logging

* fix mypy errors
This commit is contained in:
Laveesh Rohra
2025-11-26 10:44:28 -08:00
committed by GitHub
Unverified
parent 0f2c5e6cb8
commit 306c81aef8
12 changed files with 484 additions and 432 deletions
@@ -57,7 +57,7 @@ def single_agent_orchestration(context: DurableOrchestrationContext):
improved_prompt = (
"Improve this further while keeping it under 25 words: "
f"{initial.get('response', '').strip()}"
f"{initial.text}"
)
refined = yield writer.run(
@@ -65,7 +65,7 @@ def single_agent_orchestration(context: DurableOrchestrationContext):
thread=writer_thread,
)
return refined.get("response", "")
return refined.text
# 5. HTTP endpoint to kick off the orchestration and return the status query URI.
@@ -10,8 +10,9 @@ Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_
import json
import logging
from typing import Any
from typing import Any, cast
from agent_framework import AgentRunResponse
import azure.functions as func
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
@@ -63,14 +64,19 @@ def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext):
physicist_thread = physicist.get_new_thread()
chemist_thread = chemist.get_new_thread()
# Create tasks from agent.run() calls
physicist_task = physicist.run(messages=str(prompt), thread=physicist_thread)
chemist_task = chemist.run(messages=str(prompt), thread=chemist_thread)
results = yield context.task_all([physicist_task, chemist_task])
# Execute both tasks concurrently using task_all
task_results = yield context.task_all([physicist_task, chemist_task])
physicist_result = cast(AgentRunResponse, task_results[0])
chemist_result = cast(AgentRunResponse, task_results[1])
return {
"physicist": results[0].get("response", ""),
"chemist": results[1].get("response", ""),
"physicist": physicist_result.text,
"chemist": chemist_result.text,
}
@@ -102,7 +102,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext):
response_format=SpamDetectionResult,
)
spam_result = cast(SpamDetectionResult, _coerce_structured(spam_result_raw, SpamDetectionResult))
spam_result = cast(SpamDetectionResult, spam_result_raw.value)
if spam_result.is_spam:
result = yield context.call_activity("handle_spam_email", spam_result.reason)
@@ -123,7 +123,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext):
response_format=EmailResponse,
)
email_result = cast(EmailResponse, _coerce_structured(email_result_raw, EmailResponse))
email_result = cast(EmailResponse, email_result_raw.value)
result = yield context.call_activity("send_email", email_result.response)
return result
@@ -231,24 +231,6 @@ def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
return f"{base_url}/api/{route}/status/{instance_id}"
def _coerce_structured(result: Mapping[str, Any], model: type[BaseModel]) -> BaseModel:
structured = result.get("structured_response") if isinstance(result, Mapping) else None
if structured is not None:
return model.model_validate(structured)
response_text = result.get("response") if isinstance(result, Mapping) else None
if isinstance(response_text, str) and response_text.strip():
try:
parsed = json.loads(response_text)
if isinstance(parsed, Mapping):
return model.model_validate(parsed)
except json.JSONDecodeError:
logger.warning("[ConditionalOrchestration] Failed to parse agent JSON response; raising error.")
# If parsing failed, raise to surface the issue to the caller.
raise ValueError(f"Agent response could not be parsed as {model.__name__}.")
"""
Expected response from `POST /api/spamdetection/run`:
@@ -100,7 +100,12 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
thread=writer_thread,
response_format=GeneratedContent,
)
content = _coerce_generated_content(initial_raw)
content = initial_raw.value
logger.info("Type of content after extraction: %s", type(content))
if content is None or not isinstance(content, GeneratedContent):
raise ValueError("Agent returned no content after extraction.")
attempt = 0
while attempt < payload.max_review_attempts:
@@ -142,7 +147,12 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
thread=writer_thread,
response_format=GeneratedContent,
)
content = _coerce_generated_content(rewritten_raw)
rewritten_value = rewritten_raw.value
if rewritten_value is None or not isinstance(rewritten_value, GeneratedContent):
raise ValueError("Agent returned no content after rewrite.")
content = rewritten_value
else:
context.set_custom_status(
f"Human approval timed out after {payload.approval_timeout_hours} hour(s). Treating as rejection."
@@ -317,23 +327,6 @@ def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
return f"{base_url}/api/{route}/status/{instance_id}"
def _coerce_generated_content(result: Mapping[str, Any]) -> GeneratedContent:
structured = result.get("structured_response") if isinstance(result, Mapping) else None
if structured is not None:
return GeneratedContent.model_validate(structured)
response_text = result.get("response") if isinstance(result, Mapping) else None
if isinstance(response_text, str) and response_text.strip():
try:
parsed = json.loads(response_text)
if isinstance(parsed, Mapping):
return GeneratedContent.model_validate(parsed)
except json.JSONDecodeError:
logger.warning("[HITL] Failed to parse agent JSON response; falling back to defaults.")
raise ValueError("Agent response could not be parsed as GeneratedContent.")
def _parse_human_approval(raw: Any) -> HumanApproval:
if isinstance(raw, Mapping):
return HumanApproval.model_validate(raw)