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.
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/PKG-INFO +14 -1
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/README.md +13 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_group_chat.py +9 -2
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_handoff.py +104 -20
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_magentic.py +24 -9
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_orchestration_request_info.py +3 -4
- agent_framework_orchestrations-1.3.0/agent_framework_orchestrations/_orchestrator_helpers.py +163 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/pyproject.toml +1 -1
- agent_framework_orchestrations-1.2.0/agent_framework_orchestrations/_orchestrator_helpers.py +0 -78
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/LICENSE +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/__init__.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_base_group_chat_orchestrator.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_concurrent.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_feature_usage.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_orchestration_state.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_participant_output_config.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_sequential.py +0 -0
- {agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/agent_framework_orchestrations/_workflow_builder.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
#
|
|
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
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
if
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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.
|
|
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
|
-
#
|
|
225
|
-
#
|
|
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=[
|
|
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
|
{agent_framework_orchestrations-1.2.0 → agent_framework_orchestrations-1.3.0}/pyproject.toml
RENAMED
|
@@ -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.
|
|
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"
|
agent_framework_orchestrations-1.2.0/agent_framework_orchestrations/_orchestrator_helpers.py
DELETED
|
@@ -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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|