strands-swarms 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,738 @@
1
+ """DynamicSwarm - automatically construct and execute multi-agent workflows.
2
+
3
+ This module provides the core swarm functionality:
4
+ - Agent and task definitions
5
+ - SwarmConfig for collecting definitions and building the swarm graph
6
+ - DynamicSwarm class that uses an orchestrator for:
7
+ 1. Planning and creating subagents
8
+ 2. Assigning tasks
9
+ 3. Generating final response
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from dataclasses import dataclass, field
16
+ from textwrap import dedent
17
+ from typing import Any, Callable, TYPE_CHECKING
18
+
19
+ from strands import Agent
20
+ from strands.multiagent.base import Status, MultiAgentResult
21
+ from strands.multiagent.graph import Graph, GraphBuilder, GraphResult
22
+ from strands.hooks import HookProvider, HookRegistry
23
+
24
+ from .events import (
25
+ AGENT_COLORS,
26
+ SwarmStartedEvent,
27
+ PlanningStartedEvent,
28
+ ExecutionStartedEvent,
29
+ TaskStartedEvent,
30
+ TaskCompletedEvent,
31
+ ExecutionCompletedEvent,
32
+ SwarmCompletedEvent,
33
+ SwarmFailedEvent,
34
+ PrintingHookProvider,
35
+ create_colored_callback_handler,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from strands.models import Model
40
+ from strands.session import SessionManager
41
+
42
+
43
+ # =============================================================================
44
+ # Agent and Task Definitions
45
+ # =============================================================================
46
+
47
+
48
+ @dataclass
49
+ class AgentDefinition:
50
+ """Definition for a dynamically spawned agent.
51
+
52
+ Attributes:
53
+ name: Unique identifier for this agent.
54
+ role: What this agent does (used in system prompt).
55
+ instructions: Additional instructions for the agent.
56
+ tools: List of tool names from the available pool.
57
+ model: Model name from the available pool.
58
+ color: Display color for this agent (auto-assigned on registration).
59
+ """
60
+
61
+ name: str
62
+ role: str
63
+ instructions: str | None = None
64
+ tools: list[str] = field(default_factory=list)
65
+ model: str | None = None
66
+ color: str | None = None
67
+
68
+ def build_system_prompt(self) -> str:
69
+ """Build the system prompt for this agent."""
70
+ parts = [f"You are a {self.role}."]
71
+ if self.instructions:
72
+ parts.append(f"\n\nInstructions:\n{self.instructions}")
73
+ return "\n".join(parts)
74
+
75
+
76
+ @dataclass
77
+ class TaskDefinition:
78
+ """Definition for a task in the dynamic workflow.
79
+
80
+ Attributes:
81
+ name: Unique identifier for this task.
82
+ agent: Name of the agent assigned to this task.
83
+ description: What this task should accomplish.
84
+ depends_on: List of task names this task depends on.
85
+ """
86
+
87
+ name: str
88
+ agent: str
89
+ description: str | None = None
90
+ depends_on: list[str] = field(default_factory=list)
91
+
92
+
93
+ # =============================================================================
94
+ # Swarm Configuration
95
+ # =============================================================================
96
+
97
+
98
+ class SwarmConfig:
99
+ """Configuration for a dynamic swarm.
100
+
101
+ Collects agent and task definitions during planning. Call build() to
102
+ create a complete Graph ready for execution.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ available_tools: dict[str, Callable[..., Any]],
108
+ available_models: dict[str, Model],
109
+ default_model: str | None = None,
110
+ ) -> None:
111
+ """Initialize the swarm configuration.
112
+
113
+ Args:
114
+ available_tools: Pool of tools that can be assigned to agents.
115
+ available_models: Pool of models (name -> Model instance) that can be used by agents.
116
+ default_model: Default model name to use if none specified.
117
+ """
118
+ self._available_tools = available_tools
119
+ self._available_models = available_models
120
+ self._default_model = default_model or next(iter(available_models.keys()), None)
121
+
122
+ self._agents: dict[str, AgentDefinition] = {}
123
+ self._tasks: dict[str, TaskDefinition] = {}
124
+ self._entry_task: str | None = None
125
+ self._color_index = 0
126
+
127
+ def register_agent(self, definition: AgentDefinition) -> None:
128
+ """Register a new agent definition.
129
+
130
+ Args:
131
+ definition: The agent definition to register.
132
+
133
+ Raises:
134
+ ValueError: If agent name already exists or references invalid tools/models.
135
+ """
136
+ if definition.name in self._agents:
137
+ raise ValueError(f"Agent '{definition.name}' already exists")
138
+
139
+ # Validate tools
140
+ for tool_name in definition.tools:
141
+ if tool_name not in self._available_tools:
142
+ available = list(self._available_tools.keys())
143
+ raise ValueError(
144
+ f"Tool '{tool_name}' not in available tools: {available}"
145
+ )
146
+
147
+ # Validate model
148
+ if definition.model and definition.model not in self._available_models:
149
+ available = list(self._available_models.keys())
150
+ raise ValueError(
151
+ f"Model '{definition.model}' not in available models: {available}"
152
+ )
153
+
154
+ # Assign a color to this agent (used by events for display)
155
+ definition.color = AGENT_COLORS[self._color_index % len(AGENT_COLORS)]
156
+ self._color_index += 1
157
+
158
+ self._agents[definition.name] = definition
159
+
160
+ def register_task(self, definition: TaskDefinition) -> None:
161
+ """Register a new task definition.
162
+
163
+ Args:
164
+ definition: The task definition to register.
165
+
166
+ Raises:
167
+ ValueError: If task name exists or references unknown agent/dependencies.
168
+ """
169
+ if definition.name in self._tasks:
170
+ raise ValueError(f"Task '{definition.name}' already exists")
171
+
172
+ if definition.agent not in self._agents:
173
+ available = list(self._agents.keys())
174
+ raise ValueError(
175
+ f"Agent '{definition.agent}' not found. Available: {available}"
176
+ )
177
+
178
+ for dep in definition.depends_on:
179
+ if dep not in self._tasks:
180
+ available = list(self._tasks.keys())
181
+ raise ValueError(
182
+ f"Dependency '{dep}' not found. Available: {available}"
183
+ )
184
+
185
+ self._tasks[definition.name] = definition
186
+
187
+ def set_entry_task(self, task_name: str) -> None:
188
+ """Set the entry point task."""
189
+ if task_name not in self._tasks:
190
+ raise ValueError(f"Task '{task_name}' not found")
191
+ self._entry_task = task_name
192
+
193
+ def clear(self) -> None:
194
+ """Clear all registered agents and tasks."""
195
+ self._agents.clear()
196
+ self._tasks.clear()
197
+ self._color_index = 0
198
+ self._entry_task = None
199
+
200
+ @property
201
+ def agents(self) -> dict[str, AgentDefinition]:
202
+ """Get all registered agent definitions."""
203
+ return dict(self._agents)
204
+
205
+ @property
206
+ def tasks(self) -> dict[str, TaskDefinition]:
207
+ """Get all registered task definitions."""
208
+ return dict(self._tasks)
209
+
210
+ @property
211
+ def entry_task(self) -> str | None:
212
+ """Get the entry task name."""
213
+ return self._entry_task
214
+
215
+ @property
216
+ def available_tools(self) -> dict[str, Callable[..., Any]]:
217
+ """Get available tools mapping."""
218
+ return self._available_tools
219
+
220
+ @property
221
+ def available_models(self) -> dict[str, Model]:
222
+ """Get available models mapping."""
223
+ return self._available_models
224
+
225
+ @property
226
+ def default_model(self) -> str | None:
227
+ """Get the default model name."""
228
+ return self._default_model
229
+
230
+ @property
231
+ def available_tool_names(self) -> list[str]:
232
+ """Get list of available tool names."""
233
+ return list(self._available_tools.keys())
234
+
235
+ @property
236
+ def available_model_names(self) -> list[str]:
237
+ """Get list of available model names."""
238
+ return list(self._available_models.keys())
239
+
240
+ @property
241
+ def agent_colors(self) -> dict[str, str]:
242
+ """Get agent name to color mapping."""
243
+ return {name: d.color for name, d in self._agents.items() if d.color}
244
+
245
+ def get_summary(self) -> str:
246
+ """Get a summary of the current swarm configuration."""
247
+ lines = [
248
+ f"Agents ({len(self._agents)}):",
249
+ *[f" - {name}: {d.role}" for name, d in self._agents.items()],
250
+ f"\nTasks ({len(self._tasks)}):",
251
+ *[
252
+ f" - {name} -> {d.agent}" + (f" (depends: {d.depends_on})" if d.depends_on else "")
253
+ for name, d in self._tasks.items()
254
+ ],
255
+ f"\nEntry: {self._entry_task or 'auto'}",
256
+ ]
257
+ return "\n".join(lines)
258
+
259
+
260
+ def build_swarm(
261
+ config: SwarmConfig,
262
+ *,
263
+ use_colored_output: bool = False,
264
+ execution_timeout: float = 900.0,
265
+ task_timeout: float = 300.0,
266
+ session_manager: SessionManager | None = None,
267
+ ) -> Graph:
268
+ """Build a complete Graph from a swarm configuration.
269
+
270
+ Creates all agents from registered definitions and wires up the
271
+ task dependency graph.
272
+
273
+ Args:
274
+ config: The swarm configuration containing agent and task definitions.
275
+ use_colored_output: Whether to use colored output for agents.
276
+ execution_timeout: Overall execution timeout in seconds.
277
+ task_timeout: Per-task timeout in seconds.
278
+ session_manager: Optional session manager for persistence.
279
+
280
+ Returns:
281
+ Configured Graph ready for execution.
282
+
283
+ Raises:
284
+ ValueError: If no tasks are registered.
285
+ """
286
+ def build_agent(agent_name: str) -> Agent:
287
+ """Build an Agent from a registered definition."""
288
+ definition = config.agents.get(agent_name)
289
+ if not definition:
290
+ raise ValueError(f"Agent '{agent_name}' not found")
291
+
292
+ tools = [config.available_tools[t] for t in definition.tools]
293
+ model_name = definition.model or config.default_model
294
+ model = config.available_models.get(model_name) if model_name else None
295
+
296
+ callback_handler = None
297
+ if use_colored_output and definition.color:
298
+ callback_handler = create_colored_callback_handler(definition.color, agent_name)
299
+
300
+ return Agent(
301
+ name=definition.name,
302
+ system_prompt=definition.build_system_prompt(),
303
+ model=model,
304
+ tools=tools if tools else None,
305
+ callback_handler=callback_handler,
306
+ )
307
+
308
+ if not config.tasks:
309
+ raise ValueError("No tasks registered - cannot build swarm")
310
+
311
+ builder = GraphBuilder()
312
+
313
+ # Build agents and add as nodes
314
+ for task_name, task_def in config.tasks.items():
315
+ builder.add_node(build_agent(task_def.agent), task_name)
316
+
317
+ # Add edges based on dependencies
318
+ for task_name, task_def in config.tasks.items():
319
+ for dep_name in task_def.depends_on:
320
+ if dep_name in config.tasks:
321
+ builder.add_edge(dep_name, task_name)
322
+
323
+ # Set entry point
324
+ if config.entry_task:
325
+ builder.set_entry_point(config.entry_task)
326
+
327
+ # Configure execution
328
+ builder.set_execution_timeout(execution_timeout)
329
+ builder.set_node_timeout(task_timeout)
330
+
331
+ if session_manager:
332
+ builder.set_session_manager(session_manager)
333
+
334
+ return builder.build()
335
+
336
+
337
+ # =============================================================================
338
+ # DynamicSwarm Result
339
+ # =============================================================================
340
+
341
+
342
+ @dataclass
343
+ class DynamicSwarmResult:
344
+ """Result from DynamicSwarm execution.
345
+
346
+ Attributes:
347
+ status: Overall execution status.
348
+ planning_output: Output from the planning phase.
349
+ execution_result: Result from the execution phase (GraphResult).
350
+ final_response: Final response from the planner after all tasks completed.
351
+ agents_spawned: Number of agents that were dynamically spawned.
352
+ tasks_created: Number of tasks that were created.
353
+ """
354
+
355
+ status: Status
356
+ planning_output: str | None = None
357
+ execution_result: GraphResult | None = None
358
+ final_response: str | None = None
359
+ agents_spawned: int = 0
360
+ tasks_created: int = 0
361
+ error: str | None = None
362
+
363
+ def get_output(self, task_name: str) -> Any | None:
364
+ """Get output from a specific task."""
365
+ if self.execution_result and hasattr(self.execution_result, "results"):
366
+ node_result = self.execution_result.results.get(task_name)
367
+ if node_result:
368
+ return str(node_result.result)
369
+ return None
370
+
371
+ def __bool__(self) -> bool:
372
+ """Return True if completed successfully."""
373
+ return self.status == Status.COMPLETED
374
+
375
+
376
+ # =============================================================================
377
+ # DynamicSwarm Orchestrator
378
+ # =============================================================================
379
+
380
+
381
+ class DynamicSwarm:
382
+ """Dynamically construct and execute multi-agent workflows.
383
+
384
+ DynamicSwarm uses an orchestrator agent to analyze user queries and coordinate
385
+ a multi-agent workflow. The orchestrator has three main responsibilities:
386
+
387
+ 1. **Planning and Creating Subagents** - Analyze the task and spawn specialized
388
+ agents with appropriate tools and models.
389
+
390
+ 2. **Assigning Tasks** - Create tasks and assign them to the spawned agents,
391
+ defining dependencies between tasks when needed.
392
+
393
+ 3. **Generating Final Response** - After all tasks complete, synthesize the
394
+ results into a cohesive final response.
395
+
396
+ All sub-agents run in parallel unless they have dependencies declared.
397
+ Tasks with dependencies will wait for their dependencies to complete first.
398
+
399
+ This is the unique value of this package - dynamic LLM-driven workflow orchestration.
400
+ For static multi-agent workflows, use the Strands SDK directly:
401
+ - strands.multiagent.graph.Graph for dependency-based execution
402
+
403
+ Example:
404
+ from strands_swarms import DynamicSwarm
405
+ from strands import tool
406
+
407
+ @tool
408
+ def search_web(query: str) -> str:
409
+ '''Search the web.'''
410
+ return f"Results for: {query}"
411
+
412
+ swarm = DynamicSwarm(
413
+ available_tools={"search_web": search_web},
414
+ verbose=True,
415
+ )
416
+
417
+ result = swarm.execute("Research AI trends and summarize")
418
+
419
+ Customization:
420
+ Prompt templates can be overridden via subclassing:
421
+
422
+ class CustomSwarm(DynamicSwarm):
423
+ ORCHESTRATION_PROMPT_TEMPLATE = "Your custom template with {query}, {available_tools}, {available_models}"
424
+ """
425
+
426
+ ORCHESTRATION_PROMPT_TEMPLATE: str = dedent("""\
427
+ Analyze this request and design a workflow:
428
+
429
+ {query}
430
+
431
+ Available tools: {available_tools}
432
+ Available models: {available_models}
433
+
434
+ Create the necessary agents and tasks, then call execute_swarm() when ready.""")
435
+
436
+ COMPLETION_PROMPT_TEMPLATE: str = dedent("""\
437
+ The tasks you designed have completed. Here are the outputs from each agent:
438
+
439
+ {task_outputs}
440
+
441
+ Now synthesize these results into a final, cohesive response to the original query:
442
+ {query}
443
+
444
+ Be direct and deliver the result. Don't explain the process or mention the sub-agents.""")
445
+
446
+ def __init__(
447
+ self,
448
+ available_tools: dict[str, Callable[..., Any]] | None = None,
449
+ available_models: dict[str, Model] | None = None,
450
+ *,
451
+ orchestrator_model: Model | None = None,
452
+ default_agent_model: str | None = None,
453
+ max_iterations: int = 20,
454
+ execution_timeout: float = 900.0,
455
+ task_timeout: float = 300.0,
456
+ session_manager: SessionManager | None = None,
457
+ hooks: list[HookProvider] | None = None,
458
+ verbose: bool = False,
459
+ ) -> None:
460
+ """Initialize DynamicSwarm.
461
+
462
+ Args:
463
+ available_tools: Pool of tools that can be assigned to spawned agents.
464
+ Keys are tool names used by the orchestrator.
465
+ available_models: Pool of Model instances that can be used by spawned agents.
466
+ Keys are friendly names (e.g., "fast", "powerful"),
467
+ values are Model instances.
468
+ orchestrator_model: Model instance to use for the orchestrator agent.
469
+ default_agent_model: Default model name (key in available_models) for spawned agents.
470
+ max_iterations: Maximum iterations for execution.
471
+ execution_timeout: Overall execution timeout in seconds.
472
+ task_timeout: Per-task timeout in seconds.
473
+ session_manager: Optional session manager for persistence.
474
+ hooks: List of HookProvider instances for event callbacks.
475
+ Use PrintingHookProvider() for CLI output.
476
+ verbose: Shorthand for hooks=[PrintingHookProvider()].
477
+ """
478
+ self._available_tools = available_tools or {}
479
+ self._available_models = available_models or {}
480
+ self._orchestrator_model = orchestrator_model
481
+ self._default_agent_model = default_agent_model
482
+ self._max_iterations = max_iterations
483
+ self._execution_timeout = execution_timeout
484
+ self._task_timeout = task_timeout
485
+ self._session_manager = session_manager
486
+
487
+ # Build hook registry
488
+ self._hook_registry = HookRegistry()
489
+
490
+ # Add verbose hook if requested
491
+ if verbose:
492
+ self._hook_registry.add_hook(PrintingHookProvider())
493
+
494
+ # Add user-provided hooks
495
+ if hooks:
496
+ for hook in hooks:
497
+ self._hook_registry.add_hook(hook)
498
+
499
+ def execute(self, query: str) -> DynamicSwarmResult:
500
+ """Execute a query by dynamically building and running a workflow.
501
+
502
+ Args:
503
+ query: The user's request to process.
504
+
505
+ Returns:
506
+ DynamicSwarmResult containing planning and execution results.
507
+ """
508
+ return asyncio.get_event_loop().run_until_complete(self.execute_async(query))
509
+
510
+ def _emit(self, event: Any) -> None:
511
+ """Emit an event to all registered hooks."""
512
+ if self._hook_registry.has_callbacks():
513
+ self._hook_registry.invoke_callbacks(event)
514
+
515
+ async def execute_async(self, query: str) -> DynamicSwarmResult:
516
+ """Execute asynchronously.
517
+
518
+ Args:
519
+ query: The user's request to process.
520
+
521
+ Returns:
522
+ DynamicSwarmResult containing planning and execution results.
523
+ """
524
+ # Import here to avoid circular import
525
+ from .orchestrator import set_swarm_config, create_orchestrator_agent
526
+
527
+ # Create swarm config for this execution
528
+ config = SwarmConfig(
529
+ available_tools=self._available_tools,
530
+ available_models=self._available_models,
531
+ default_model=self._default_agent_model,
532
+ )
533
+
534
+ # Set config for planning tools (with hook registry)
535
+ set_swarm_config(config, hook_registry=self._hook_registry)
536
+
537
+ try:
538
+ # Emit swarm started event
539
+ self._emit(SwarmStartedEvent(
540
+ query=query,
541
+ available_tools=list(self._available_tools.keys()),
542
+ available_models=list(self._available_models.keys()),
543
+ ))
544
+
545
+ # =================================================================
546
+ # Orchestrator Phase 1 & 2: Planning, Creating Subagents, Assigning Tasks
547
+ # =================================================================
548
+ self._emit(PlanningStartedEvent())
549
+
550
+ planning_result = await self._run_planning(query, config)
551
+ if not planning_result.success:
552
+ self._emit(SwarmFailedEvent(
553
+ error=planning_result.error or "Orchestration failed"
554
+ ))
555
+ return DynamicSwarmResult(
556
+ status=Status.FAILED,
557
+ planning_output=planning_result.output,
558
+ error=planning_result.error,
559
+ )
560
+
561
+ # Validate we have tasks
562
+ if not config.tasks:
563
+ self._emit(SwarmFailedEvent(
564
+ error="Orchestration completed but no tasks were created"
565
+ ))
566
+ return DynamicSwarmResult(
567
+ status=Status.FAILED,
568
+ planning_output=planning_result.output,
569
+ error="Orchestration completed but no tasks were created",
570
+ )
571
+
572
+ # Build the swarm graph (create sub-agents, wire up task dependencies)
573
+ use_colored_output = self._hook_registry.has_callbacks()
574
+ graph = build_swarm(
575
+ config,
576
+ use_colored_output=use_colored_output,
577
+ execution_timeout=self._execution_timeout,
578
+ task_timeout=self._task_timeout,
579
+ session_manager=self._session_manager,
580
+ )
581
+
582
+ # =================================================================
583
+ # Execute Tasks
584
+ # =================================================================
585
+ self._emit(ExecutionStartedEvent(
586
+ tasks=list(config.tasks.keys()),
587
+ ))
588
+
589
+ execution_result = await self._execute_graph(query, graph, config)
590
+
591
+ # Emit execution completion event
592
+ self._emit(ExecutionCompletedEvent(
593
+ status=str(execution_result.status) if execution_result else "FAILED",
594
+ agent_count=len(config.agents),
595
+ task_count=len(config.tasks),
596
+ ))
597
+
598
+ # =================================================================
599
+ # Orchestrator Phase 3: Generate Final Response
600
+ # Uses the SAME orchestrator agent from planning (continued conversation)
601
+ # =================================================================
602
+ final_response = await self._run_completion(
603
+ query, config, execution_result,
604
+ orchestrator=planning_result.orchestrator
605
+ )
606
+
607
+ self._emit(SwarmCompletedEvent())
608
+
609
+ return DynamicSwarmResult(
610
+ status=execution_result.status if execution_result else Status.FAILED,
611
+ planning_output=planning_result.output,
612
+ execution_result=execution_result,
613
+ final_response=final_response,
614
+ agents_spawned=len(config.agents),
615
+ tasks_created=len(config.tasks),
616
+ )
617
+
618
+ finally:
619
+ # Clear config
620
+ set_swarm_config(None)
621
+
622
+ async def _run_planning(
623
+ self, query: str, state: SwarmConfig
624
+ ) -> _PlanningResult:
625
+ """Run the orchestration phase: planning and creating subagents, assigning tasks.
626
+
627
+ Returns the orchestrator agent along with the result so it can be reused
628
+ for the completion phase (same agent, continued conversation).
629
+ """
630
+ from .orchestrator import create_orchestrator_agent
631
+
632
+ orchestrator = create_orchestrator_agent(model=self._orchestrator_model)
633
+
634
+ orchestration_prompt = self.ORCHESTRATION_PROMPT_TEMPLATE.format(
635
+ query=query,
636
+ available_tools=state.available_tool_names or ['none'],
637
+ available_models=state.available_model_names or ['default only'],
638
+ )
639
+
640
+ try:
641
+ result = orchestrator(orchestration_prompt)
642
+
643
+ output = None
644
+ if hasattr(result, "message") and result.message:
645
+ content = result.message.get("content", [])
646
+ if content and isinstance(content[0], dict):
647
+ output = content[0].get("text", "")
648
+
649
+ return _PlanningResult(success=True, output=output, orchestrator=orchestrator)
650
+
651
+ except Exception as e:
652
+ return _PlanningResult(success=False, error=str(e))
653
+
654
+ async def _run_completion(
655
+ self,
656
+ query: str,
657
+ state: SwarmConfig,
658
+ execution_result: MultiAgentResult | None,
659
+ orchestrator: Agent,
660
+ ) -> str | None:
661
+ """Run the final response generation phase - synthesize task outputs into a cohesive response.
662
+
663
+ This is the third responsibility of the orchestrator: generating the final response
664
+ by combining outputs from all sub-agents.
665
+
666
+ Uses the SAME orchestrator agent that did the planning, continuing the conversation
667
+ so it has full context about why agents were spawned and what tasks were meant to do.
668
+
669
+ Args:
670
+ query: Original user query.
671
+ state: Swarm configuration with task definitions.
672
+ execution_result: Results from task execution.
673
+ orchestrator: The orchestrator agent from planning phase (same agent, continued conversation).
674
+ """
675
+ if not execution_result or not hasattr(execution_result, "results"):
676
+ return None
677
+
678
+ # Collect all task outputs
679
+ task_outputs: list[str] = []
680
+ for task_name in state.tasks:
681
+ node_result = execution_result.results.get(task_name)
682
+ if node_result:
683
+ task_outputs.append(f"[{task_name}]:\n{node_result.result}")
684
+
685
+ if not task_outputs:
686
+ return None
687
+
688
+ completion_prompt = self.COMPLETION_PROMPT_TEMPLATE.format(
689
+ query=query,
690
+ task_outputs="\n\n".join(task_outputs),
691
+ )
692
+ try:
693
+ result = orchestrator(completion_prompt)
694
+
695
+ if hasattr(result, "message") and result.message:
696
+ content = result.message.get("content", [])
697
+ if content and isinstance(content[0], dict):
698
+ return content[0].get("text", "")
699
+ return None
700
+ except Exception:
701
+ return None
702
+
703
+ async def _execute_graph(
704
+ self, query: str, graph: Graph, state: SwarmConfig
705
+ ) -> GraphResult:
706
+ """Execute a pre-built Graph with dependency-based execution."""
707
+ if self._hook_registry.has_callbacks():
708
+ result = None
709
+ current_task = None
710
+ async for event in graph.stream_async(query):
711
+ task_name = event.get("node_id")
712
+ if task_name and task_name != current_task:
713
+ if current_task:
714
+ self._emit(TaskCompletedEvent(name=current_task))
715
+ agent_def = state.agents.get(task_name)
716
+ self._emit(TaskStartedEvent(
717
+ name=task_name,
718
+ agent_role=agent_def.role if agent_def else None,
719
+ ))
720
+ current_task = task_name
721
+ if "result" in event:
722
+ result = event["result"]
723
+ if current_task:
724
+ self._emit(TaskCompletedEvent(name=current_task))
725
+ return result if result else await graph.invoke_async(query)
726
+ else:
727
+ return await graph.invoke_async(query)
728
+
729
+
730
+ @dataclass
731
+ class _PlanningResult:
732
+ """Internal result from planning phase."""
733
+
734
+ success: bool
735
+ output: str | None = None
736
+ error: str | None = None
737
+ orchestrator: Agent | None = None # Preserved for completion phase
738
+