agent-framework-orchestrations 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/PKG-INFO +14 -1
  2. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/README.md +13 -0
  3. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_group_chat.py +9 -2
  4. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_handoff.py +104 -20
  5. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_magentic.py +24 -9
  6. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_orchestration_request_info.py +3 -4
  7. agent_framework_orchestrations-1.3.0/agent_framework_orchestrations/_orchestrator_helpers.py +163 -0
  8. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/pyproject.toml +1 -1
  9. agent_framework_orchestrations-1.2.0/agent_framework_orchestrations/_orchestrator_helpers.py +0 -78
  10. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/LICENSE +0 -0
  11. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/__init__.py +0 -0
  12. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_base_group_chat_orchestrator.py +0 -0
  13. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_concurrent.py +0 -0
  14. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_feature_usage.py +0 -0
  15. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_orchestration_state.py +0 -0
  16. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_participant_output_config.py +0 -0
  17. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_sequential.py +0 -0
  18. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_workflow_builder.py +0 -0
  19. {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-framework-orchestrations
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Orchestration patterns for Microsoft Agent Framework. Includes SequentialBuilder, ConcurrentBuilder, HandoffBuilder, GroupChatBuilder, and MagenticBuilder.
5
5
  Author-email: Microsoft <af-support@microsoft.com>
6
6
  Requires-Python: >=3.10
@@ -75,6 +75,19 @@ workflow = (
75
75
  )
76
76
  ```
77
77
 
78
+ By default, each user response returns to the agent that requested it. To have the start agent re-evaluate every user
79
+ turn, disable return-to-previous routing:
80
+
81
+ ```python
82
+ workflow = (
83
+ HandoffBuilder()
84
+ .participants([triage, billing, support])
85
+ .with_start_agent(triage)
86
+ .enable_return_to_previous(False)
87
+ .build()
88
+ )
89
+ ```
90
+
78
91
  ### GroupChatBuilder
79
92
 
80
93
  Orchestrator-directed multi-agent conversations:
@@ -51,6 +51,19 @@ workflow = (
51
51
  )
52
52
  ```
53
53
 
54
+ By default, each user response returns to the agent that requested it. To have the start agent re-evaluate every user
55
+ turn, disable return-to-previous routing:
56
+
57
+ ```python
58
+ workflow = (
59
+ HandoffBuilder()
60
+ .participants([triage, billing, support])
61
+ .with_start_agent(triage)
62
+ .enable_return_to_previous(False)
63
+ .build()
64
+ )
65
+ ```
66
+
54
67
  ### GroupChatBuilder
55
68
 
56
69
  Orchestrator-directed multi-agent conversations:
@@ -52,7 +52,7 @@ from ._base_group_chat_orchestrator import (
52
52
  )
53
53
  from ._feature_usage import FeatureIndex
54
54
  from ._orchestration_request_info import AgentApprovalExecutor
55
- from ._orchestrator_helpers import clean_conversation_for_handoff
55
+ from ._orchestrator_helpers import clean_conversation_for_handoff, extract_markdown_fence_bodies
56
56
  from ._participant_output_config import (
57
57
  UNSET,
58
58
  _coalesce_output_from, # pyright: ignore[reportPrivateUsage]
@@ -450,7 +450,9 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
450
450
 
451
451
  Preferred path is structured output (`agent_response.value`) when available.
452
452
  If only text is available, first attempt strict JSON parsing and then apply a
453
- temporary concatenated-JSON fallback as a stop-gap.
453
+ temporary concatenated-JSON fallback as a stop-gap. Providers that ignore
454
+ `response_format` often wrap the JSON in a Markdown code fence, so the body of
455
+ the last fenced block is tried as well.
454
456
  """
455
457
  try:
456
458
  structured_value = agent_response.value
@@ -470,6 +472,11 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
470
472
  if response_text and response_text not in text_candidates:
471
473
  text_candidates.append(response_text)
472
474
 
475
+ for candidate in list(text_candidates):
476
+ fence_bodies = extract_markdown_fence_bodies(candidate)
477
+ if fence_bodies and fence_bodies[-1] not in text_candidates:
478
+ text_candidates.append(fence_bodies[-1])
479
+
473
480
  last_error: Exception | None = None
474
481
  for candidate in text_candidates:
475
482
  try:
@@ -38,7 +38,7 @@ from copy import deepcopy
38
38
  from dataclasses import dataclass
39
39
  from typing import Any, Literal, cast
40
40
 
41
- from agent_framework import Agent, AgentResponse, Message, SupportsAgentRun
41
+ from agent_framework import Agent, AgentResponse, ContextProvider, Message, SessionContext, SupportsAgentRun
42
42
  from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination
43
43
  from agent_framework._sessions import AgentSession
44
44
  from agent_framework._telemetry import mark_feature_used
@@ -136,6 +136,7 @@ class _AutoHandoffMiddleware(FunctionMiddleware):
136
136
  def __init__(self, handoffs: Sequence[HandoffConfiguration]) -> None:
137
137
  """Initialise middleware with the mapping from tool name to specialist id."""
138
138
  self._handoff_functions = {get_handoff_tool_name(handoff.target_id): handoff.target_id for handoff in handoffs}
139
+ self._invoked_handoffs: list[tuple[str, str]] = []
139
140
 
140
141
  async def process(
141
142
  self,
@@ -147,13 +148,43 @@ class _AutoHandoffMiddleware(FunctionMiddleware):
147
148
  await call_next()
148
149
  return
149
150
 
151
+ call_id = context.metadata.get("call_id")
152
+ if not isinstance(call_id, str) or not call_id:
153
+ raise RuntimeError("Generated handoff invocation is missing its function call ID.")
154
+ target_id = self._handoff_functions[context.function.name]
155
+ self._invoked_handoffs.append((call_id, target_id))
156
+
150
157
  # Short-circuit execution and provide deterministic response payload for the tool call.
151
158
  # Parse the result using the default parser to ensure in a form that can be passed directly to LLM APIs.
152
- context.result = FunctionTool.parse_result({
153
- HANDOFF_FUNCTION_RESULT_KEY: self._handoff_functions[context.function.name]
154
- })
159
+ context.result = FunctionTool.parse_result({HANDOFF_FUNCTION_RESULT_KEY: target_id})
155
160
  raise MiddlewareTermination(result=context.result)
156
161
 
162
+ def consume_invoked_handoffs(self) -> list[tuple[str, str]]:
163
+ """Consume handoffs that were intercepted since the previous completed response."""
164
+ invoked_handoffs = self._invoked_handoffs
165
+ self._invoked_handoffs = []
166
+ return invoked_handoffs
167
+
168
+
169
+ class _AutoHandoffContextProvider(ContextProvider):
170
+ """Add auto-handoff middleware after application context providers."""
171
+
172
+ def __init__(self, middleware: _AutoHandoffMiddleware) -> None:
173
+ """Initialize the provider with the generated handoff middleware."""
174
+ super().__init__("agent_framework.handoff")
175
+ self._middleware = middleware
176
+
177
+ async def before_run(
178
+ self,
179
+ *,
180
+ agent: SupportsAgentRun,
181
+ session: AgentSession,
182
+ context: SessionContext,
183
+ state: dict[str, Any],
184
+ ) -> None:
185
+ """Add handoff interception after previously configured provider middleware."""
186
+ context.extend_middleware(self.source_id, self._middleware)
187
+
157
188
 
158
189
  @dataclass
159
190
  class HandoffAgentUserRequest:
@@ -210,6 +241,7 @@ class HandoffAgentExecutor(AgentExecutor):
210
241
  *,
211
242
  agent_session: AgentSession | None = None,
212
243
  is_start_agent: bool = False,
244
+ user_response_target_id: str | None = None,
213
245
  termination_condition: TerminationCondition | None = None,
214
246
  autonomous_mode: bool = False,
215
247
  autonomous_mode_prompt: str | None = None,
@@ -223,6 +255,8 @@ class HandoffAgentExecutor(AgentExecutor):
223
255
  agent_session: Optional AgentSession that manages the agent's execution context
224
256
  is_start_agent: Whether this agent is the starting agent in the handoff workflow.
225
257
  There can only be one starting agent in a handoff workflow.
258
+ user_response_target_id: Optional executor ID that should handle user responses.
259
+ Defaults to this executor.
226
260
  termination_condition: Optional callable that determines when to terminate the workflow
227
261
  autonomous_mode: Whether the agent should operate involve external systems after
228
262
  a response that does not trigger a handoff or before the turn
@@ -233,12 +267,14 @@ class HandoffAgentExecutor(AgentExecutor):
233
267
  This will guide the agent in the absence of user input.
234
268
  autonomous_mode_turn_limit: Maximum number of autonomous turns before requesting user input.
235
269
  """
236
- cloned_agent = self._prepare_agent_with_handoffs(agent, handoffs)
270
+ self._auto_handoff_middleware = _AutoHandoffMiddleware(handoffs)
271
+ cloned_agent = self._prepare_agent_with_handoffs(agent, handoffs, self._auto_handoff_middleware)
237
272
  super().__init__(cloned_agent, session=agent_session)
238
273
 
239
274
  self._handoff_targets = {handoff.target_id for handoff in handoffs}
240
275
  self._termination_condition = termination_condition
241
276
  self._is_start_agent = is_start_agent
277
+ self._user_response_target_id = user_response_target_id
242
278
 
243
279
  # Autonomous mode members
244
280
  self._autonomous_mode = autonomous_mode
@@ -250,12 +286,14 @@ class HandoffAgentExecutor(AgentExecutor):
250
286
  self,
251
287
  agent: Agent,
252
288
  handoffs: Sequence[HandoffConfiguration],
289
+ auto_handoff_middleware: _AutoHandoffMiddleware,
253
290
  ) -> Agent:
254
291
  """Prepare an agent by adding handoff tools for the specified target agents.
255
292
 
256
293
  Args:
257
294
  agent: The ``Agent`` instance to prepare
258
295
  handoffs: Sequence of handoff configurations defining target agents
296
+ auto_handoff_middleware: Middleware that intercepts generated handoff tools
259
297
 
260
298
  Returns:
261
299
  A cloned ``Agent`` instance with handoff tools added
@@ -264,11 +302,14 @@ class HandoffAgentExecutor(AgentExecutor):
264
302
  cloned_agent = self._clone_chat_agent(agent)
265
303
  # Add handoff tools to the cloned agent
266
304
  self._apply_auto_tools(cloned_agent, handoffs)
267
- # Add middleware to handle handoff tool invocations
268
- middleware = _AutoHandoffMiddleware(handoffs)
269
- existing_middleware = list(cloned_agent.middleware or [])
270
- existing_middleware.append(middleware)
271
- cloned_agent.middleware = existing_middleware
305
+ if cloned_agent.context_providers:
306
+ # Provider middleware is added after agent middleware. Contribute the interceptor
307
+ # last so existing provider policies can inspect or block generated handoff calls.
308
+ cloned_agent.context_providers.append(_AutoHandoffContextProvider(auto_handoff_middleware))
309
+ else:
310
+ existing_middleware = list(cloned_agent.middleware or [])
311
+ existing_middleware.append(auto_handoff_middleware)
312
+ cloned_agent.middleware = existing_middleware
272
313
 
273
314
  return cloned_agent
274
315
 
@@ -473,8 +514,15 @@ class HandoffAgentExecutor(AgentExecutor):
473
514
  # Broadcast the user response to all other agents
474
515
  await self._broadcast_messages(response, ctx)
475
516
 
476
- # Append the user response messages to the cache
517
+ # Keep this executor synchronized even when another agent handles the response.
477
518
  self._cache.extend(response)
519
+ if self._user_response_target_id is not None and self._user_response_target_id != self.id:
520
+ await ctx.send_message(
521
+ AgentExecutorRequest(messages=[], should_respond=True),
522
+ target_id=self._user_response_target_id,
523
+ )
524
+ return
525
+
478
526
  await self._run_agent_and_emit(ctx)
479
527
 
480
528
  async def _broadcast_messages(
@@ -493,9 +541,10 @@ class HandoffAgentExecutor(AgentExecutor):
493
541
  def _is_handoff_requested(self, response: AgentResponse) -> tuple[str, Message] | None:
494
542
  """Determine if the agent response includes a handoff request.
495
543
 
496
- If a handoff tool is invoked, the middleware will short-circuit execution
497
- and provide a synthetic result that includes the target agent ID. The message
498
- that contains the function result will be the last message in the response.
544
+ If a generated handoff tool is invoked, the middleware will short-circuit execution
545
+ and provide a synthetic result that includes the target agent ID. The matching
546
+ function call must occur in the same response, and the message containing its
547
+ function result must be the last message in the response.
499
548
 
500
549
  Args:
501
550
  response: The AgentResponse to inspect for handoff requests
@@ -510,12 +559,18 @@ class HandoffAgentExecutor(AgentExecutor):
510
559
  messages. By returning the full message, we can ensure the agent's chat history remains valid with
511
560
  a function result for the handoff tool call.
512
561
  """
513
- if not response.messages:
562
+ invoked_handoffs = self._auto_handoff_middleware.consume_invoked_handoffs()
563
+ if not invoked_handoffs or not response.messages:
514
564
  return None
515
565
 
516
566
  last_message = response.messages[-1]
517
567
  for content in last_message.contents:
518
- if content.type == "function_result":
568
+ if content.type == "function_result" and content.call_id:
569
+ matching_handoffs = [target_id for call_id, target_id in invoked_handoffs if call_id == content.call_id]
570
+ if len(matching_handoffs) != 1:
571
+ continue
572
+ handoff_target = matching_handoffs[0]
573
+
519
574
  payload = content.result
520
575
  parsed_payload: dict[str, Any] | None = None
521
576
  if isinstance(payload, Mapping):
@@ -529,11 +584,9 @@ class HandoffAgentExecutor(AgentExecutor):
529
584
  parsed_payload = {key: value for key, value in maybe_payload.items() if isinstance(key, str)} # pyright: ignore[reportUnknownVariableType]
530
585
 
531
586
  if parsed_payload:
532
- handoff_target = parsed_payload.get(HANDOFF_FUNCTION_RESULT_KEY)
533
- if isinstance(handoff_target, str):
587
+ payload_target = parsed_payload.get(HANDOFF_FUNCTION_RESULT_KEY)
588
+ if payload_target == handoff_target:
534
589
  return handoff_target, last_message
535
- else:
536
- continue
537
590
 
538
591
  return None
539
592
 
@@ -581,6 +634,8 @@ class HandoffBuilder:
581
634
  Agents can hand off to other agents using `.add_handoff()`. This provides a decentralized
582
635
  approach to multi-agent collaboration. Handoffs can be configured using `.add_handoff`. If
583
636
  none are specified, all agents can hand off to all others by default (making a mesh topology).
637
+ By default, user responses return to the agent that requested them. Use
638
+ `.enable_return_to_previous(False)` to route user responses through the start agent instead.
584
639
 
585
640
  Participants must be ``Agent`` instances. ``SupportsAgentRun`` protocol implementors that
586
641
  are not ``Agent`` subclasses are not supported because handoff workflows require cloning,
@@ -648,6 +703,7 @@ class HandoffBuilder:
648
703
 
649
704
  # Handoff related members
650
705
  self._handoff_config: dict[str, set[HandoffConfiguration]] = {}
706
+ self._return_to_previous: bool = True
651
707
 
652
708
  # Checkpoint related members
653
709
  self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
@@ -822,6 +878,33 @@ class HandoffBuilder:
822
878
 
823
879
  return self
824
880
 
881
+ def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder":
882
+ """Configure whether user responses return to the agent that requested them.
883
+
884
+ Return-to-previous routing is enabled by default. Disable it to route each user response
885
+ through the configured start agent so that agent can re-evaluate the conversation before
886
+ handing off again.
887
+
888
+ Args:
889
+ enabled: Whether user responses should return to the agent that requested them.
890
+
891
+ Returns:
892
+ Self for method chaining.
893
+
894
+ Example:
895
+
896
+ .. code-block:: python
897
+
898
+ workflow = (
899
+ HandoffBuilder(participants=[triage, billing, support])
900
+ .with_start_agent(triage)
901
+ .enable_return_to_previous(False)
902
+ .build()
903
+ )
904
+ """
905
+ self._return_to_previous = enabled
906
+ return self
907
+
825
908
  def with_autonomous_mode(
826
909
  self,
827
910
  *,
@@ -1113,6 +1196,7 @@ class HandoffBuilder:
1113
1196
  agent=agent,
1114
1197
  handoffs=handoffs.get(resolved_id, []),
1115
1198
  is_start_agent=(id == self._start_id),
1199
+ user_response_target_id=None if self._return_to_previous else self._start_id,
1116
1200
  termination_condition=self._termination_condition,
1117
1201
  autonomous_mode=autonomous_mode,
1118
1202
  autonomous_mode_prompt=self._autonomous_mode_prompts.get(id, None),
@@ -4,12 +4,12 @@ import asyncio
4
4
  import contextlib
5
5
  import json
6
6
  import logging
7
- import re
8
7
  import sys
9
8
  from abc import ABC, abstractmethod
10
9
  from collections.abc import Callable, Sequence
11
10
  from dataclasses import dataclass, field
12
11
  from enum import Enum
12
+ from functools import partial
13
13
  from typing import Any, ClassVar, Literal, TypeVar, cast
14
14
 
15
15
  from agent_framework import (
@@ -38,6 +38,7 @@ from ._base_group_chat_orchestrator import (
38
38
  ParticipantRegistry,
39
39
  )
40
40
  from ._feature_usage import FeatureIndex
41
+ from ._orchestrator_helpers import extract_markdown_fence_bodies
41
42
  from ._participant_output_config import (
42
43
  UNSET,
43
44
  _coalesce_output_from, # pyright: ignore[reportPrivateUsage]
@@ -413,9 +414,12 @@ def _extract_json(text: str) -> dict[str, Any]:
413
414
 
414
415
  The `text` method is concatenating multiple text contents from diff msgs into a single string.
415
416
  """
416
- fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text, flags=re.IGNORECASE)
417
- if fence:
418
- candidate = fence.group(1)
417
+ fenced_object = next(
418
+ (body for body in extract_markdown_fence_bodies(text) if body.startswith("{") and body.endswith("}")),
419
+ None,
420
+ )
421
+ if fenced_object is not None:
422
+ candidate = fenced_object
419
423
  else:
420
424
  # Find first balanced JSON object
421
425
  start = text.find("{")
@@ -1435,9 +1439,14 @@ class MagenticBuilder:
1435
1439
  name: Optional workflow identifier. Defaults to ``"Magentic"``.
1436
1440
  participants: Sequence of agent or executor instances for the workflow.
1437
1441
  manager: Pre-configured manager instance (subclass of MagenticManagerBase).
1438
- manager_factory: Callable that returns a new MagenticManagerBase instance.
1439
- manager_agent: Agent instance for creating a StandardMagenticManager.
1440
- manager_agent_factory: Callable that returns a new agent instance for creating a StandardMagenticManager.
1442
+ The same instance, including its mutable state, is reused by every workflow
1443
+ built with this builder. Do not share a stateful manager across concurrent or
1444
+ interleaved workflows; use manager_factory to create a fresh manager per build.
1445
+ manager_factory: Callable invoked on each build to return a new MagenticManagerBase instance.
1446
+ manager_agent: Agent instance used to create a new StandardMagenticManager on each build.
1447
+ Each workflow has its own manager, but the supplied agent is shared.
1448
+ manager_agent_factory: Callable invoked on each build to return a new agent instance
1449
+ for a new StandardMagenticManager.
1441
1450
  task_ledger: Optional custom task ledger (used with manager_agent/manager_agent_factory).
1442
1451
  task_ledger_facts_prompt: Custom prompt for extracting facts.
1443
1452
  task_ledger_plan_prompt: Custom prompt for generating initial plan.
@@ -1691,7 +1700,8 @@ class MagenticBuilder:
1691
1700
  self._manager = manager
1692
1701
  _log_warning_if_constructor_args_provided()
1693
1702
  elif manager_agent is not None:
1694
- self._manager = StandardMagenticManager(
1703
+ self._manager_factory = partial(
1704
+ StandardMagenticManager,
1695
1705
  agent=manager_agent,
1696
1706
  task_ledger=task_ledger,
1697
1707
  task_ledger_facts_prompt=task_ledger_facts_prompt,
@@ -1781,7 +1791,12 @@ class MagenticBuilder:
1781
1791
  return executors
1782
1792
 
1783
1793
  def build(self) -> Workflow:
1784
- """Build a Magentic workflow with the orchestrator and all agent executors."""
1794
+ """Build a Magentic workflow with the orchestrator and all agent executors.
1795
+
1796
+ A new standard manager is created for each build when configured with manager_agent
1797
+ or manager_agent_factory. An explicitly supplied manager instance is reused unchanged;
1798
+ manager_factory is called on each build to obtain a manager.
1799
+ """
1785
1800
  mark_feature_used(FeatureIndex.ORCHESTRATION_MAGENTIC)
1786
1801
  logger.info(f"Building Magentic workflow with {len(self._participants)} participants")
1787
1802
 
@@ -221,13 +221,12 @@ class AgentApprovalExecutor(WorkflowExecutor):
221
221
  request_info_cls = _TerminalAgentRequestInfoExecutor if terminal else AgentRequestInfoExecutor
222
222
  request_info_executor = request_info_cls(id="agent_request_info_executor")
223
223
 
224
- # Both inner executors yield the inner workflow's terminal output (the agent
225
- # during its turn; the _TerminalAgentRequestInfoExecutor after approval), so
226
- # both must be designated for WorkflowExecutor.get_outputs() to surface them.
224
+ # Only the request info executor surfaces approved responses. The agent's
225
+ # unapproved response remains available through the request_info event.
227
226
  return (
228
227
  WorkflowBuilder(
229
228
  start_executor=agent_executor,
230
- output_from=[agent_executor, request_info_executor],
229
+ output_from=[request_info_executor],
231
230
  )
232
231
  # Create a loop between agent executor and request info executor
233
232
  .add_edge(agent_executor, request_info_executor)
@@ -0,0 +1,163 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Shared orchestrator utilities for group chat patterns.
4
+
5
+ This module provides simple, reusable functions for common orchestration tasks.
6
+ No inheritance required - just import and call.
7
+ """
8
+
9
+ import logging
10
+
11
+ from agent_framework._types import Message
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # Semantic content types that carry real user input and must survive handoff routing.
17
+ # Everything else (function calls, function results, approval payloads, tool outputs)
18
+ # is runtime-only tool-control state that providers reject when replayed.
19
+ _USER_SEMANTIC_CONTENT_TYPES: frozenset[str] = frozenset({"text", "data", "uri", "hosted_file", "hosted_vector_store"})
20
+
21
+
22
+ def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message]:
23
+ """Clean the conversation history for handoff routing.
24
+
25
+ Handoff executors must not replay prior tool-control artifacts (function calls,
26
+ tool outputs, approval payloads) into future model turns, or providers may reject
27
+ the next request due to unmatched tool-call state. At the same time, genuine user
28
+ input such as images or files must be preserved so the receiving agent keeps the
29
+ full context of the request.
30
+
31
+ This helper builds a cleaned copy of the conversation:
32
+ - Keeps text content on every message.
33
+ - Additionally keeps semantic multimodal content (data, uri, hosted_file,
34
+ hosted_vector_store) on user messages.
35
+ - Keeps assistant and other non-user messages text-only, because providers treat
36
+ multimodal parts as input-only and reject them when replayed on assistant turns.
37
+ - Drops tool-control payloads (function_call, function_result, approval payloads, etc.).
38
+ - Drops messages with no remaining content.
39
+ - Preserves original roles and author names for retained messages.
40
+
41
+ Args:
42
+ conversation: Full conversation history, including tool-control content
43
+ Returns:
44
+ Cleaned conversation history suitable for handoff routing, with semantic
45
+ multimodal content preserved on user messages.
46
+ """
47
+ cleaned: list[Message] = []
48
+ for msg in conversation:
49
+ # Tool-control content (function_call/function_result/approval payloads) is
50
+ # runtime-only and must not be replayed in future model turns.
51
+ allowed_types = _USER_SEMANTIC_CONTENT_TYPES if str(msg.role).lower() == "user" else frozenset({"text"})
52
+ retained = [
53
+ content
54
+ for content in msg.contents
55
+ if content.type in allowed_types and (content.type != "text" or content.text)
56
+ ]
57
+ if not retained:
58
+ continue
59
+
60
+ msg_copy = Message(
61
+ role=msg.role,
62
+ contents=retained,
63
+ author_name=msg.author_name,
64
+ additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
65
+ )
66
+ cleaned.append(msg_copy)
67
+
68
+ return cleaned
69
+
70
+
71
+ def create_completion_message(
72
+ *,
73
+ text: str | None = None,
74
+ author_name: str,
75
+ reason: str = "completed",
76
+ ) -> Message:
77
+ """Create a standardized completion message.
78
+
79
+ Simple helper to avoid duplicating completion message creation.
80
+
81
+ Args:
82
+ text: Message text, or None to generate default
83
+ author_name: Author/orchestrator name
84
+ reason: Reason for completion (for default text generation)
85
+
86
+ Returns:
87
+ Message with assistant role
88
+ """
89
+ message_text = text or f"Conversation {reason}."
90
+ return Message(
91
+ role="assistant",
92
+ contents=[message_text],
93
+ author_name=author_name,
94
+ )
95
+
96
+
97
+ _FENCE_MARKER = "```"
98
+
99
+
100
+ def _backtick_run_end(text: str, start: int) -> int:
101
+ """Return the index just past the run of backticks that begins at ``start``."""
102
+ end = start
103
+ while end < len(text) and text[end] == "`":
104
+ end += 1
105
+ return end
106
+
107
+
108
+ def _fence_info_string_end(text: str, start: int) -> int:
109
+ """Return the index just past the info string of an opening fence such as ``json`` or ``application/json``.
110
+
111
+ An info string has to start with a letter, so a same-line body such as ``{"a": 1}`` that
112
+ follows the fence directly is left in place.
113
+ """
114
+ index = start
115
+ while index < len(text) and text[index] in " \t":
116
+ index += 1
117
+ if index >= len(text) or not text[index].isalpha():
118
+ return start
119
+ while index < len(text) and (text[index].isalnum() or text[index] in "_-+./"):
120
+ index += 1
121
+ return index
122
+
123
+
124
+ def extract_markdown_fence_bodies(text: str) -> list[str]:
125
+ """Return the stripped bodies of the Markdown code fences in ``text``, in source order.
126
+
127
+ Model output that ignores ``response_format`` often wraps JSON in a fence. An opening fence
128
+ is a run of three or more backticks followed by an optional info string. The closing fence
129
+ is the next run that is at least as long and ends its line, so backticks inside a JSON
130
+ string value (which always end in a quote on the same line) never close the block, and an
131
+ outer fence can use more backticks than the ones inside it. A fence that is never closed
132
+ yields nothing.
133
+
134
+ The scan advances through ``text`` without backtracking, so it stays linear in the input
135
+ length for malformed output such as an unterminated fence followed by whitespace.
136
+ """
137
+ bodies: list[str] = []
138
+ length = len(text)
139
+ open_start = text.find(_FENCE_MARKER)
140
+ while open_start != -1:
141
+ open_end = _backtick_run_end(text, open_start)
142
+ closing_marker = text[open_start:open_end]
143
+ body_start = _fence_info_string_end(text, open_end)
144
+
145
+ search_from = body_start
146
+ close_start = close_end = -1
147
+ while (run_start := text.find(closing_marker, search_from)) != -1:
148
+ run_end = _backtick_run_end(text, run_start)
149
+ line_end = run_end
150
+ while line_end < length and text[line_end] in " \t\r":
151
+ line_end += 1
152
+ if line_end == length or text[line_end] == "\n":
153
+ close_start, close_end = run_start, run_end
154
+ break
155
+ search_from = line_end
156
+ if close_start == -1:
157
+ break
158
+
159
+ body = text[body_start:close_start].strip()
160
+ if body:
161
+ bodies.append(body)
162
+ open_start = text.find(_FENCE_MARKER, close_end)
163
+ return bodies
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
4
4
  authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  license-files = ["LICENSE"]
9
9
  urls.homepage = "https://aka.ms/agent-framework"
10
10
  urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -1,78 +0,0 @@
1
- # Copyright (c) Microsoft. All rights reserved.
2
-
3
- """Shared orchestrator utilities for group chat patterns.
4
-
5
- This module provides simple, reusable functions for common orchestration tasks.
6
- No inheritance required - just import and call.
7
- """
8
-
9
- import logging
10
-
11
- from agent_framework._types import Message
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message]:
17
- """Keep only plain text chat history for handoff routing.
18
-
19
- Handoff executors must not replay prior tool-control artifacts (function calls,
20
- tool outputs, approval payloads) into future model turns, or providers may reject
21
- the next request due to unmatched tool-call state.
22
-
23
- This helper builds a text-only copy of the conversation:
24
- - Drops all non-text content from every message.
25
- - Drops messages with no remaining text content.
26
- - Preserves original roles and author names for retained text messages.
27
-
28
- Args:
29
- conversation: Full conversation history, including tool-control content
30
- Returns:
31
- Cleaned conversation history with only text content, suitable for handoff routing
32
- """
33
- cleaned: list[Message] = []
34
- for msg in conversation:
35
- # Keep only plain text history for handoff routing. Tool-control content
36
- # (function_call/function_result/approval payloads) is runtime-only and
37
- # must not be replayed in future model turns.
38
- text_parts = [content.text for content in msg.contents if content.type == "text" and content.text]
39
- # TODO(@taochen): This is a simplified check that considers any non-text content as a tool call.
40
- # We need to enhance this logic to specifically identify tool related contents.
41
- if not text_parts:
42
- continue
43
-
44
- msg_copy = Message(
45
- role=msg.role,
46
- contents=[" ".join(text_parts)],
47
- author_name=msg.author_name,
48
- additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
49
- )
50
- cleaned.append(msg_copy)
51
-
52
- return cleaned
53
-
54
-
55
- def create_completion_message(
56
- *,
57
- text: str | None = None,
58
- author_name: str,
59
- reason: str = "completed",
60
- ) -> Message:
61
- """Create a standardized completion message.
62
-
63
- Simple helper to avoid duplicating completion message creation.
64
-
65
- Args:
66
- text: Message text, or None to generate default
67
- author_name: Author/orchestrator name
68
- reason: Reason for completion (for default text generation)
69
-
70
- Returns:
71
- Message with assistant role
72
- """
73
- message_text = text or f"Conversation {reason}."
74
- return Message(
75
- role="assistant",
76
- contents=[message_text],
77
- author_name=author_name,
78
- )