mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: (samples): adopt AzureOpenAIResponsesClient, reorganize orchestration examples, and fix workflow/orchestration bugs (#3873)
* adopt AzureOpenAIResponsesClient, reorganize orchestration examples, and fix workflow/orchestration bugs * Updates * add comment
This commit is contained in:
committed by
GitHub
Unverified
parent
8457533c69
commit
1b10b051fd
@@ -21,6 +21,7 @@ existing observability and streaming semantics continue to apply.
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
@@ -393,6 +394,76 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
|
||||
)
|
||||
self._increment_round()
|
||||
|
||||
@staticmethod
|
||||
def _parse_last_json_object(text: str) -> AgentOrchestrationOutput | None:
|
||||
"""Best-effort parser for concatenated JSON and return the last object.
|
||||
|
||||
Stop-gap workaround:
|
||||
In some runs, the orchestrator manager text can contain multiple JSON objects
|
||||
concatenated back-to-back (for example: `{...}{...}`), which causes
|
||||
`model_validate_json` to fail with trailing characters. Until the root cause
|
||||
is fully understood and fixed, decode sequential top-level JSON values and
|
||||
validate the last one.
|
||||
"""
|
||||
decoder = json.JSONDecoder()
|
||||
index = 0
|
||||
parsed: Any | None = None
|
||||
|
||||
while index < len(text):
|
||||
while index < len(text) and text[index].isspace():
|
||||
index += 1
|
||||
if index >= len(text):
|
||||
break
|
||||
parsed, index = decoder.raw_decode(text, index)
|
||||
|
||||
if parsed is None:
|
||||
return None
|
||||
return AgentOrchestrationOutput.model_validate(parsed)
|
||||
|
||||
@classmethod
|
||||
def _parse_agent_output(cls, agent_response: Any) -> AgentOrchestrationOutput:
|
||||
"""Parse manager output with defensive fallbacks.
|
||||
|
||||
Preferred path is structured output (`agent_response.value`) when available.
|
||||
If only text is available, first attempt strict JSON parsing and then apply a
|
||||
temporary concatenated-JSON fallback as a stop-gap.
|
||||
"""
|
||||
try:
|
||||
structured_value = agent_response.value
|
||||
except Exception:
|
||||
structured_value = None
|
||||
|
||||
if structured_value is not None:
|
||||
return AgentOrchestrationOutput.model_validate(structured_value)
|
||||
|
||||
text_candidates: list[str] = []
|
||||
for message in reversed(agent_response.messages):
|
||||
if message.role == "assistant" and message.text.strip():
|
||||
text_candidates.append(message.text.strip())
|
||||
break
|
||||
|
||||
response_text = agent_response.text.strip()
|
||||
if response_text and response_text not in text_candidates:
|
||||
text_candidates.append(response_text)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for candidate in text_candidates:
|
||||
try:
|
||||
return AgentOrchestrationOutput.model_validate_json(candidate)
|
||||
except Exception as ex:
|
||||
last_error = ex
|
||||
|
||||
try:
|
||||
# Stop-gap fallback for rare cases where multiple JSON objects are
|
||||
# returned in one text payload (concatenated with no separator).
|
||||
parsed = cls._parse_last_json_object(candidate)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
except Exception as ex:
|
||||
last_error = ex
|
||||
|
||||
raise ValueError("Failed to parse agent orchestration output.") from last_error
|
||||
|
||||
async def _invoke_agent(self) -> AgentOrchestrationOutput:
|
||||
"""Invoke the orchestrator agent to determine the next speaker and termination."""
|
||||
|
||||
@@ -404,7 +475,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
|
||||
options={"response_format": AgentOrchestrationOutput},
|
||||
)
|
||||
# Parse and validate the structured output
|
||||
agent_orchestration_output = AgentOrchestrationOutput.model_validate_json(agent_response.text)
|
||||
agent_orchestration_output = self._parse_agent_output(agent_response)
|
||||
|
||||
if not agent_orchestration_output.terminate and not agent_orchestration_output.next_speaker:
|
||||
raise ValueError("next_speaker must be provided if not terminating the conversation.")
|
||||
|
||||
@@ -121,6 +121,51 @@ class StubManagerAgent(Agent):
|
||||
)
|
||||
|
||||
|
||||
class ConcatenatedJsonManagerAgent(Agent):
|
||||
"""Manager agent that emits concatenated JSON in a single assistant message."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(client=MockChatClient(), name="concat_manager", description="Concatenated JSON manager")
|
||||
self._call_count = 0
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
if self._call_count == 0:
|
||||
self._call_count += 1
|
||||
return AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
text=(
|
||||
'{"terminate": false, "reason": "invalid candidate", '
|
||||
'"next_speaker": "unknown", "final_message": null} '
|
||||
'{"terminate": false, "reason": "pick known participant", '
|
||||
'"next_speaker": "agent", "final_message": null}'
|
||||
),
|
||||
author_name=self.name,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
text=(
|
||||
'{"terminate": true, "reason": "Task complete", '
|
||||
'"next_speaker": null, "final_message": "concatenated manager final"}'
|
||||
),
|
||||
author_name=self.name,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def make_sequence_selector() -> Callable[[GroupChatState], str]:
|
||||
state_counter = {"value": 0}
|
||||
|
||||
@@ -221,6 +266,29 @@ async def test_group_chat_as_agent_accepts_conversation() -> None:
|
||||
assert response.messages, "Expected agent conversation output"
|
||||
|
||||
|
||||
async def test_agent_manager_handles_concatenated_json_output() -> None:
|
||||
manager = ConcatenatedJsonManagerAgent()
|
||||
worker = StubAgent("agent", "worker response")
|
||||
|
||||
workflow = GroupChatBuilder(
|
||||
participants=[worker],
|
||||
orchestrator_agent=manager,
|
||||
).build()
|
||||
|
||||
outputs: list[list[Message]] = []
|
||||
async for event in workflow.run("coordinate task", stream=True):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[Message], data))
|
||||
|
||||
assert outputs
|
||||
conversation = outputs[-1]
|
||||
assert any(msg.author_name == "agent" and msg.text == "worker response" for msg in conversation)
|
||||
assert conversation[-1].author_name == manager.name
|
||||
assert conversation[-1].text == "concatenated manager final"
|
||||
|
||||
|
||||
# Comprehensive tests for group chat functionality
|
||||
|
||||
|
||||
|
||||
@@ -366,6 +366,11 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
assert checkpoints
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
resume_checkpoint = checkpoints[-1]
|
||||
loaded_checkpoint = await storage.load(resume_checkpoint.checkpoint_id)
|
||||
assert loaded_checkpoint is not None
|
||||
# Regression check: checkpoints with pending request_info must include executor state.
|
||||
assert "_executor_state" in loaded_checkpoint.state
|
||||
assert "magentic_orchestrator" in loaded_checkpoint.state["_executor_state"]
|
||||
|
||||
manager2 = FakeManager()
|
||||
wf_resume = MagenticBuilder(
|
||||
@@ -378,7 +383,7 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
completed: WorkflowEvent | None = None
|
||||
req_event = None
|
||||
async for event in wf_resume.run(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
checkpoint_id=resume_checkpoint.checkpoint_id,
|
||||
stream=True,
|
||||
):
|
||||
if event.type == "request_info" and event.request_type is MagenticPlanReviewRequest:
|
||||
|
||||
Reference in New Issue
Block a user