sofias-sdk-lite 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.
Files changed (74) hide show
  1. sofias_sdk_lite/__init__.py +262 -0
  2. sofias_sdk_lite/agent/__init__.py +67 -0
  3. sofias_sdk_lite/agent/agent.py +899 -0
  4. sofias_sdk_lite/agent/agent_builder.py +1221 -0
  5. sofias_sdk_lite/agent/agent_config.py +87 -0
  6. sofias_sdk_lite/agent/execution_context.py +222 -0
  7. sofias_sdk_lite/agent/middleware.py +118 -0
  8. sofias_sdk_lite/agent/response_workflow.py +232 -0
  9. sofias_sdk_lite/agent/streaming.py +119 -0
  10. sofias_sdk_lite/config/__init__.py +63 -0
  11. sofias_sdk_lite/config/agent_settings.py +72 -0
  12. sofias_sdk_lite/config/defaults.py +78 -0
  13. sofias_sdk_lite/config/runtime_config.py +201 -0
  14. sofias_sdk_lite/config/sdk_config.py +105 -0
  15. sofias_sdk_lite/config/source.py +168 -0
  16. sofias_sdk_lite/contracts/__init__.py +48 -0
  17. sofias_sdk_lite/contracts/agent_contracts.py +174 -0
  18. sofias_sdk_lite/contracts/base_contracts.py +115 -0
  19. sofias_sdk_lite/contracts/node_contracts.py +177 -0
  20. sofias_sdk_lite/contracts/strict_contract.py +34 -0
  21. sofias_sdk_lite/errors/__init__.py +76 -0
  22. sofias_sdk_lite/errors/circuit_breaker.py +150 -0
  23. sofias_sdk_lite/errors/error_handler.py +413 -0
  24. sofias_sdk_lite/errors/exceptions.py +408 -0
  25. sofias_sdk_lite/llm/__init__.py +27 -0
  26. sofias_sdk_lite/llm/protocol.py +92 -0
  27. sofias_sdk_lite/llm/tokens.py +19 -0
  28. sofias_sdk_lite/llm/tools.py +36 -0
  29. sofias_sdk_lite/messaging/__init__.py +47 -0
  30. sofias_sdk_lite/messaging/models.py +516 -0
  31. sofias_sdk_lite/nodes/__init__.py +104 -0
  32. sofias_sdk_lite/nodes/aggregator_config.py +78 -0
  33. sofias_sdk_lite/nodes/aggregator_node.py +390 -0
  34. sofias_sdk_lite/nodes/base_node.py +211 -0
  35. sofias_sdk_lite/nodes/delegation_config.py +281 -0
  36. sofias_sdk_lite/nodes/delegation_node.py +1120 -0
  37. sofias_sdk_lite/nodes/delegation_transport.py +228 -0
  38. sofias_sdk_lite/nodes/function_node.py +289 -0
  39. sofias_sdk_lite/nodes/llm_node.py +1451 -0
  40. sofias_sdk_lite/nodes/llm_node_config.py +266 -0
  41. sofias_sdk_lite/nodes/plan_executor.py +434 -0
  42. sofias_sdk_lite/nodes/planner_node.py +206 -0
  43. sofias_sdk_lite/nodes/planning_models.py +137 -0
  44. sofias_sdk_lite/nodes/prompt_assembler.py +266 -0
  45. sofias_sdk_lite/observability/__init__.py +47 -0
  46. sofias_sdk_lite/observability/_log.py +43 -0
  47. sofias_sdk_lite/observability/events.py +438 -0
  48. sofias_sdk_lite/observability/tracing.py +619 -0
  49. sofias_sdk_lite/py.typed +0 -0
  50. sofias_sdk_lite/rabbitmq/__init__.py +33 -0
  51. sofias_sdk_lite/rabbitmq/client.py +105 -0
  52. sofias_sdk_lite/rabbitmq/config.py +33 -0
  53. sofias_sdk_lite/rabbitmq/consumer.py +149 -0
  54. sofias_sdk_lite/rabbitmq/delegation_transport.py +400 -0
  55. sofias_sdk_lite/rabbitmq/publisher.py +273 -0
  56. sofias_sdk_lite/rabbitmq/rpc_client.py +338 -0
  57. sofias_sdk_lite/rabbitmq/types.py +68 -0
  58. sofias_sdk_lite/routing/__init__.py +27 -0
  59. sofias_sdk_lite/routing/router.py +301 -0
  60. sofias_sdk_lite/routing/strategies.py +485 -0
  61. sofias_sdk_lite/runner/__init__.py +8 -0
  62. sofias_sdk_lite/runner/config.py +43 -0
  63. sofias_sdk_lite/runner/runner.py +243 -0
  64. sofias_sdk_lite/state/__init__.py +26 -0
  65. sofias_sdk_lite/state/conversation_state.py +259 -0
  66. sofias_sdk_lite/state/history.py +104 -0
  67. sofias_sdk_lite/state/memory.py +123 -0
  68. sofias_sdk_lite/tools.py +482 -0
  69. sofias_sdk_lite/workflows/__init__.py +28 -0
  70. sofias_sdk_lite/workflows/chat.py +113 -0
  71. sofias_sdk_lite/workflows/null.py +35 -0
  72. sofias_sdk_lite-0.1.0.dist-info/METADATA +151 -0
  73. sofias_sdk_lite-0.1.0.dist-info/RECORD +74 -0
  74. sofias_sdk_lite-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,899 @@
1
+ """Agent implementation for graph orchestration.
2
+
3
+ This module provides the Agent class, which is the central orchestrator
4
+ for executing agent graphs. The agent manages node execution, routing,
5
+ error handling, and context propagation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ import uuid
13
+ from collections.abc import AsyncGenerator
14
+ from datetime import datetime, timezone
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from sofias_sdk_lite.config.runtime_config import AgentSettings, SettingsResolver
18
+ from sofias_sdk_lite.agent.execution_context import (
19
+ ExecutionContext,
20
+ get_execution_context,
21
+ set_execution_context,
22
+ )
23
+ from sofias_sdk_lite.contracts import AgentContract
24
+ from sofias_sdk_lite.contracts.agent_contracts import (
25
+ AgentMessage,
26
+ AgentResponse,
27
+ ResponseStatus,
28
+ )
29
+ from sofias_sdk_lite.errors.exceptions import (
30
+ AgentSDKError,
31
+ GraphExecutionError,
32
+ ParallelExecutionError,
33
+ PlanExecutionError,
34
+ RoutingError,
35
+ )
36
+ from sofias_sdk_lite.nodes.base_node import NodeType
37
+ from sofias_sdk_lite.nodes.planning_models import ExecutionPlan
38
+ from sofias_sdk_lite.observability._log import get_logger
39
+
40
+ if TYPE_CHECKING:
41
+ from sofias_sdk_lite.agent.response_workflow import (
42
+ ResponseWorkflow,
43
+ StreamingResponseWorkflow,
44
+ )
45
+ from sofias_sdk_lite.errors.error_handler import ErrorHandler, ExecutionErrorContext
46
+ from sofias_sdk_lite.nodes.base_node import BaseNode as Node
47
+ from sofias_sdk_lite.nodes.plan_executor import PlanExecutor
48
+ from sofias_sdk_lite.routing.router import Router
49
+ from sofias_sdk_lite.agent.streaming import StreamEvent
50
+
51
+ __all__ = ["Agent"]
52
+
53
+ logger = get_logger(__name__)
54
+
55
+
56
+ class Agent:
57
+ """The complete agent graph ready for execution.
58
+
59
+ The Agent is a pure orchestrator. It does not contain business logic.
60
+ Its responsibilities are:
61
+ 1. Receive input and resolve runtime configuration
62
+ 2. Traverse the graph executing nodes according to routing decisions
63
+ 3. Manage errors through the error handler
64
+ 4. Produce validated output
65
+
66
+ The agent does NOT execute LLM calls or tools directly. It delegates
67
+ to Node instances which handle their own execution.
68
+
69
+ Example:
70
+ # Agent is typically built via AgentBuilder
71
+ agent = (
72
+ AgentBuilder("my_agent", version="1.0.0")
73
+ .with_settings_class(MySettings)
74
+ .with_contract(input_schema=MyInput, output_schema=MyOutput)
75
+ .add_node("start", node_config, contract)
76
+ .set_entry_node("start")
77
+ .set_terminal("start")
78
+ .build()
79
+ )
80
+
81
+ # Execute the agent
82
+ response = await agent.execute(
83
+ AgentMessage(
84
+ content=MyInput(query="Hello"),
85
+ runtime_config={"model": "gpt-4o"},
86
+ )
87
+ )
88
+ assert response.status == ResponseStatus.SUCCESS
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ name: str,
95
+ version: str,
96
+ description: str | None,
97
+ nodes: dict[str, Node],
98
+ router: Router,
99
+ error_handler: ErrorHandler,
100
+ contract: AgentContract,
101
+ entry_node: str,
102
+ settings_resolver: SettingsResolver[AgentSettings],
103
+ context: dict[str, Any] | None = None,
104
+ response_workflow: ResponseWorkflow | None = None,
105
+ plan_executor: PlanExecutor | None = None,
106
+ middleware: list[Any] | None = None,
107
+ ) -> None:
108
+ """Initialize the agent.
109
+
110
+ This constructor is typically called by AgentBuilder.build(),
111
+ not directly by users.
112
+
113
+ Args:
114
+ name: Unique name identifying this agent.
115
+ version: Semantic version of the agent.
116
+ description: Human-readable description.
117
+ nodes: Dictionary mapping node names to Node instances.
118
+ router: Router managing graph edges and routing decisions.
119
+ error_handler: ErrorHandler for retry, fallback, circuit breaker.
120
+ contract: Contract defining agent input/output schemas.
121
+ entry_node: Name of the node where execution starts.
122
+ settings_resolver: Resolver for runtime settings.
123
+ context: Read-only context shared with all nodes.
124
+ response_workflow: Optional workflow for delivering the response.
125
+ plan_executor: Optional executor for plans produced by PlannerNodes.
126
+ middleware: Optional list of AgentMiddleware for before/after node hooks.
127
+ """
128
+ self._name = name
129
+ self._version = version
130
+ self._description = description
131
+ self._nodes = nodes
132
+ self._router = router
133
+ self._error_handler = error_handler
134
+ self._contract = contract
135
+ self._entry_node = entry_node
136
+ self._settings_resolver = settings_resolver
137
+ self._context = context or {}
138
+ self._response_workflow = response_workflow
139
+ self._plan_executor = plan_executor
140
+ self._middleware = middleware or []
141
+
142
+ @property
143
+ def name(self) -> str:
144
+ """The agent's name."""
145
+ return self._name
146
+
147
+ @property
148
+ def version(self) -> str:
149
+ """The agent's version."""
150
+ return self._version
151
+
152
+ @property
153
+ def description(self) -> str | None:
154
+ """The agent's description."""
155
+ return self._description
156
+
157
+ @property
158
+ def entry_node(self) -> str:
159
+ """The name of the entry node."""
160
+ return self._entry_node
161
+
162
+ @property
163
+ def context(self) -> dict[str, Any]:
164
+ """The read-only context shared with all nodes."""
165
+ return dict(self._context)
166
+
167
+ async def execute(self, message: AgentMessage) -> AgentResponse:
168
+ """Execute the agent graph with the given input message.
169
+
170
+ This is the main entry point for agent execution. The flow is:
171
+ 1. Resolve runtime config from message and set in context
172
+ 2. Validate message content against agent contract
173
+ 3. Execute graph nodes following routing decisions
174
+ 4. Validate final output against agent contract
175
+ 5. Build AgentResponse with execution metadata
176
+ 6. Clean up context and return result
177
+
178
+ For controlled business errors (validation failures, routing errors,
179
+ node execution errors handled by the error handler), returns an
180
+ AgentResponse with status=ERROR and error_message describing the
181
+ problem. Infrastructure failures (unexpected exceptions not derived
182
+ from AgentSDKError) are re-raised.
183
+
184
+ Args:
185
+ message: Typed input envelope containing domain data and metadata.
186
+
187
+ Returns:
188
+ AgentResponse with validated output and execution metadata.
189
+
190
+ Raises:
191
+ Exception: Only for infrastructure failures not covered by
192
+ AgentSDKError (unexpected crashes, connectivity issues, etc.).
193
+ """
194
+ run_id = str(uuid.uuid4())
195
+ start_time = time.monotonic()
196
+
197
+ # Create typed execution context — available immediately for
198
+ # error handlers and nodes via get_execution_context()
199
+ exec_ctx = ExecutionContext(
200
+ execution_id=run_id,
201
+ agent_name=self._name,
202
+ agent_version=self._version,
203
+ trace_id=message.trace_id,
204
+ started_at=datetime.now(timezone.utc),
205
+ metadata={
206
+ **self._context,
207
+ "conversation_id": message.conversation_id or "",
208
+ "tenant_identifier": message.metadata.get("tenant_identifier", ""),
209
+ },
210
+ )
211
+ set_execution_context(exec_ctx)
212
+
213
+ logger.info(
214
+ "Agent execution started",
215
+ run_id=run_id,
216
+ agent_name=self._name,
217
+ agent_version=self._version,
218
+ entry_node=self._entry_node,
219
+ total_nodes=len(self._nodes),
220
+ )
221
+
222
+ response: AgentResponse | None = None
223
+
224
+ try:
225
+ # Step 1: Resolve runtime config
226
+ if message.runtime_config is not None:
227
+ self._settings_resolver.resolve_and_set(message.runtime_config)
228
+ else:
229
+ self._settings_resolver.set_current(
230
+ self._settings_resolver.settings_class()
231
+ )
232
+
233
+ # Step 2: Validate input content against agent contract
234
+ validated_input = self._contract.validate_input(
235
+ message.content.model_dump()
236
+ )
237
+ current_data = validated_input.model_dump()
238
+ logger.debug("Input validation passed", run_id=run_id)
239
+
240
+ # Step 3: Execute the graph
241
+ current_node_name = self._entry_node
242
+
243
+ while current_node_name is not None:
244
+ exec_ctx.current_node = current_node_name
245
+ exec_ctx.transition_history.append(current_node_name)
246
+
247
+ # Get the node
248
+ node = self._nodes.get(current_node_name)
249
+ if node is None:
250
+ logger.error(
251
+ "Node not found", run_id=run_id, node_name=current_node_name
252
+ )
253
+ raise GraphExecutionError(
254
+ f"Node '{current_node_name}' not found in agent graph",
255
+ graph_name=self._name,
256
+ )
257
+
258
+ # Run before_node middleware
259
+ for mw in self._middleware:
260
+ if hasattr(mw, "before_node"):
261
+ modified = await mw.before_node(current_node_name, current_data)
262
+ if modified is not None:
263
+ current_data = modified
264
+
265
+ # Execute node with error handling
266
+ try:
267
+ current_data = await self._error_handler.handle_node_execution(
268
+ node_name=current_node_name,
269
+ execute_fn=node.execute,
270
+ input_data=current_data,
271
+ )
272
+ except GraphExecutionError as e:
273
+ # Run on_error middleware
274
+ for mw in self._middleware:
275
+ if hasattr(mw, "on_error"):
276
+ await mw.on_error(current_node_name, e)
277
+ logger.error(
278
+ "Graph execution failed",
279
+ run_id=run_id,
280
+ failed_node=current_node_name,
281
+ error=str(e),
282
+ execution_path=exec_ctx.transition_history,
283
+ )
284
+ raise GraphExecutionError(
285
+ f"Graph execution failed at node '{current_node_name}': {e.message}",
286
+ graph_name=self._name,
287
+ cause=e.cause,
288
+ ) from e
289
+
290
+ # Run after_node middleware
291
+ for mw in self._middleware:
292
+ if hasattr(mw, "after_node"):
293
+ modified = await mw.after_node(current_node_name, current_data, current_data)
294
+ if modified is not None:
295
+ current_data = modified
296
+
297
+ # Plan intercept: detect PlannerNode by TYPE, not by output
298
+ if (
299
+ node.node_type == NodeType.PLANNER
300
+ and self._plan_executor is not None
301
+ ):
302
+ plan = ExecutionPlan.model_validate(current_data)
303
+
304
+ validation_errors = self._plan_executor.validate_plan(plan)
305
+ if validation_errors:
306
+ raise GraphExecutionError(
307
+ f"Plan validation failed: {'; '.join(validation_errors)}",
308
+ graph_name=self._name,
309
+ )
310
+
311
+ plan_result = await self._plan_executor.execute(
312
+ plan, exec_ctx
313
+ )
314
+ current_data = plan_result.model_dump()
315
+
316
+ # Resolve next node (may return str, list[str], or None)
317
+ try:
318
+ next_target = self._router.resolve(current_node_name, current_data)
319
+ except RoutingError as e:
320
+ logger.error(
321
+ "Routing failed",
322
+ run_id=run_id,
323
+ source_node=current_node_name,
324
+ error=str(e),
325
+ )
326
+ raise RoutingError(
327
+ f"Routing failed from node '{current_node_name}' in agent '{self._name}': {e.message}",
328
+ source_node=current_node_name,
329
+ available_targets=e.available_targets,
330
+ ) from e
331
+
332
+ if isinstance(next_target, list):
333
+ # Fan-out: execute parallel branches
334
+ fan_out_config = self._router.get_fan_out_config(current_node_name)
335
+ parallel_results = await self._execute_fan_out(
336
+ node_names=next_target,
337
+ input_data=current_data,
338
+ exec_ctx=exec_ctx,
339
+ on_error=fan_out_config.on_error if fan_out_config else "fail_all",
340
+ timeout_seconds=fan_out_config.timeout_seconds if fan_out_config else 300,
341
+ )
342
+ current_data = {
343
+ **current_data,
344
+ "parallel_results": parallel_results,
345
+ }
346
+ current_node_name = fan_out_config.join_node if fan_out_config else None
347
+ else:
348
+ current_node_name = next_target
349
+
350
+ # Step 4: Validate output
351
+ exec_ctx.current_node = None
352
+ validated_output = self._contract.validate_output(current_data)
353
+ elapsed_ms = (time.monotonic() - start_time) * 1000
354
+
355
+ logger.info(
356
+ "Agent execution completed",
357
+ run_id=run_id,
358
+ execution_path=exec_ctx.transition_history,
359
+ nodes_executed=len(exec_ctx.transition_history),
360
+ execution_time_ms=elapsed_ms,
361
+ )
362
+
363
+ # Step 5: Build success response
364
+ token_data = exec_ctx.token_accumulator.to_dict()
365
+ usage_list = exec_ctx.token_accumulator.to_usage_list()
366
+ response = AgentResponse(
367
+ content=validated_output.model_dump(),
368
+ status=ResponseStatus.SUCCESS,
369
+ agent_name=self._name,
370
+ agent_version=self._version,
371
+ execution_path=exec_ctx.transition_history,
372
+ execution_time_ms=elapsed_ms,
373
+ metadata={
374
+ "run_id": run_id,
375
+ "trace_id": message.trace_id,
376
+ },
377
+ usage=usage_list,
378
+ **token_data, # type: ignore[arg-type]
379
+ )
380
+
381
+ except AgentSDKError as e:
382
+ # Business errors — build structured error response
383
+ elapsed_ms = (time.monotonic() - start_time) * 1000
384
+ logger.error(
385
+ "Agent execution failed (business error)",
386
+ run_id=run_id,
387
+ error_type=type(e).__name__,
388
+ error=str(e),
389
+ execution_path=exec_ctx.transition_history,
390
+ )
391
+ token_data = exec_ctx.token_accumulator.to_dict()
392
+ usage_list = exec_ctx.token_accumulator.to_usage_list()
393
+ response = AgentResponse(
394
+ content={},
395
+ status=ResponseStatus.ERROR,
396
+ agent_name=self._name,
397
+ agent_version=self._version,
398
+ execution_path=exec_ctx.transition_history,
399
+ error_message=str(e),
400
+ execution_time_ms=elapsed_ms,
401
+ metadata={
402
+ "run_id": run_id,
403
+ "trace_id": message.trace_id,
404
+ "error_type": type(e).__name__,
405
+ },
406
+ usage=usage_list,
407
+ **token_data, # type: ignore[arg-type]
408
+ )
409
+
410
+ except Exception as e:
411
+ # Infrastructure errors — re-raise
412
+ logger.error(
413
+ "Agent execution failed (infrastructure error)",
414
+ run_id=run_id,
415
+ error_type=type(e).__name__,
416
+ error=str(e),
417
+ )
418
+ raise
419
+
420
+ finally:
421
+ # Step 6: Deliver response via workflow (before cleanup)
422
+ if response is not None and self._response_workflow is not None:
423
+ try:
424
+ await self._response_workflow.send_response(response, exec_ctx)
425
+ except Exception as wf_err:
426
+ logger.error(
427
+ "Response workflow failed",
428
+ run_id=run_id,
429
+ error_type=type(wf_err).__name__,
430
+ error=str(wf_err),
431
+ )
432
+
433
+ # Step 7: Clean up context
434
+ self._settings_resolver.clear()
435
+ set_execution_context(None)
436
+
437
+ assert response is not None # guaranteed by try/except structure
438
+ return response
439
+
440
+ async def stream_execute(
441
+ self, message: AgentMessage,
442
+ ) -> AsyncGenerator[StreamEvent, None]:
443
+ """Execute the agent graph with streaming.
444
+
445
+ Same flow as execute() but yields StreamEvent instances in real time.
446
+ For LLMNodes, text chunks are yielded as they arrive from the LLM.
447
+ For non-streaming nodes, only NodeStart/NodeComplete events are emitted.
448
+
449
+ If a StreamingResponseWorkflow is configured, on_stream_event() is
450
+ called for each event. send_response() is called at the end.
451
+
452
+ Args:
453
+ message: Typed input envelope containing domain data and metadata.
454
+
455
+ Yields:
456
+ StreamEvent instances for real-time visibility into execution.
457
+ """
458
+ from sofias_sdk_lite.agent.response_workflow import StreamingResponseWorkflow
459
+ from sofias_sdk_lite.agent.streaming import (
460
+ AgentCompleteEvent,
461
+ AgentErrorEvent,
462
+ NodeCompleteEvent,
463
+ StreamEvent,
464
+ TextChunkEvent,
465
+ )
466
+
467
+ run_id = str(uuid.uuid4())
468
+ start_time = time.monotonic()
469
+
470
+ exec_ctx = ExecutionContext(
471
+ execution_id=run_id,
472
+ agent_name=self._name,
473
+ agent_version=self._version,
474
+ trace_id=message.trace_id,
475
+ started_at=datetime.now(timezone.utc),
476
+ metadata={
477
+ **self._context,
478
+ "conversation_id": message.conversation_id or "",
479
+ "tenant_identifier": message.metadata.get("tenant_identifier", ""),
480
+ },
481
+ )
482
+ set_execution_context(exec_ctx)
483
+
484
+ logger.info(
485
+ "Agent streaming execution started",
486
+ run_id=run_id,
487
+ agent_name=self._name,
488
+ agent_version=self._version,
489
+ entry_node=self._entry_node,
490
+ total_nodes=len(self._nodes),
491
+ )
492
+
493
+ streaming_workflow: StreamingResponseWorkflow | None = (
494
+ self._response_workflow
495
+ if isinstance(self._response_workflow, StreamingResponseWorkflow)
496
+ else None
497
+ )
498
+ response: AgentResponse | None = None
499
+
500
+ try:
501
+ # Step 1: Resolve runtime config
502
+ if message.runtime_config is not None:
503
+ self._settings_resolver.resolve_and_set(message.runtime_config)
504
+ else:
505
+ self._settings_resolver.set_current(
506
+ self._settings_resolver.settings_class()
507
+ )
508
+
509
+ # Step 2: Validate input
510
+ validated_input = self._contract.validate_input(
511
+ message.content.model_dump()
512
+ )
513
+ current_data = validated_input.model_dump()
514
+ logger.debug("Input validation passed", run_id=run_id)
515
+
516
+ # Step 3: Execute graph with streaming
517
+ current_node_name = self._entry_node
518
+
519
+ while current_node_name is not None:
520
+ exec_ctx.current_node = current_node_name
521
+ exec_ctx.transition_history.append(current_node_name)
522
+
523
+ node = self._nodes.get(current_node_name)
524
+ if node is None:
525
+ logger.error(
526
+ "Node not found", run_id=run_id, node_name=current_node_name
527
+ )
528
+ raise GraphExecutionError(
529
+ f"Node '{current_node_name}' not found in agent graph",
530
+ graph_name=self._name,
531
+ )
532
+
533
+ # Run before_node middleware
534
+ for mw in self._middleware:
535
+ if hasattr(mw, "before_node"):
536
+ modified = await mw.before_node(current_node_name, current_data)
537
+ if modified is not None:
538
+ current_data = modified
539
+
540
+ # Stream through the node
541
+ is_terminal = current_node_name in self._router.terminals
542
+ node_output = None
543
+ async for event in node.stream_execute(current_data):
544
+ if streaming_workflow is not None:
545
+ # Only forward TextChunkEvents from terminal nodes to
546
+ # the workflow — intermediate LLMNode text (e.g. planner
547
+ # JSON) must not leak to the user stream.
548
+ if is_terminal or not isinstance(event, TextChunkEvent):
549
+ await streaming_workflow.on_stream_event(event, exec_ctx)
550
+ yield event
551
+ if isinstance(event, NodeCompleteEvent):
552
+ node_output = event.output
553
+
554
+ if node_output is None:
555
+ raise GraphExecutionError(
556
+ f"Node '{current_node_name}' produced no output",
557
+ graph_name=self._name,
558
+ )
559
+ current_data = node_output
560
+
561
+ # Run after_node middleware
562
+ for mw in self._middleware:
563
+ if hasattr(mw, "after_node"):
564
+ modified = await mw.after_node(current_node_name, current_data, current_data)
565
+ if modified is not None:
566
+ current_data = modified
567
+
568
+ # Plan intercept
569
+ if (
570
+ node.node_type == NodeType.PLANNER
571
+ and self._plan_executor is not None
572
+ ):
573
+ plan = ExecutionPlan.model_validate(current_data)
574
+ validation_errors = self._plan_executor.validate_plan(plan)
575
+ if validation_errors:
576
+ raise GraphExecutionError(
577
+ f"Plan validation failed: {'; '.join(validation_errors)}",
578
+ graph_name=self._name,
579
+ )
580
+ plan_result = await self._plan_executor.execute(plan, exec_ctx)
581
+ current_data = plan_result.model_dump()
582
+
583
+ # Resolve next node (may return str, list[str], or None)
584
+ try:
585
+ next_target = self._router.resolve(current_node_name, current_data)
586
+ except RoutingError as e:
587
+ logger.error(
588
+ "Routing failed",
589
+ run_id=run_id,
590
+ source_node=current_node_name,
591
+ error=str(e),
592
+ )
593
+ raise RoutingError(
594
+ f"Routing failed from node '{current_node_name}' in agent '{self._name}': {e.message}",
595
+ source_node=current_node_name,
596
+ available_targets=e.available_targets,
597
+ ) from e
598
+
599
+ if isinstance(next_target, list):
600
+ # Fan-out: execute parallel branches
601
+ fan_out_config = self._router.get_fan_out_config(current_node_name)
602
+ parallel_results = await self._execute_fan_out(
603
+ node_names=next_target,
604
+ input_data=current_data,
605
+ exec_ctx=exec_ctx,
606
+ on_error=fan_out_config.on_error if fan_out_config else "fail_all",
607
+ timeout_seconds=fan_out_config.timeout_seconds if fan_out_config else 300,
608
+ )
609
+ current_data = {
610
+ **current_data,
611
+ "parallel_results": parallel_results,
612
+ }
613
+ current_node_name = fan_out_config.join_node if fan_out_config else None
614
+ else:
615
+ current_node_name = next_target
616
+
617
+ # Step 4: Validate output
618
+ exec_ctx.current_node = None
619
+ validated_output = self._contract.validate_output(current_data)
620
+ elapsed_ms = (time.monotonic() - start_time) * 1000
621
+
622
+ logger.info(
623
+ "Agent streaming execution completed",
624
+ run_id=run_id,
625
+ execution_path=exec_ctx.transition_history,
626
+ nodes_executed=len(exec_ctx.transition_history),
627
+ execution_time_ms=elapsed_ms,
628
+ )
629
+
630
+ # Yield agent complete
631
+ token_data = exec_ctx.token_accumulator.to_dict()
632
+ usage_list = exec_ctx.token_accumulator.to_usage_list()
633
+ complete_event = AgentCompleteEvent(
634
+ content=validated_output.model_dump(),
635
+ execution_path=exec_ctx.transition_history,
636
+ execution_time_ms=elapsed_ms,
637
+ usage=usage_list,
638
+ **token_data, # type: ignore[arg-type]
639
+ )
640
+ if streaming_workflow is not None:
641
+ await streaming_workflow.on_stream_event(complete_event, exec_ctx)
642
+ yield complete_event
643
+
644
+ # Build and send final response
645
+ response = AgentResponse(
646
+ content=validated_output.model_dump(),
647
+ status=ResponseStatus.SUCCESS,
648
+ agent_name=self._name,
649
+ agent_version=self._version,
650
+ execution_path=exec_ctx.transition_history,
651
+ execution_time_ms=elapsed_ms,
652
+ metadata={
653
+ "run_id": run_id,
654
+ "trace_id": message.trace_id,
655
+ },
656
+ usage=usage_list,
657
+ **token_data, # type: ignore[arg-type]
658
+ )
659
+
660
+ except AgentSDKError as e:
661
+ elapsed_ms = (time.monotonic() - start_time) * 1000
662
+ logger.error(
663
+ "Agent streaming execution failed (business error)",
664
+ run_id=run_id,
665
+ error_type=type(e).__name__,
666
+ error=str(e),
667
+ execution_path=exec_ctx.transition_history,
668
+ )
669
+ error_event = AgentErrorEvent(
670
+ error_type=type(e).__name__,
671
+ error_message=str(e),
672
+ execution_path=exec_ctx.transition_history,
673
+ )
674
+ if streaming_workflow is not None:
675
+ await streaming_workflow.on_stream_event(error_event, exec_ctx)
676
+ yield error_event
677
+
678
+ token_data = exec_ctx.token_accumulator.to_dict()
679
+ usage_list = exec_ctx.token_accumulator.to_usage_list()
680
+ response = AgentResponse(
681
+ content={},
682
+ status=ResponseStatus.ERROR,
683
+ agent_name=self._name,
684
+ agent_version=self._version,
685
+ execution_path=exec_ctx.transition_history,
686
+ error_message=str(e),
687
+ execution_time_ms=elapsed_ms,
688
+ metadata={
689
+ "run_id": run_id,
690
+ "trace_id": message.trace_id,
691
+ "error_type": type(e).__name__,
692
+ },
693
+ usage=usage_list,
694
+ **token_data, # type: ignore[arg-type]
695
+ )
696
+
697
+ except Exception as e:
698
+ logger.error(
699
+ "Agent streaming execution failed (infrastructure error)",
700
+ run_id=run_id,
701
+ error_type=type(e).__name__,
702
+ error=str(e),
703
+ )
704
+ raise
705
+
706
+ finally:
707
+ if response is not None and self._response_workflow is not None:
708
+ try:
709
+ await self._response_workflow.send_response(response, exec_ctx)
710
+ except Exception as wf_err:
711
+ logger.error(
712
+ "Response workflow failed",
713
+ run_id=run_id,
714
+ error_type=type(wf_err).__name__,
715
+ error=str(wf_err),
716
+ )
717
+
718
+ self._settings_resolver.clear()
719
+ set_execution_context(None)
720
+
721
+ async def _execute_fan_out(
722
+ self,
723
+ node_names: list[str],
724
+ input_data: dict[str, Any],
725
+ exec_ctx: ExecutionContext,
726
+ on_error: str = "fail_all",
727
+ timeout_seconds: int = 300,
728
+ ) -> dict[str, dict[str, Any]]:
729
+ """Execute multiple nodes in parallel (fan-out).
730
+
731
+ Each node receives a copy of the input data. Results are collected
732
+ into a dictionary keyed by node name.
733
+
734
+ Args:
735
+ node_names: Names of nodes to execute concurrently.
736
+ input_data: Input data (copied to each branch).
737
+ exec_ctx: Execution context for tracking.
738
+ on_error: "fail_all" cancels remaining on first failure,
739
+ "continue_partial" waits for all and collects what succeeded.
740
+ timeout_seconds: Maximum time for all branches.
741
+
742
+ Returns:
743
+ Dictionary mapping node name to its output.
744
+
745
+ Raises:
746
+ ParallelExecutionError: If a branch fails under "fail_all" policy.
747
+ GraphExecutionError: If a node is not found.
748
+ """
749
+ logger.info(
750
+ "Fan-out execution started",
751
+ targets=node_names,
752
+ on_error=on_error,
753
+ timeout_seconds=timeout_seconds,
754
+ )
755
+
756
+ # Validate all nodes exist
757
+ for name in node_names:
758
+ if name not in self._nodes:
759
+ raise GraphExecutionError(
760
+ f"Fan-out target node '{name}' not found in agent graph",
761
+ graph_name=self._name,
762
+ )
763
+
764
+ # Track in execution context
765
+ exec_ctx.transition_history.append(
766
+ f"fan_out:[{','.join(node_names)}]"
767
+ )
768
+
769
+ # Launch all branches concurrently
770
+ running_tasks: dict[asyncio.Task[dict[str, Any]], str] = {}
771
+ for name in node_names:
772
+ node = self._nodes[name]
773
+ branch_input = dict(input_data) # shallow copy per branch
774
+
775
+ async def _run_branch(
776
+ n: str,
777
+ nd: Any,
778
+ inp: dict[str, Any],
779
+ ) -> dict[str, Any]:
780
+ return await self._error_handler.handle_node_execution(
781
+ node_name=n,
782
+ execute_fn=nd.execute,
783
+ input_data=inp,
784
+ )
785
+
786
+ task = asyncio.create_task(
787
+ _run_branch(name, node, branch_input),
788
+ name=f"fan_out_{name}",
789
+ )
790
+ running_tasks[task] = name
791
+
792
+ results: dict[str, dict[str, Any]] = {}
793
+ failed: dict[str, str] = {}
794
+
795
+ try:
796
+ done, pending = await asyncio.wait(
797
+ running_tasks.keys(),
798
+ timeout=timeout_seconds,
799
+ return_when=(
800
+ asyncio.FIRST_EXCEPTION
801
+ if on_error == "fail_all"
802
+ else asyncio.ALL_COMPLETED
803
+ ),
804
+ )
805
+
806
+ # Process completed tasks
807
+ for task in done:
808
+ name = running_tasks[task]
809
+ try:
810
+ results[name] = task.result()
811
+ exec_ctx.transition_history.append(name)
812
+ except Exception as e:
813
+ failed[name] = str(e)
814
+ if on_error == "fail_all":
815
+ # Cancel remaining tasks
816
+ for p in pending:
817
+ p.cancel()
818
+ raise ParallelExecutionError(
819
+ f"Fan-out branch '{name}' failed: {e}",
820
+ failed_nodes=failed,
821
+ completed_nodes=list(results.keys()),
822
+ cause=e,
823
+ ) from e
824
+
825
+ # Handle pending (timed out) tasks
826
+ if pending:
827
+ for task in pending:
828
+ task.cancel()
829
+ timed_out = [running_tasks[t] for t in pending]
830
+ if on_error == "fail_all":
831
+ raise ParallelExecutionError(
832
+ f"Fan-out timed out after {timeout_seconds}s. "
833
+ f"Pending: {timed_out}",
834
+ failed_nodes={n: "timeout" for n in timed_out},
835
+ completed_nodes=list(results.keys()),
836
+ )
837
+ for name in timed_out:
838
+ failed[name] = "timeout"
839
+
840
+ except ParallelExecutionError:
841
+ raise
842
+ except Exception as e:
843
+ # Cancel any remaining tasks on unexpected error
844
+ for task in running_tasks:
845
+ if not task.done():
846
+ task.cancel()
847
+ raise GraphExecutionError(
848
+ f"Unexpected error during fan-out execution: {e}",
849
+ graph_name=self._name,
850
+ cause=e,
851
+ ) from e
852
+
853
+ logger.info(
854
+ "Fan-out execution completed",
855
+ completed=list(results.keys()),
856
+ failed=list(failed.keys()) if failed else None,
857
+ )
858
+
859
+ return results
860
+
861
+ def get_graph_info(self) -> dict[str, Any]:
862
+ """Get information about the graph structure.
863
+
864
+ Useful for debugging, logging, and observability.
865
+
866
+ Returns:
867
+ Dictionary containing:
868
+ - name: Agent name
869
+ - version: Agent version
870
+ - description: Agent description
871
+ - entry_node: Name of the entry node
872
+ - nodes: List of all node names
873
+ - terminals: Set of terminal node names
874
+ - routes: Dictionary of source node to target nodes
875
+ - fallbacks: Dictionary of node to fallback node mappings
876
+ """
877
+ # Extract route information
878
+ routes: dict[str, list[str]] = {}
879
+ for source, strategy in self._router.routes.items():
880
+ targets = self._router._extract_strategy_targets(strategy)
881
+ routes[source] = targets
882
+
883
+ # Extract fallback information from error handler config
884
+ fallbacks = dict(self._error_handler.config.fallback.fallbacks)
885
+
886
+ return {
887
+ "name": self._name,
888
+ "version": self._version,
889
+ "description": self._description,
890
+ "entry_node": self._entry_node,
891
+ "nodes": list(self._nodes.keys()),
892
+ "terminals": list(self._router.terminals),
893
+ "routes": routes,
894
+ "fallbacks": fallbacks,
895
+ }
896
+
897
+ def __repr__(self) -> str:
898
+ """Return a string representation of the agent."""
899
+ return f"Agent(name={self._name!r}, version={self._version!r}, nodes={len(self._nodes)})"