agstack 1.4.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {agstack-1.4.0 → agstack-1.6.0}/PKG-INFO +2 -2
  2. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/__init__.py +4 -0
  3. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/flow.py +186 -197
  4. agstack-1.6.0/agstack/llm/flow/nodes/__init__.py +39 -0
  5. agstack-1.6.0/agstack/llm/flow/nodes/agent_node.py +55 -0
  6. agstack-1.6.0/agstack/llm/flow/nodes/base.py +59 -0
  7. agstack-1.6.0/agstack/llm/flow/nodes/detect_node.py +93 -0
  8. agstack-1.6.0/agstack/llm/flow/nodes/llm_chat_node.py +152 -0
  9. agstack-1.6.0/agstack/llm/flow/nodes/llm_embed_node.py +39 -0
  10. agstack-1.6.0/agstack/llm/flow/nodes/llm_rerank_node.py +49 -0
  11. agstack-1.4.0/agstack/llm/flow/sandbox.py → agstack-1.6.0/agstack/llm/flow/nodes/python_node.py +44 -8
  12. agstack-1.6.0/agstack/llm/flow/nodes/tool_node.py +40 -0
  13. agstack-1.6.0/agstack/llm/flow/sandbox.py +8 -0
  14. {agstack-1.4.0 → agstack-1.6.0}/agstack.egg-info/PKG-INFO +2 -2
  15. {agstack-1.4.0 → agstack-1.6.0}/agstack.egg-info/SOURCES.txt +9 -0
  16. {agstack-1.4.0 → agstack-1.6.0}/agstack.egg-info/requires.txt +1 -1
  17. {agstack-1.4.0 → agstack-1.6.0}/pyproject.toml +2 -2
  18. {agstack-1.4.0 → agstack-1.6.0}/LICENSE +0 -0
  19. {agstack-1.4.0 → agstack-1.6.0}/README.md +0 -0
  20. {agstack-1.4.0 → agstack-1.6.0}/agstack/__init__.py +0 -0
  21. {agstack-1.4.0 → agstack-1.6.0}/agstack/config/__init__.py +0 -0
  22. {agstack-1.4.0 → agstack-1.6.0}/agstack/config/logger.py +0 -0
  23. {agstack-1.4.0 → agstack-1.6.0}/agstack/config/manager.py +0 -0
  24. {agstack-1.4.0 → agstack-1.6.0}/agstack/config/types.py +0 -0
  25. {agstack-1.4.0 → agstack-1.6.0}/agstack/contexts.py +0 -0
  26. {agstack-1.4.0 → agstack-1.6.0}/agstack/decorators.py +0 -0
  27. {agstack-1.4.0 → agstack-1.6.0}/agstack/events.py +0 -0
  28. {agstack-1.4.0 → agstack-1.6.0}/agstack/exceptions.py +0 -0
  29. {agstack-1.4.0 → agstack-1.6.0}/agstack/fastapi/__init__.py +0 -0
  30. {agstack-1.4.0 → agstack-1.6.0}/agstack/fastapi/exception.py +0 -0
  31. {agstack-1.4.0 → agstack-1.6.0}/agstack/fastapi/middleware.py +0 -0
  32. {agstack-1.4.0 → agstack-1.6.0}/agstack/fastapi/offline.py +0 -0
  33. {agstack-1.4.0 → agstack-1.6.0}/agstack/fastapi/sse.py +0 -0
  34. {agstack-1.4.0 → agstack-1.6.0}/agstack/infra/db/__init__.py +0 -0
  35. {agstack-1.4.0 → agstack-1.6.0}/agstack/infra/es/__init__.py +0 -0
  36. {agstack-1.4.0 → agstack-1.6.0}/agstack/infra/kg/__init__.py +0 -0
  37. {agstack-1.4.0 → agstack-1.6.0}/agstack/infra/mq/__init__.py +0 -0
  38. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/__init__.py +0 -0
  39. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/client.py +0 -0
  40. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/agent.py +0 -0
  41. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/context.py +0 -0
  42. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/event.py +0 -0
  43. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/exceptions.py +0 -0
  44. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/factory.py +0 -0
  45. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/loader.py +0 -0
  46. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/records.py +0 -0
  47. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/registry.py +0 -0
  48. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/state.py +0 -0
  49. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/flow/tool.py +0 -0
  50. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/prompts.py +0 -0
  51. {agstack-1.4.0 → agstack-1.6.0}/agstack/llm/token.py +0 -0
  52. {agstack-1.4.0 → agstack-1.6.0}/agstack/registry.py +0 -0
  53. {agstack-1.4.0 → agstack-1.6.0}/agstack/schema.py +0 -0
  54. {agstack-1.4.0 → agstack-1.6.0}/agstack/security/__init__.py +0 -0
  55. {agstack-1.4.0 → agstack-1.6.0}/agstack/security/casbin.py +0 -0
  56. {agstack-1.4.0 → agstack-1.6.0}/agstack/security/crypt.py +0 -0
  57. {agstack-1.4.0 → agstack-1.6.0}/agstack/status.py +0 -0
  58. {agstack-1.4.0 → agstack-1.6.0}/agstack.egg-info/dependency_links.txt +0 -0
  59. {agstack-1.4.0 → agstack-1.6.0}/agstack.egg-info/top_level.txt +0 -0
  60. {agstack-1.4.0 → agstack-1.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: Production-ready toolkit for building FastAPI and LLM applications
5
5
  Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
6
6
  Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
@@ -35,7 +35,7 @@ Requires-Dist: python-multipart>=0.0.20
35
35
  Requires-Dist: requests>=2.32.5
36
36
  Requires-Dist: RestrictedPython>=7.0
37
37
  Requires-Dist: sqlalchemy[asyncio]>=2.0.48
38
- Requires-Dist: sqlobjects>=1.3.0
38
+ Requires-Dist: sqlobjects>=1.4.0
39
39
  Requires-Dist: tiktoken>=0.12.0
40
40
  Requires-Dist: uvicorn>=0.41.0
41
41
  Dynamic: license-file
@@ -18,6 +18,7 @@ from .exceptions import (
18
18
  from .factory import create_agent, create_tool
19
19
  from .flow import Flow
20
20
  from .loader import FlowLoader
21
+ from .nodes import NodeHandler, register_node_handler
21
22
  from .records import Record, Status
22
23
  from .registry import registry
23
24
  from .state import FlowState
@@ -32,6 +33,9 @@ __all__ = [
32
33
  "Flow",
33
34
  "FlowContext",
34
35
  "Usage",
36
+ # 节点处理器
37
+ "NodeHandler",
38
+ "register_node_handler",
35
39
  # AG-UI 协议
36
40
  "EventType",
37
41
  "event",
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2020-2025 XtraVisions, All rights reserved.
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
2
 
3
3
  """Flow 定义和执行"""
4
4
 
@@ -9,12 +9,21 @@ from typing import TYPE_CHECKING, Any, AsyncIterator
9
9
  from uuid import uuid4
10
10
 
11
11
  from . import event
12
- from .exceptions import FlowError
13
- from .registry import registry
12
+ from .exceptions import NodeExecutionError
14
13
 
15
14
 
16
15
  if TYPE_CHECKING:
17
16
  from .context import FlowContext
17
+ from .nodes.base import NodeHandler
18
+
19
+
20
+ @dataclass
21
+ class RetryPolicy:
22
+ """节点重试策略"""
23
+
24
+ max_retries: int = 0 # 0 = 不重试
25
+ delay: float = 1.0 # 初始延迟(秒)
26
+ backoff: float = 2.0 # 退避倍数
18
27
 
19
28
 
20
29
  class _SafeFormatDict(dict):
@@ -35,6 +44,31 @@ class Flow:
35
44
  edges: list[dict[str, Any]] = field(default_factory=list)
36
45
  variables: dict[str, Any] = field(default_factory=dict)
37
46
 
47
+ _node_handlers: dict[str, "NodeHandler"] = field(default_factory=dict, init=False, repr=False)
48
+
49
+ def __post_init__(self) -> None:
50
+ from .nodes import _global_node_handlers, builtin_handlers
51
+
52
+ for handler in builtin_handlers:
53
+ self._node_handlers[handler.node_type] = handler
54
+ # 全局注册的自定义 handler 可覆盖内置
55
+ self._node_handlers.update(_global_node_handlers)
56
+
57
+ # ── 重试策略 ──
58
+
59
+ @staticmethod
60
+ def _get_retry_policy(node: dict) -> RetryPolicy:
61
+ """从节点 config 解析重试策略"""
62
+ config = node.get("config", {})
63
+ retry_cfg = config.get("retry", {})
64
+ if not retry_cfg:
65
+ return RetryPolicy()
66
+ return RetryPolicy(
67
+ max_retries=retry_cfg.get("max_retries", 0),
68
+ delay=retry_cfg.get("delay", 1.0),
69
+ backoff=retry_cfg.get("backoff", 2.0),
70
+ )
71
+
38
72
  # ── 边驱动路由 ──
39
73
 
40
74
  def _resolve_next_node(self, current_id: str, result: str | None = None) -> str | None:
@@ -46,33 +80,22 @@ class Flow:
46
80
  return edge.get("target")
47
81
  return None
48
82
 
49
- # ── condition 节点 ──
50
-
51
- async def _evaluate_condition(self, node: dict, context: "FlowContext") -> str:
52
- """调用 LLM 判断条件是否匹配"""
53
- config = node.get("config", {})
54
- topic = config.get("topic", "")
55
- query = context.get_variable("query", "")
56
-
57
- prompt = (
58
- f"判断以下问题是否属于「{topic}」相关问题。\n"
59
- f"问题:{query}\n"
60
- f'仅回复 JSON:{{"result": "match"}} 或 {{"result": "reject"}}'
61
- )
62
-
63
- from ..client import get_llm_client
83
+ @staticmethod
84
+ def _extract_route_key(result: Any) -> str:
85
+ """从节点执行结果中提取路由键。
64
86
 
65
- client = get_llm_client()
66
- response = await client.chat(
67
- messages=[{"role": "user", "content": prompt}],
68
- model=config.get("model", "gpt-4o-mini"),
69
- temperature=0,
70
- )
71
- text = response.choices[0].message.content or ""
87
+ 支持 ``{"result": "qa"}`` 形式的 JSON 字符串,
88
+ 以及纯字符串结果。
89
+ """
90
+ if not isinstance(result, str):
91
+ return "done"
72
92
  try:
73
- return _json.loads(text).get("result", "reject")
74
- except Exception:
75
- return "match" if "match" in text.lower() else "reject"
93
+ parsed = _json.loads(result)
94
+ if isinstance(parsed, dict) and "result" in parsed:
95
+ return str(parsed["result"])
96
+ except (ValueError, TypeError):
97
+ pass
98
+ return result or "done"
76
99
 
77
100
  # ── message 节点 ──
78
101
 
@@ -86,6 +109,62 @@ class Flow:
86
109
  yield event.text_message_content(message_id=msg_id, delta=text)
87
110
  yield event.text_message_end(message_id=msg_id)
88
111
 
112
+ # ── 带重试的节点执行(统一走 NodeHandler) ──
113
+
114
+ async def _execute_node_with_retry(
115
+ self,
116
+ node: dict,
117
+ context: "FlowContext",
118
+ node_id: str,
119
+ ) -> AsyncIterator[dict[str, Any]]:
120
+ """执行节点,带重试策略,产出 AG-UI 事件"""
121
+ node_type: str = node.get("type", "")
122
+ handler = self._node_handlers.get(node_type)
123
+ if not handler:
124
+ yield event.run_error(
125
+ message=f"Unknown node type: {node_type}",
126
+ code="UNKNOWN_NODE_TYPE",
127
+ )
128
+ raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
129
+
130
+ policy = self._get_retry_policy(node)
131
+ label = handler.get_step_name(node, node_id)
132
+ last_error: Exception | None = None
133
+
134
+ for attempt in range(policy.max_retries + 1):
135
+ try:
136
+ if attempt > 0:
137
+ wait = policy.delay * (policy.backoff ** (attempt - 1))
138
+ await asyncio.sleep(wait)
139
+ yield event.custom(
140
+ name="node_retry",
141
+ value={
142
+ "nodeId": node_id,
143
+ "nodeType": node_type,
144
+ "label": label,
145
+ "attempt": attempt + 1,
146
+ "maxAttempts": policy.max_retries + 1,
147
+ "error": str(last_error),
148
+ },
149
+ )
150
+
151
+ async for evt in handler.stream(node, context, node_id):
152
+ yield evt
153
+ return
154
+
155
+ except Exception as e:
156
+ last_error = e
157
+ if attempt < policy.max_retries:
158
+ continue
159
+ yield event.run_error(
160
+ message=str(e),
161
+ code=type(e).__name__,
162
+ )
163
+ raise NodeExecutionError(
164
+ "NODE_EXECUTION_FAILED",
165
+ args={"node_id": node_id, "error": str(e)},
166
+ ) from e
167
+
89
168
  # ── 执行入口 ──
90
169
 
91
170
  async def run(self, context: "FlowContext") -> dict[str, Any]:
@@ -97,8 +176,13 @@ class Flow:
97
176
  if not node_id:
98
177
  continue
99
178
  context.current_node = node_id
100
- result = await self._execute_node(node, context)
101
- context.set_node_result(node_id, result)
179
+ node_type: str = node.get("type", "")
180
+ handler = self._node_handlers.get(node_type)
181
+ if handler:
182
+ result = await handler.execute(node, context)
183
+ context.set_node_result(node_id, result)
184
+ else:
185
+ raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
102
186
  else:
103
187
  # edge 驱动执行
104
188
  current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
@@ -107,22 +191,14 @@ class Flow:
107
191
  if not node:
108
192
  break
109
193
  context.current_node = current_node_id
110
- node_type = node.get("type")
194
+ node_type: str = node.get("type", "")
111
195
 
112
- if node_type == "condition":
113
- result = await self._evaluate_condition(node, context)
114
- context.set_node_result(current_node_id, result)
115
- current_node_id = self._resolve_next_node(current_node_id, result)
116
- elif node_type == "message":
196
+ if node_type == "message":
117
197
  config = node.get("config", {})
118
198
  template = config.get("content", "")
119
199
  text = template.format_map(_SafeFormatDict(context.variables))
120
200
  context.set_node_result(current_node_id, text)
121
201
  current_node_id = self._resolve_next_node(current_node_id, "done")
122
- elif node_type in ("agent", "tool"):
123
- result = await self._execute_node(node, context)
124
- context.set_node_result(current_node_id, result)
125
- current_node_id = self._resolve_next_node(current_node_id, "done")
126
202
 
127
203
  elif node_type == "parallel":
128
204
  config = node.get("config", {})
@@ -133,9 +209,11 @@ class Flow:
133
209
  if not branch_node:
134
210
  return
135
211
  context.current_node = branch_id
136
- self._set_parameters(branch_node.get("config", {}), context)
137
- result = await self._execute_node(branch_node, context)
138
- context.set_node_result(branch_id, result)
212
+ branch_type: str = branch_node.get("type", "")
213
+ branch_handler = self._node_handlers.get(branch_type)
214
+ if branch_handler:
215
+ result = await branch_handler.execute(branch_node, context)
216
+ context.set_node_result(branch_id, result)
139
217
 
140
218
  await asyncio.gather(*[_run_branch(bid) for bid in branches])
141
219
  context.set_node_result(current_node_id, "done")
@@ -163,9 +241,11 @@ class Flow:
163
241
  body_node = self.get_node_config(body_node_id)
164
242
  if not body_node:
165
243
  continue
166
- self._set_parameters(body_node.get("config", {}), context)
167
- body_result = await self._execute_node(body_node, context)
168
- context.set_node_result(body_node_id, body_result)
244
+ body_type: str = body_node.get("type", "")
245
+ body_handler = self._node_handlers.get(body_type)
246
+ if body_handler:
247
+ body_result = await body_handler.execute(body_node, context)
248
+ context.set_node_result(body_node_id, body_result)
169
249
  if body_nodes:
170
250
  results.append(context.node_results.get(body_nodes[-1]))
171
251
 
@@ -187,9 +267,11 @@ class Flow:
187
267
  body_node = self.get_node_config(body_node_id)
188
268
  if not body_node:
189
269
  continue
190
- self._set_parameters(body_node.get("config", {}), context)
191
- body_result = await self._execute_node(body_node, context)
192
- context.set_node_result(body_node_id, body_result)
270
+ body_type: str = body_node.get("type", "")
271
+ body_handler = self._node_handlers.get(body_type)
272
+ if body_handler:
273
+ body_result = await body_handler.execute(body_node, context)
274
+ context.set_node_result(body_node_id, body_result)
193
275
  if condition_node_id:
194
276
  cond_result = context.node_results.get(condition_node_id, "")
195
277
  if isinstance(cond_result, str):
@@ -204,27 +286,18 @@ class Flow:
204
286
  context.set_node_result(current_node_id, "done")
205
287
  current_node_id = self._resolve_next_node(current_node_id, "done")
206
288
 
207
- elif node_type == "python":
208
- config = node.get("config", {})
209
- inputs_spec: dict[str, Any] = config.get("inputs", {})
210
- resolved_inputs: dict[str, Any] = {}
211
- for key, ref in inputs_spec.items():
212
- resolved_inputs[key] = context.resolve_reference(ref) if isinstance(ref, str) else ref
213
-
214
- from .sandbox import execute_python_node
215
-
216
- code_str = config.get("code", "")
217
- py_result = execute_python_node(code_str, resolved_inputs)
218
-
219
- outputs_spec: dict[str, Any] = config.get("outputs", {})
220
- for key in outputs_spec:
221
- if key in py_result:
222
- context.set_variable(key, py_result[key])
289
+ elif node_type in self._node_handlers:
290
+ # 所有执行类节点统一分发
291
+ handler = self._node_handlers[node_type]
292
+ result = await handler.execute(node, context)
293
+ context.set_node_result(current_node_id, result)
294
+ route_key = self._extract_route_key(result)
295
+ current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
296
+ current_node_id, "done"
297
+ )
223
298
 
224
- context.set_node_result(current_node_id, _json.dumps(py_result, ensure_ascii=False))
225
- current_node_id = self._resolve_next_node(current_node_id, "done")
226
299
  else:
227
- break
300
+ raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
228
301
 
229
302
  return context.node_results
230
303
 
@@ -251,24 +324,17 @@ class Flow:
251
324
  continue
252
325
 
253
326
  context.current_node = node_id
254
- yield event.step_started(step_name=f"node:{node_id}")
255
-
256
- if node.get("type") == "agent":
257
- agent_name = node.get("config", {}).get("agent_name", "")
258
- yield event.step_started(step_name=f"agent:{agent_name}")
259
- self._set_parameters(node.get("config", {}), context)
260
- ag = self._create_agent(node.get("config", {}))
261
- async for evt in ag.stream(context):
327
+ node_type: str = node.get("type", "")
328
+
329
+ if node_type in self._node_handlers:
330
+ async for evt in self._execute_node_with_retry(node, context, node_id):
262
331
  yield evt
263
- result = context.get_last_output(ag.name) or ""
264
- context.set_node_result(node_id, result)
265
- yield event.step_finished(step_name=f"agent:{agent_name}")
266
332
  else:
267
- tool_name = node.get("config", {}).get("tool_name", "")
268
- yield event.step_started(step_name=f"tool:{tool_name}")
269
- result = await self._execute_node(node, context)
270
- context.set_node_result(node_id, result)
271
- yield event.step_finished(step_name=f"tool:{tool_name}")
333
+ yield event.run_error(
334
+ message=f"Unknown node type: {node_type}",
335
+ code="UNKNOWN_NODE_TYPE",
336
+ )
337
+ raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
272
338
 
273
339
  async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
274
340
  """边驱动流式执行"""
@@ -277,41 +343,20 @@ class Flow:
277
343
  while current_node_id:
278
344
  node = self.get_node_config(current_node_id)
279
345
  if not node:
280
- break
346
+ yield event.run_error(
347
+ message=f"Node not found: {current_node_id}",
348
+ code="NODE_NOT_FOUND",
349
+ )
350
+ raise NodeExecutionError("NODE_NOT_FOUND", args={"node_id": current_node_id})
281
351
 
282
352
  context.current_node = current_node_id
283
- node_type = node.get("type")
353
+ node_type: str = node.get("type", "")
284
354
 
285
- if node_type == "condition":
286
- result = await self._evaluate_condition(node, context)
287
- context.set_node_result(current_node_id, result)
288
- current_node_id = self._resolve_next_node(current_node_id, result)
289
-
290
- elif node_type == "message":
355
+ if node_type == "message":
291
356
  async for evt in self._emit_message(node, context):
292
357
  yield evt
293
358
  current_node_id = self._resolve_next_node(current_node_id, "done")
294
359
 
295
- elif node_type == "agent":
296
- agent_name = node.get("config", {}).get("agent_name", "")
297
- yield event.step_started(step_name=f"agent:{agent_name}")
298
- self._set_parameters(node.get("config", {}), context)
299
- ag = self._create_agent(node.get("config", {}))
300
- async for evt in ag.stream(context):
301
- yield evt
302
- result = context.get_last_output(ag.name) or ""
303
- context.set_node_result(current_node_id, result)
304
- yield event.step_finished(step_name=f"agent:{agent_name}")
305
- current_node_id = self._resolve_next_node(current_node_id, "done")
306
-
307
- elif node_type == "tool":
308
- tool_name = node.get("config", {}).get("tool_name", "")
309
- yield event.step_started(step_name=f"tool:{tool_name}")
310
- result = await self._execute_node(node, context)
311
- context.set_node_result(current_node_id, result)
312
- yield event.step_finished(step_name=f"tool:{tool_name}")
313
- current_node_id = self._resolve_next_node(current_node_id, "done")
314
-
315
360
  elif node_type == "parallel":
316
361
  config = node.get("config", {})
317
362
  branches = config.get("branches", [])
@@ -322,9 +367,11 @@ class Flow:
322
367
  if not branch_node:
323
368
  return
324
369
  context.current_node = branch_id
325
- self._set_parameters(branch_node.get("config", {}), context)
326
- result = await self._execute_node(branch_node, context)
327
- context.set_node_result(branch_id, result)
370
+ branch_type = branch_node.get("type", "")
371
+ branch_handler = self._node_handlers.get(branch_type)
372
+ if branch_handler:
373
+ result = await branch_handler.execute(branch_node, context)
374
+ context.set_node_result(branch_id, result)
328
375
 
329
376
  await asyncio.gather(*[_exec_branch(bid) for bid in branches])
330
377
  context.set_node_result(current_node_id, "done")
@@ -354,9 +401,11 @@ class Flow:
354
401
  body_node = self.get_node_config(body_node_id)
355
402
  if not body_node:
356
403
  continue
357
- self._set_parameters(body_node.get("config", {}), context)
358
- body_result = await self._execute_node(body_node, context)
359
- context.set_node_result(body_node_id, body_result)
404
+ body_type = body_node.get("type", "")
405
+ body_handler = self._node_handlers.get(body_type)
406
+ if body_handler:
407
+ body_result = await body_handler.execute(body_node, context)
408
+ context.set_node_result(body_node_id, body_result)
360
409
  if body_nodes:
361
410
  results.append(context.node_results.get(body_nodes[-1]))
362
411
 
@@ -380,9 +429,11 @@ class Flow:
380
429
  body_node = self.get_node_config(body_node_id)
381
430
  if not body_node:
382
431
  continue
383
- self._set_parameters(body_node.get("config", {}), context)
384
- body_result = await self._execute_node(body_node, context)
385
- context.set_node_result(body_node_id, body_result)
432
+ body_type = body_node.get("type", "")
433
+ body_handler = self._node_handlers.get(body_type)
434
+ if body_handler:
435
+ body_result = await body_handler.execute(body_node, context)
436
+ context.set_node_result(body_node_id, body_result)
386
437
  # 检查终止条件
387
438
  if condition_node_id:
388
439
  cond_result = context.node_results.get(condition_node_id, "")
@@ -399,84 +450,22 @@ class Flow:
399
450
  yield event.step_finished(step_name=f"loop:{current_node_id}")
400
451
  current_node_id = self._resolve_next_node(current_node_id, "done")
401
452
 
402
- elif node_type == "python":
403
- config = node.get("config", {})
404
- yield event.step_started(step_name=f"python:{current_node_id}")
405
-
406
- # 解析 inputs
407
- inputs_spec: dict[str, Any] = config.get("inputs", {})
408
- resolved_inputs: dict[str, Any] = {}
409
- for key, ref in inputs_spec.items():
410
- resolved_inputs[key] = context.resolve_reference(ref) if isinstance(ref, str) else ref
411
-
412
- # 沙箱执行
413
- from .sandbox import execute_python_node
414
-
415
- code_str = config.get("code", "")
416
- py_result = execute_python_node(code_str, resolved_inputs)
417
-
418
- # 映射 outputs 到 context.variables
419
- outputs_spec: dict[str, Any] = config.get("outputs", {})
420
- for key in outputs_spec:
421
- if key in py_result:
422
- context.set_variable(key, py_result[key])
423
-
424
- context.set_node_result(current_node_id, _json.dumps(py_result, ensure_ascii=False))
425
- yield event.step_finished(step_name=f"python:{current_node_id}")
426
- current_node_id = self._resolve_next_node(current_node_id, "done")
453
+ elif node_type in self._node_handlers:
454
+ # 所有执行类节点统一分发
455
+ async for evt in self._execute_node_with_retry(node, context, current_node_id):
456
+ yield evt
457
+ result = context.node_results.get(current_node_id, "")
458
+ route_key = self._extract_route_key(result)
459
+ current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
460
+ current_node_id, "done"
461
+ )
427
462
 
428
463
  else:
429
- break
430
-
431
- async def _execute_node(self, node_config: dict, context: "FlowContext") -> Any:
432
- """执行节点"""
433
- node_type = node_config.get("type")
434
- config = node_config.get("config", {})
435
-
436
- # 设置参数到 context
437
- self._set_parameters(config, context)
438
-
439
- # 创建并执行 runnable
440
- if node_type == "agent":
441
- runnable = self._create_agent(config)
442
- elif node_type == "tool":
443
- runnable = self._create_tool(config)
444
- else:
445
- raise FlowError("UNKNOWN_NODE_TYPE", 400, {"type": node_type})
446
-
447
- return await runnable.run(context)
448
-
449
- def _set_parameters(self, config: dict, context: "FlowContext") -> None:
450
- """设置参数到 context"""
451
- parameters = config.get("parameters", {})
452
-
453
- for key, value in parameters.items():
454
- resolved_value = context.resolve_reference(value) if isinstance(value, str) else value
455
- context.set_variable(key, resolved_value)
456
-
457
- def _create_agent(self, config: dict):
458
- """创建 Agent"""
459
- agent_name = config.get("agent_name")
460
- if not agent_name:
461
- raise FlowError("MISSING_AGENT_NAME", 400)
462
-
463
- agent = registry.create_agent(agent_name)
464
- if not agent:
465
- raise FlowError("AGENT_NOT_FOUND", 404, {"agent_name": agent_name})
466
-
467
- return agent
468
-
469
- def _create_tool(self, config: dict):
470
- """创建 Tool"""
471
- tool_name = config.get("tool_name")
472
- if not tool_name:
473
- raise FlowError("MISSING_TOOL_NAME", 400)
474
-
475
- tool = registry.create_tool(tool_name)
476
- if not tool:
477
- raise FlowError("TOOL_NOT_FOUND", 404, {"tool_name": tool_name})
478
-
479
- return tool
464
+ yield event.run_error(
465
+ message=f"Unknown node type: {node_type}",
466
+ code="UNKNOWN_NODE_TYPE",
467
+ )
468
+ raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
480
469
 
481
470
  def get_node_config(self, node_id: str) -> dict[str, Any] | None:
482
471
  """获取节点配置"""
@@ -0,0 +1,39 @@
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
+
3
+ """内置节点处理器注册"""
4
+
5
+ from .agent_node import AgentNodeHandler
6
+ from .base import NodeHandler
7
+ from .detect_node import DetectNodeHandler
8
+ from .llm_chat_node import LLMChatNodeHandler
9
+ from .llm_embed_node import LLMEmbedNodeHandler
10
+ from .llm_rerank_node import LLMRerankNodeHandler
11
+ from .python_node import PythonNodeHandler
12
+ from .tool_node import ToolNodeHandler
13
+
14
+
15
+ # 所有内置 handler 实例
16
+ builtin_handlers: list[NodeHandler] = [
17
+ AgentNodeHandler(),
18
+ ToolNodeHandler(),
19
+ PythonNodeHandler(),
20
+ LLMChatNodeHandler(),
21
+ LLMEmbedNodeHandler(),
22
+ LLMRerankNodeHandler(),
23
+ DetectNodeHandler(),
24
+ ]
25
+
26
+ # 全局自定义节点注册
27
+ _global_node_handlers: dict[str, NodeHandler] = {}
28
+
29
+
30
+ def register_node_handler(node_type: str, handler: NodeHandler) -> None:
31
+ """注册自定义节点处理器(全局,所有 Flow 实例共享)"""
32
+ _global_node_handlers[node_type] = handler
33
+
34
+
35
+ __all__ = [
36
+ "NodeHandler",
37
+ "builtin_handlers",
38
+ "register_node_handler",
39
+ ]
@@ -0,0 +1,55 @@
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
+
3
+ """Agent 节点处理器 — 从 flow.py 提取"""
4
+
5
+ from typing import TYPE_CHECKING, Any, AsyncIterator
6
+
7
+ from .. import event
8
+ from ..exceptions import FlowError
9
+ from ..registry import registry
10
+ from .base import NodeHandler
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from ..context import FlowContext
15
+
16
+
17
+ class AgentNodeHandler(NodeHandler):
18
+ """Agent 节点:通过 registry 查找 agent → ag.stream(context)"""
19
+
20
+ node_type = "agent"
21
+
22
+ def _set_parameters(self, config: dict, context: "FlowContext") -> None:
23
+ parameters = config.get("parameters", {})
24
+ for key, value in parameters.items():
25
+ resolved = context.resolve_reference(value) if isinstance(value, str) else value
26
+ context.set_variable(key, resolved)
27
+
28
+ def _create_agent(self, config: dict):
29
+ agent_name = config.get("agent_name")
30
+ if not agent_name:
31
+ raise FlowError("MISSING_AGENT_NAME", 400)
32
+ agent = registry.create_agent(agent_name)
33
+ if not agent:
34
+ raise FlowError("AGENT_NOT_FOUND", 404, {"agent_name": agent_name})
35
+ return agent
36
+
37
+ async def execute(self, node: dict, context: "FlowContext") -> Any:
38
+ config = node.get("config", {})
39
+ self._set_parameters(config, context)
40
+ ag = self._create_agent(config)
41
+ return await ag.run(context)
42
+
43
+ async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
44
+ config = node.get("config", {})
45
+ step_name = self.get_step_name(node, node_id)
46
+
47
+ yield event.step_started(step_name=step_name)
48
+ self._set_parameters(config, context)
49
+ ag = self._create_agent(config)
50
+ async for evt in ag.stream(context):
51
+ yield evt
52
+ result = context.get_last_output(ag.name) or ""
53
+ context.set_node_result(node_id, result)
54
+ self.map_outputs(config, context, {"result": result})
55
+ yield event.step_finished(step_name=step_name)