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.
- sofias_sdk_lite/__init__.py +262 -0
- sofias_sdk_lite/agent/__init__.py +67 -0
- sofias_sdk_lite/agent/agent.py +899 -0
- sofias_sdk_lite/agent/agent_builder.py +1221 -0
- sofias_sdk_lite/agent/agent_config.py +87 -0
- sofias_sdk_lite/agent/execution_context.py +222 -0
- sofias_sdk_lite/agent/middleware.py +118 -0
- sofias_sdk_lite/agent/response_workflow.py +232 -0
- sofias_sdk_lite/agent/streaming.py +119 -0
- sofias_sdk_lite/config/__init__.py +63 -0
- sofias_sdk_lite/config/agent_settings.py +72 -0
- sofias_sdk_lite/config/defaults.py +78 -0
- sofias_sdk_lite/config/runtime_config.py +201 -0
- sofias_sdk_lite/config/sdk_config.py +105 -0
- sofias_sdk_lite/config/source.py +168 -0
- sofias_sdk_lite/contracts/__init__.py +48 -0
- sofias_sdk_lite/contracts/agent_contracts.py +174 -0
- sofias_sdk_lite/contracts/base_contracts.py +115 -0
- sofias_sdk_lite/contracts/node_contracts.py +177 -0
- sofias_sdk_lite/contracts/strict_contract.py +34 -0
- sofias_sdk_lite/errors/__init__.py +76 -0
- sofias_sdk_lite/errors/circuit_breaker.py +150 -0
- sofias_sdk_lite/errors/error_handler.py +413 -0
- sofias_sdk_lite/errors/exceptions.py +408 -0
- sofias_sdk_lite/llm/__init__.py +27 -0
- sofias_sdk_lite/llm/protocol.py +92 -0
- sofias_sdk_lite/llm/tokens.py +19 -0
- sofias_sdk_lite/llm/tools.py +36 -0
- sofias_sdk_lite/messaging/__init__.py +47 -0
- sofias_sdk_lite/messaging/models.py +516 -0
- sofias_sdk_lite/nodes/__init__.py +104 -0
- sofias_sdk_lite/nodes/aggregator_config.py +78 -0
- sofias_sdk_lite/nodes/aggregator_node.py +390 -0
- sofias_sdk_lite/nodes/base_node.py +211 -0
- sofias_sdk_lite/nodes/delegation_config.py +281 -0
- sofias_sdk_lite/nodes/delegation_node.py +1120 -0
- sofias_sdk_lite/nodes/delegation_transport.py +228 -0
- sofias_sdk_lite/nodes/function_node.py +289 -0
- sofias_sdk_lite/nodes/llm_node.py +1451 -0
- sofias_sdk_lite/nodes/llm_node_config.py +266 -0
- sofias_sdk_lite/nodes/plan_executor.py +434 -0
- sofias_sdk_lite/nodes/planner_node.py +206 -0
- sofias_sdk_lite/nodes/planning_models.py +137 -0
- sofias_sdk_lite/nodes/prompt_assembler.py +266 -0
- sofias_sdk_lite/observability/__init__.py +47 -0
- sofias_sdk_lite/observability/_log.py +43 -0
- sofias_sdk_lite/observability/events.py +438 -0
- sofias_sdk_lite/observability/tracing.py +619 -0
- sofias_sdk_lite/py.typed +0 -0
- sofias_sdk_lite/rabbitmq/__init__.py +33 -0
- sofias_sdk_lite/rabbitmq/client.py +105 -0
- sofias_sdk_lite/rabbitmq/config.py +33 -0
- sofias_sdk_lite/rabbitmq/consumer.py +149 -0
- sofias_sdk_lite/rabbitmq/delegation_transport.py +400 -0
- sofias_sdk_lite/rabbitmq/publisher.py +273 -0
- sofias_sdk_lite/rabbitmq/rpc_client.py +338 -0
- sofias_sdk_lite/rabbitmq/types.py +68 -0
- sofias_sdk_lite/routing/__init__.py +27 -0
- sofias_sdk_lite/routing/router.py +301 -0
- sofias_sdk_lite/routing/strategies.py +485 -0
- sofias_sdk_lite/runner/__init__.py +8 -0
- sofias_sdk_lite/runner/config.py +43 -0
- sofias_sdk_lite/runner/runner.py +243 -0
- sofias_sdk_lite/state/__init__.py +26 -0
- sofias_sdk_lite/state/conversation_state.py +259 -0
- sofias_sdk_lite/state/history.py +104 -0
- sofias_sdk_lite/state/memory.py +123 -0
- sofias_sdk_lite/tools.py +482 -0
- sofias_sdk_lite/workflows/__init__.py +28 -0
- sofias_sdk_lite/workflows/chat.py +113 -0
- sofias_sdk_lite/workflows/null.py +35 -0
- sofias_sdk_lite-0.1.0.dist-info/METADATA +151 -0
- sofias_sdk_lite-0.1.0.dist-info/RECORD +74 -0
- 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)})"
|