agstack 1.3.0__tar.gz → 1.5.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 (52) hide show
  1. {agstack-1.3.0 → agstack-1.5.0}/PKG-INFO +3 -2
  2. agstack-1.5.0/agstack/llm/flow/flow.py +551 -0
  3. agstack-1.5.0/agstack/llm/flow/sandbox.py +68 -0
  4. {agstack-1.3.0 → agstack-1.5.0}/agstack.egg-info/PKG-INFO +3 -2
  5. {agstack-1.3.0 → agstack-1.5.0}/agstack.egg-info/SOURCES.txt +1 -0
  6. {agstack-1.3.0 → agstack-1.5.0}/agstack.egg-info/requires.txt +2 -1
  7. {agstack-1.3.0 → agstack-1.5.0}/pyproject.toml +3 -2
  8. agstack-1.3.0/agstack/llm/flow/flow.py +0 -272
  9. {agstack-1.3.0 → agstack-1.5.0}/LICENSE +0 -0
  10. {agstack-1.3.0 → agstack-1.5.0}/README.md +0 -0
  11. {agstack-1.3.0 → agstack-1.5.0}/agstack/__init__.py +0 -0
  12. {agstack-1.3.0 → agstack-1.5.0}/agstack/config/__init__.py +0 -0
  13. {agstack-1.3.0 → agstack-1.5.0}/agstack/config/logger.py +0 -0
  14. {agstack-1.3.0 → agstack-1.5.0}/agstack/config/manager.py +0 -0
  15. {agstack-1.3.0 → agstack-1.5.0}/agstack/config/types.py +0 -0
  16. {agstack-1.3.0 → agstack-1.5.0}/agstack/contexts.py +0 -0
  17. {agstack-1.3.0 → agstack-1.5.0}/agstack/decorators.py +0 -0
  18. {agstack-1.3.0 → agstack-1.5.0}/agstack/events.py +0 -0
  19. {agstack-1.3.0 → agstack-1.5.0}/agstack/exceptions.py +0 -0
  20. {agstack-1.3.0 → agstack-1.5.0}/agstack/fastapi/__init__.py +0 -0
  21. {agstack-1.3.0 → agstack-1.5.0}/agstack/fastapi/exception.py +0 -0
  22. {agstack-1.3.0 → agstack-1.5.0}/agstack/fastapi/middleware.py +0 -0
  23. {agstack-1.3.0 → agstack-1.5.0}/agstack/fastapi/offline.py +0 -0
  24. {agstack-1.3.0 → agstack-1.5.0}/agstack/fastapi/sse.py +0 -0
  25. {agstack-1.3.0 → agstack-1.5.0}/agstack/infra/db/__init__.py +0 -0
  26. {agstack-1.3.0 → agstack-1.5.0}/agstack/infra/es/__init__.py +0 -0
  27. {agstack-1.3.0 → agstack-1.5.0}/agstack/infra/kg/__init__.py +0 -0
  28. {agstack-1.3.0 → agstack-1.5.0}/agstack/infra/mq/__init__.py +0 -0
  29. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/__init__.py +0 -0
  30. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/client.py +0 -0
  31. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/__init__.py +0 -0
  32. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/agent.py +0 -0
  33. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/context.py +0 -0
  34. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/event.py +0 -0
  35. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/exceptions.py +0 -0
  36. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/factory.py +0 -0
  37. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/loader.py +0 -0
  38. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/records.py +0 -0
  39. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/registry.py +0 -0
  40. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/state.py +0 -0
  41. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/flow/tool.py +0 -0
  42. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/prompts.py +0 -0
  43. {agstack-1.3.0 → agstack-1.5.0}/agstack/llm/token.py +0 -0
  44. {agstack-1.3.0 → agstack-1.5.0}/agstack/registry.py +0 -0
  45. {agstack-1.3.0 → agstack-1.5.0}/agstack/schema.py +0 -0
  46. {agstack-1.3.0 → agstack-1.5.0}/agstack/security/__init__.py +0 -0
  47. {agstack-1.3.0 → agstack-1.5.0}/agstack/security/casbin.py +0 -0
  48. {agstack-1.3.0 → agstack-1.5.0}/agstack/security/crypt.py +0 -0
  49. {agstack-1.3.0 → agstack-1.5.0}/agstack/status.py +0 -0
  50. {agstack-1.3.0 → agstack-1.5.0}/agstack.egg-info/dependency_links.txt +0 -0
  51. {agstack-1.3.0 → agstack-1.5.0}/agstack.egg-info/top_level.txt +0 -0
  52. {agstack-1.3.0 → agstack-1.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.3.0
3
+ Version: 1.5.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>
@@ -33,8 +33,9 @@ Requires-Dist: pycasbin>=2.8.0
33
33
  Requires-Dist: pydantic>=2.12.4
34
34
  Requires-Dist: python-multipart>=0.0.20
35
35
  Requires-Dist: requests>=2.32.5
36
+ Requires-Dist: RestrictedPython>=7.0
36
37
  Requires-Dist: sqlalchemy[asyncio]>=2.0.48
37
- Requires-Dist: sqlobjects>=1.3.0
38
+ Requires-Dist: sqlobjects>=1.4.0
38
39
  Requires-Dist: tiktoken>=0.12.0
39
40
  Requires-Dist: uvicorn>=0.41.0
40
41
  Dynamic: license-file
@@ -0,0 +1,551 @@
1
+ # Copyright (c) 2020-2025 XtraVisions, All rights reserved.
2
+
3
+ """Flow 定义和执行"""
4
+
5
+ import asyncio
6
+ import json as _json
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any, AsyncIterator
9
+ from uuid import uuid4
10
+
11
+ from . import event
12
+ from .exceptions import FlowError, NodeExecutionError
13
+ from .registry import registry
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from .context import FlowContext
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 # 退避倍数
27
+
28
+
29
+ class _SafeFormatDict(dict):
30
+ """安全的模板变量替换,缺失 key 时保留原始占位符"""
31
+
32
+ def __missing__(self, key: str) -> str:
33
+ return f"{{{key}}}"
34
+
35
+
36
+ @dataclass
37
+ class Flow:
38
+ """Flow 配置定义"""
39
+
40
+ flow_id: str
41
+ name: str
42
+ description: str = ""
43
+ nodes: list[dict[str, Any]] = field(default_factory=list)
44
+ edges: list[dict[str, Any]] = field(default_factory=list)
45
+ variables: dict[str, Any] = field(default_factory=dict)
46
+
47
+ # ── 重试策略 ──
48
+
49
+ @staticmethod
50
+ def _get_retry_policy(node: dict) -> RetryPolicy:
51
+ """从节点 config 解析重试策略"""
52
+ config = node.get("config", {})
53
+ retry_cfg = config.get("retry", {})
54
+ if not retry_cfg:
55
+ return RetryPolicy()
56
+ return RetryPolicy(
57
+ max_retries=retry_cfg.get("max_retries", 0),
58
+ delay=retry_cfg.get("delay", 1.0),
59
+ backoff=retry_cfg.get("backoff", 2.0),
60
+ )
61
+
62
+ # ── 边驱动路由 ──
63
+
64
+ def _resolve_next_node(self, current_id: str, result: str | None = None) -> str | None:
65
+ """根据当前节点和执行结果,通过 edges 查找下一节点"""
66
+ for edge in self.edges:
67
+ if edge.get("source") == current_id:
68
+ cond = edge.get("condition")
69
+ if cond is None or cond == result:
70
+ return edge.get("target")
71
+ return None
72
+
73
+ @staticmethod
74
+ def _extract_route_key(result: Any) -> str:
75
+ """从节点执行结果中提取路由键。
76
+
77
+ 支持 ``{"result": "qa"}`` 形式的 JSON 字符串,
78
+ 以及纯字符串结果。
79
+ """
80
+ if not isinstance(result, str):
81
+ return "done"
82
+ try:
83
+ parsed = _json.loads(result)
84
+ if isinstance(parsed, dict) and "result" in parsed:
85
+ return str(parsed["result"])
86
+ except (ValueError, TypeError):
87
+ pass
88
+ return result or "done"
89
+
90
+ # ── message 节点 ──
91
+
92
+ async def _emit_message(self, node: dict, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
93
+ """输出模板文本"""
94
+ config = node.get("config", {})
95
+ template = config.get("content", "")
96
+ text = template.format_map(_SafeFormatDict(context.variables))
97
+ msg_id = context.message_id or str(uuid4())
98
+ yield event.text_message_start(message_id=msg_id, role="assistant")
99
+ yield event.text_message_content(message_id=msg_id, delta=text)
100
+ yield event.text_message_end(message_id=msg_id)
101
+
102
+ # ── 带重试的节点执行 ──
103
+
104
+ async def _execute_node_with_retry(
105
+ self,
106
+ node: dict,
107
+ context: "FlowContext",
108
+ node_id: str,
109
+ ) -> AsyncIterator[dict[str, Any]]:
110
+ """执行节点,带重试策略,产出 AG-UI 事件"""
111
+ policy = self._get_retry_policy(node)
112
+ node_type = node.get("type")
113
+ config = node.get("config", {})
114
+ label = config.get("agent_name") or config.get("tool_name") or node_id
115
+ last_error: Exception | None = None
116
+
117
+ for attempt in range(policy.max_retries + 1):
118
+ try:
119
+ if attempt > 0:
120
+ wait = policy.delay * (policy.backoff ** (attempt - 1))
121
+ await asyncio.sleep(wait)
122
+ yield event.custom(
123
+ name="node_retry",
124
+ value={
125
+ "nodeId": node_id,
126
+ "nodeType": node_type,
127
+ "label": label,
128
+ "attempt": attempt + 1,
129
+ "maxAttempts": policy.max_retries + 1,
130
+ "error": str(last_error),
131
+ },
132
+ )
133
+
134
+ if node_type == "agent":
135
+ yield event.step_started(step_name=f"agent:{label}")
136
+ self._set_parameters(config, context)
137
+ ag = self._create_agent(config)
138
+ async for evt in ag.stream(context):
139
+ yield evt
140
+ result = context.get_last_output(ag.name) or ""
141
+ context.set_node_result(node_id, result)
142
+ yield event.step_finished(step_name=f"agent:{label}")
143
+ return
144
+
145
+ elif node_type == "tool":
146
+ yield event.step_started(step_name=f"tool:{label}")
147
+ result = await self._execute_node(node, context)
148
+ context.set_node_result(node_id, result)
149
+ yield event.step_finished(step_name=f"tool:{label}")
150
+ return
151
+
152
+ except Exception as e:
153
+ last_error = e
154
+ if attempt < policy.max_retries:
155
+ continue
156
+ yield event.run_error(
157
+ message=str(e),
158
+ code=type(e).__name__,
159
+ )
160
+ raise NodeExecutionError(
161
+ "NODE_EXECUTION_FAILED",
162
+ args={"node_id": node_id, "error": str(e)},
163
+ ) from e
164
+
165
+ # ── 执行入口 ──
166
+
167
+ async def run(self, context: "FlowContext") -> dict[str, Any]:
168
+ """执行 Flow"""
169
+ if not self.edges:
170
+ # 向后兼容:无 edges 时按 nodes 列表顺序执行
171
+ for node in self.nodes:
172
+ node_id = node.get("id")
173
+ if not node_id:
174
+ continue
175
+ context.current_node = node_id
176
+ result = await self._execute_node(node, context)
177
+ context.set_node_result(node_id, result)
178
+ else:
179
+ # edge 驱动执行
180
+ current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
181
+ while current_node_id:
182
+ node = self.get_node_config(current_node_id)
183
+ if not node:
184
+ break
185
+ context.current_node = current_node_id
186
+ node_type = node.get("type")
187
+
188
+ if node_type == "message":
189
+ config = node.get("config", {})
190
+ template = config.get("content", "")
191
+ text = template.format_map(_SafeFormatDict(context.variables))
192
+ context.set_node_result(current_node_id, text)
193
+ current_node_id = self._resolve_next_node(current_node_id, "done")
194
+ elif node_type in ("agent", "tool"):
195
+ result = await self._execute_node(node, context)
196
+ context.set_node_result(current_node_id, result)
197
+ route_key = self._extract_route_key(result)
198
+ current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
199
+ current_node_id, "done"
200
+ )
201
+
202
+ elif node_type == "parallel":
203
+ config = node.get("config", {})
204
+ branches: list[str] = config.get("branches", [])
205
+
206
+ async def _run_branch(branch_id: str) -> None:
207
+ branch_node = self.get_node_config(branch_id)
208
+ if not branch_node:
209
+ return
210
+ context.current_node = branch_id
211
+ self._set_parameters(branch_node.get("config", {}), context)
212
+ result = await self._execute_node(branch_node, context)
213
+ context.set_node_result(branch_id, result)
214
+
215
+ await asyncio.gather(*[_run_branch(bid) for bid in branches])
216
+ context.set_node_result(current_node_id, "done")
217
+ current_node_id = self._resolve_next_node(current_node_id, "done")
218
+
219
+ elif node_type == "iteration":
220
+ config = node.get("config", {})
221
+ items_ref = config.get("items", "")
222
+ items = context.resolve_reference(items_ref) if isinstance(items_ref, str) else items_ref
223
+ if isinstance(items, str):
224
+ items = _json.loads(items)
225
+ if not isinstance(items, list):
226
+ items = [items]
227
+
228
+ item_var = config.get("item_variable", "item")
229
+ index_var = config.get("index_variable", "index")
230
+ body_nodes: list[str] = config.get("body", [])
231
+ output_var = config.get("output_variable", "iteration_results")
232
+ results: list[Any] = []
233
+
234
+ for idx, item in enumerate(items):
235
+ context.set_variable(item_var, item)
236
+ context.set_variable(index_var, idx)
237
+ for body_node_id in body_nodes:
238
+ body_node = self.get_node_config(body_node_id)
239
+ if not body_node:
240
+ continue
241
+ self._set_parameters(body_node.get("config", {}), context)
242
+ body_result = await self._execute_node(body_node, context)
243
+ context.set_node_result(body_node_id, body_result)
244
+ if body_nodes:
245
+ results.append(context.node_results.get(body_nodes[-1]))
246
+
247
+ context.set_variable(output_var, results)
248
+ context.set_node_result(current_node_id, _json.dumps(results, ensure_ascii=False))
249
+ current_node_id = self._resolve_next_node(current_node_id, "done")
250
+
251
+ elif node_type == "loop":
252
+ config = node.get("config", {})
253
+ body_nodes_l: list[str] = config.get("body", [])
254
+ condition_node_id = config.get("condition_node")
255
+ break_cond = config.get("break_condition", "done")
256
+ max_iter = config.get("max_iterations", 10)
257
+ loop_var = config.get("loop_variable", "loop_count")
258
+
259
+ for iteration in range(max_iter):
260
+ context.set_variable(loop_var, iteration)
261
+ for body_node_id in body_nodes_l:
262
+ body_node = self.get_node_config(body_node_id)
263
+ if not body_node:
264
+ continue
265
+ self._set_parameters(body_node.get("config", {}), context)
266
+ body_result = await self._execute_node(body_node, context)
267
+ context.set_node_result(body_node_id, body_result)
268
+ if condition_node_id:
269
+ cond_result = context.node_results.get(condition_node_id, "")
270
+ if isinstance(cond_result, str):
271
+ try:
272
+ parsed = _json.loads(cond_result)
273
+ if isinstance(parsed, dict) and parsed.get("result") == break_cond:
274
+ break
275
+ except (ValueError, TypeError):
276
+ if cond_result == break_cond:
277
+ break
278
+
279
+ context.set_node_result(current_node_id, "done")
280
+ current_node_id = self._resolve_next_node(current_node_id, "done")
281
+
282
+ elif node_type == "python":
283
+ config = node.get("config", {})
284
+ inputs_spec: dict[str, Any] = config.get("inputs", {})
285
+ resolved_inputs: dict[str, Any] = {}
286
+ for key, ref in inputs_spec.items():
287
+ resolved_inputs[key] = context.resolve_reference(ref) if isinstance(ref, str) else ref
288
+
289
+ from .sandbox import execute_python_node
290
+
291
+ code_str = config.get("code", "")
292
+ py_result = execute_python_node(code_str, resolved_inputs)
293
+
294
+ outputs_spec: dict[str, Any] = config.get("outputs", {})
295
+ for key in outputs_spec:
296
+ if key in py_result:
297
+ context.set_variable(key, py_result[key])
298
+
299
+ context.set_node_result(current_node_id, _json.dumps(py_result, ensure_ascii=False))
300
+ current_node_id = self._resolve_next_node(current_node_id, "done")
301
+ else:
302
+ break
303
+
304
+ return context.node_results
305
+
306
+ async def stream(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
307
+ """流式执行 Flow(输出 AG-UI 标准事件)"""
308
+ yield event.step_started(step_name=f"flow:{self.name}")
309
+
310
+ if not self.edges:
311
+ # 向后兼容:无 edges 时按 nodes 列表顺序执行(原有逻辑)
312
+ async for evt in self._stream_sequential(context):
313
+ yield evt
314
+ else:
315
+ # edge 驱动执行
316
+ async for evt in self._stream_edge_driven(context):
317
+ yield evt
318
+
319
+ yield event.step_finished(step_name=f"flow:{self.name}")
320
+
321
+ async def _stream_sequential(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
322
+ """顺序流式执行(原有逻辑)"""
323
+ for node in self.nodes:
324
+ node_id = node.get("id")
325
+ if not node_id:
326
+ continue
327
+
328
+ context.current_node = node_id
329
+ yield event.step_started(step_name=f"node:{node_id}")
330
+
331
+ if node.get("type") in ("agent", "tool"):
332
+ async for evt in self._execute_node_with_retry(node, context, node_id):
333
+ yield evt
334
+ else:
335
+ tool_name = node.get("config", {}).get("tool_name", "")
336
+ yield event.step_started(step_name=f"tool:{tool_name}")
337
+ result = await self._execute_node(node, context)
338
+ context.set_node_result(node_id, result)
339
+ yield event.step_finished(step_name=f"tool:{tool_name}")
340
+
341
+ async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
342
+ """边驱动流式执行"""
343
+ current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
344
+
345
+ while current_node_id:
346
+ node = self.get_node_config(current_node_id)
347
+ if not node:
348
+ yield event.run_error(
349
+ message=f"Node not found: {current_node_id}",
350
+ code="NODE_NOT_FOUND",
351
+ )
352
+ raise NodeExecutionError("NODE_NOT_FOUND", args={"node_id": current_node_id})
353
+
354
+ context.current_node = current_node_id
355
+ node_type = node.get("type")
356
+
357
+ if node_type == "message":
358
+ async for evt in self._emit_message(node, context):
359
+ yield evt
360
+ current_node_id = self._resolve_next_node(current_node_id, "done")
361
+
362
+ elif node_type == "agent":
363
+ async for evt in self._execute_node_with_retry(node, context, current_node_id):
364
+ yield evt
365
+ result = context.node_results.get(current_node_id, "")
366
+ route_key = self._extract_route_key(result)
367
+ current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
368
+ current_node_id, "done"
369
+ )
370
+
371
+ elif node_type == "tool":
372
+ async for evt in self._execute_node_with_retry(node, context, current_node_id):
373
+ yield evt
374
+ result = context.node_results.get(current_node_id, "")
375
+ route_key = self._extract_route_key(result)
376
+ current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
377
+ current_node_id, "done"
378
+ )
379
+
380
+ elif node_type == "parallel":
381
+ config = node.get("config", {})
382
+ branches = config.get("branches", [])
383
+ yield event.step_started(step_name=f"parallel:{current_node_id}")
384
+
385
+ async def _exec_branch(branch_id: str) -> None:
386
+ branch_node = self.get_node_config(branch_id)
387
+ if not branch_node:
388
+ return
389
+ context.current_node = branch_id
390
+ self._set_parameters(branch_node.get("config", {}), context)
391
+ result = await self._execute_node(branch_node, context)
392
+ context.set_node_result(branch_id, result)
393
+
394
+ await asyncio.gather(*[_exec_branch(bid) for bid in branches])
395
+ context.set_node_result(current_node_id, "done")
396
+ yield event.step_finished(step_name=f"parallel:{current_node_id}")
397
+ current_node_id = self._resolve_next_node(current_node_id, "done")
398
+
399
+ elif node_type == "iteration":
400
+ config = node.get("config", {})
401
+ items_ref = config.get("items", "")
402
+ items = context.resolve_reference(items_ref) if isinstance(items_ref, str) else items_ref
403
+ if isinstance(items, str):
404
+ items = _json.loads(items)
405
+ if not isinstance(items, list):
406
+ items = [items]
407
+
408
+ item_var = config.get("item_variable", "item")
409
+ index_var = config.get("index_variable", "index")
410
+ body_nodes: list[str] = config.get("body", [])
411
+ output_var = config.get("output_variable", "iteration_results")
412
+ results: list[Any] = []
413
+
414
+ yield event.step_started(step_name=f"iteration:{current_node_id}")
415
+ for idx, item in enumerate(items):
416
+ context.set_variable(item_var, item)
417
+ context.set_variable(index_var, idx)
418
+ for body_node_id in body_nodes:
419
+ body_node = self.get_node_config(body_node_id)
420
+ if not body_node:
421
+ continue
422
+ self._set_parameters(body_node.get("config", {}), context)
423
+ body_result = await self._execute_node(body_node, context)
424
+ context.set_node_result(body_node_id, body_result)
425
+ if body_nodes:
426
+ results.append(context.node_results.get(body_nodes[-1]))
427
+
428
+ context.set_variable(output_var, results)
429
+ context.set_node_result(current_node_id, _json.dumps(results, ensure_ascii=False))
430
+ yield event.step_finished(step_name=f"iteration:{current_node_id}")
431
+ current_node_id = self._resolve_next_node(current_node_id, "done")
432
+
433
+ elif node_type == "loop":
434
+ config = node.get("config", {})
435
+ body_nodes_l: list[str] = config.get("body", [])
436
+ condition_node_id = config.get("condition_node")
437
+ break_cond = config.get("break_condition", "done")
438
+ max_iter = config.get("max_iterations", 10)
439
+ loop_var = config.get("loop_variable", "loop_count")
440
+
441
+ yield event.step_started(step_name=f"loop:{current_node_id}")
442
+ for iteration in range(max_iter):
443
+ context.set_variable(loop_var, iteration)
444
+ for body_node_id in body_nodes_l:
445
+ body_node = self.get_node_config(body_node_id)
446
+ if not body_node:
447
+ continue
448
+ self._set_parameters(body_node.get("config", {}), context)
449
+ body_result = await self._execute_node(body_node, context)
450
+ context.set_node_result(body_node_id, body_result)
451
+ # 检查终止条件
452
+ if condition_node_id:
453
+ cond_result = context.node_results.get(condition_node_id, "")
454
+ if isinstance(cond_result, str):
455
+ try:
456
+ parsed = _json.loads(cond_result)
457
+ if isinstance(parsed, dict) and parsed.get("result") == break_cond:
458
+ break
459
+ except (ValueError, TypeError):
460
+ if cond_result == break_cond:
461
+ break
462
+
463
+ context.set_node_result(current_node_id, "done")
464
+ yield event.step_finished(step_name=f"loop:{current_node_id}")
465
+ current_node_id = self._resolve_next_node(current_node_id, "done")
466
+
467
+ elif node_type == "python":
468
+ config = node.get("config", {})
469
+ yield event.step_started(step_name=f"python:{current_node_id}")
470
+
471
+ # 解析 inputs
472
+ inputs_spec: dict[str, Any] = config.get("inputs", {})
473
+ resolved_inputs: dict[str, Any] = {}
474
+ for key, ref in inputs_spec.items():
475
+ resolved_inputs[key] = context.resolve_reference(ref) if isinstance(ref, str) else ref
476
+
477
+ # 沙箱执行
478
+ from .sandbox import execute_python_node
479
+
480
+ code_str = config.get("code", "")
481
+ py_result = execute_python_node(code_str, resolved_inputs)
482
+
483
+ # 映射 outputs 到 context.variables
484
+ outputs_spec: dict[str, Any] = config.get("outputs", {})
485
+ for key in outputs_spec:
486
+ if key in py_result:
487
+ context.set_variable(key, py_result[key])
488
+
489
+ context.set_node_result(current_node_id, _json.dumps(py_result, ensure_ascii=False))
490
+ yield event.step_finished(step_name=f"python:{current_node_id}")
491
+ current_node_id = self._resolve_next_node(current_node_id, "done")
492
+
493
+ else:
494
+ break
495
+
496
+ async def _execute_node(self, node_config: dict, context: "FlowContext") -> Any:
497
+ """执行节点"""
498
+ node_type = node_config.get("type")
499
+ config = node_config.get("config", {})
500
+
501
+ # 设置参数到 context
502
+ self._set_parameters(config, context)
503
+
504
+ # 创建并执行 runnable
505
+ if node_type == "agent":
506
+ runnable = self._create_agent(config)
507
+ elif node_type == "tool":
508
+ runnable = self._create_tool(config)
509
+ else:
510
+ raise FlowError("UNKNOWN_NODE_TYPE", 400, {"type": node_type})
511
+
512
+ return await runnable.run(context)
513
+
514
+ def _set_parameters(self, config: dict, context: "FlowContext") -> None:
515
+ """设置参数到 context"""
516
+ parameters = config.get("parameters", {})
517
+
518
+ for key, value in parameters.items():
519
+ resolved_value = context.resolve_reference(value) if isinstance(value, str) else value
520
+ context.set_variable(key, resolved_value)
521
+
522
+ def _create_agent(self, config: dict):
523
+ """创建 Agent"""
524
+ agent_name = config.get("agent_name")
525
+ if not agent_name:
526
+ raise FlowError("MISSING_AGENT_NAME", 400)
527
+
528
+ agent = registry.create_agent(agent_name)
529
+ if not agent:
530
+ raise FlowError("AGENT_NOT_FOUND", 404, {"agent_name": agent_name})
531
+
532
+ return agent
533
+
534
+ def _create_tool(self, config: dict):
535
+ """创建 Tool"""
536
+ tool_name = config.get("tool_name")
537
+ if not tool_name:
538
+ raise FlowError("MISSING_TOOL_NAME", 400)
539
+
540
+ tool = registry.create_tool(tool_name)
541
+ if not tool:
542
+ raise FlowError("TOOL_NOT_FOUND", 404, {"tool_name": tool_name})
543
+
544
+ return tool
545
+
546
+ def get_node_config(self, node_id: str) -> dict[str, Any] | None:
547
+ """获取节点配置"""
548
+ for node in self.nodes:
549
+ if node.get("id") == node_id:
550
+ return node
551
+ return None
@@ -0,0 +1,68 @@
1
+ # Copyright (c) 2020-2025 XtraVisions, All rights reserved.
2
+
3
+ """Python 沙箱执行(基于 RestrictedPython)"""
4
+
5
+ import builtins
6
+ from typing import Any
7
+
8
+ from RestrictedPython import compile_restricted, safe_globals
9
+ from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
10
+ from RestrictedPython.Guards import guarded_unpack_sequence, safer_getattr
11
+
12
+
13
+ # 白名单内置模块
14
+ _ALLOWED_MODULES = frozenset(
15
+ {
16
+ "json",
17
+ "re",
18
+ "math",
19
+ "datetime",
20
+ "collections",
21
+ "itertools",
22
+ "functools",
23
+ "operator",
24
+ "string",
25
+ }
26
+ )
27
+
28
+ _builtins_import = builtins.__import__
29
+
30
+
31
+ def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
32
+ """只允许导入白名单模块"""
33
+ if name not in _ALLOWED_MODULES:
34
+ raise ImportError(f"Import of '{name}' is not allowed in python node")
35
+ return _builtins_import(name, *args, **kwargs)
36
+
37
+
38
+ def execute_python_node(code: str, inputs: dict[str, Any]) -> dict[str, Any]:
39
+ """在 RestrictedPython 沙箱中执行用户代码
40
+
41
+ Args:
42
+ code: 用户代码,必须定义 main(**kwargs) -> dict 函数
43
+ inputs: 传入 main 函数的参数
44
+
45
+ Returns:
46
+ main 函数的返回值(dict)
47
+ """
48
+ byte_code = compile_restricted(code, "<flow_python_node>", "exec")
49
+
50
+ glb: dict[str, Any] = dict(safe_globals)
51
+ glb["_getitem_"] = default_guarded_getitem
52
+ glb["_getiter_"] = default_guarded_getiter
53
+ glb["_unpack_sequence_"] = guarded_unpack_sequence
54
+ glb["_getattr_"] = safer_getattr
55
+ glb["__builtins__"] = {**glb["__builtins__"], "__import__": _safe_import}
56
+
57
+ loc: dict[str, Any] = {}
58
+ exec(byte_code, glb, loc) # noqa: S102
59
+
60
+ main_fn = loc.get("main")
61
+ if not callable(main_fn):
62
+ raise ValueError("Python node code must define a callable 'main' function")
63
+
64
+ result = main_fn(**inputs)
65
+ if not isinstance(result, dict):
66
+ raise TypeError(f"main() must return a dict, got {type(result).__name__}")
67
+
68
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.3.0
3
+ Version: 1.5.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>
@@ -33,8 +33,9 @@ Requires-Dist: pycasbin>=2.8.0
33
33
  Requires-Dist: pydantic>=2.12.4
34
34
  Requires-Dist: python-multipart>=0.0.20
35
35
  Requires-Dist: requests>=2.32.5
36
+ Requires-Dist: RestrictedPython>=7.0
36
37
  Requires-Dist: sqlalchemy[asyncio]>=2.0.48
37
- Requires-Dist: sqlobjects>=1.3.0
38
+ Requires-Dist: sqlobjects>=1.4.0
38
39
  Requires-Dist: tiktoken>=0.12.0
39
40
  Requires-Dist: uvicorn>=0.41.0
40
41
  Dynamic: license-file
@@ -41,6 +41,7 @@ agstack/llm/flow/flow.py
41
41
  agstack/llm/flow/loader.py
42
42
  agstack/llm/flow/records.py
43
43
  agstack/llm/flow/registry.py
44
+ agstack/llm/flow/sandbox.py
44
45
  agstack/llm/flow/state.py
45
46
  agstack/llm/flow/tool.py
46
47
  agstack/security/__init__.py
@@ -11,7 +11,8 @@ pycasbin>=2.8.0
11
11
  pydantic>=2.12.4
12
12
  python-multipart>=0.0.20
13
13
  requests>=2.32.5
14
+ RestrictedPython>=7.0
14
15
  sqlalchemy[asyncio]>=2.0.48
15
- sqlobjects>=1.3.0
16
+ sqlobjects>=1.4.0
16
17
  tiktoken>=0.12.0
17
18
  uvicorn>=0.41.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.3.0"
3
+ version = "1.5.0"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -52,8 +52,9 @@ dependencies = [
52
52
  "pydantic>=2.12.4",
53
53
  "python-multipart>=0.0.20",
54
54
  "requests>=2.32.5",
55
+ "RestrictedPython>=7.0",
55
56
  "sqlalchemy[asyncio]>=2.0.48",
56
- "sqlobjects>=1.3.0",
57
+ "sqlobjects>=1.4.0",
57
58
  "tiktoken>=0.12.0",
58
59
  "uvicorn>=0.41.0",
59
60
  ]
@@ -1,272 +0,0 @@
1
- # Copyright (c) 2020-2025 XtraVisions, All rights reserved.
2
-
3
- """Flow 定义和执行"""
4
-
5
- import json as _json
6
- from dataclasses import dataclass, field
7
- from typing import TYPE_CHECKING, Any, AsyncIterator
8
- from uuid import uuid4
9
-
10
- from . import event
11
- from .exceptions import FlowError
12
- from .registry import registry
13
-
14
-
15
- if TYPE_CHECKING:
16
- from .context import FlowContext
17
-
18
-
19
- class _SafeFormatDict(dict):
20
- """安全的模板变量替换,缺失 key 时保留原始占位符"""
21
-
22
- def __missing__(self, key: str) -> str:
23
- return f"{{{key}}}"
24
-
25
-
26
- @dataclass
27
- class Flow:
28
- """Flow 配置定义"""
29
-
30
- flow_id: str
31
- name: str
32
- description: str = ""
33
- nodes: list[dict[str, Any]] = field(default_factory=list)
34
- edges: list[dict[str, Any]] = field(default_factory=list)
35
- variables: dict[str, Any] = field(default_factory=dict)
36
-
37
- # ── 边驱动路由 ──
38
-
39
- def _resolve_next_node(self, current_id: str, result: str | None = None) -> str | None:
40
- """根据当前节点和执行结果,通过 edges 查找下一节点"""
41
- for edge in self.edges:
42
- if edge.get("source") == current_id:
43
- cond = edge.get("condition")
44
- if cond is None or cond == result:
45
- return edge.get("target")
46
- return None
47
-
48
- # ── condition 节点 ──
49
-
50
- async def _evaluate_condition(self, node: dict, context: "FlowContext") -> str:
51
- """调用 LLM 判断条件是否匹配"""
52
- config = node.get("config", {})
53
- topic = config.get("topic", "")
54
- query = context.get_variable("query", "")
55
-
56
- prompt = (
57
- f"判断以下问题是否属于「{topic}」相关问题。\n"
58
- f"问题:{query}\n"
59
- f'仅回复 JSON:{{"result": "match"}} 或 {{"result": "reject"}}'
60
- )
61
-
62
- from ..client import get_llm_client
63
-
64
- client = get_llm_client()
65
- response = await client.chat(
66
- messages=[{"role": "user", "content": prompt}],
67
- model=config.get("model", "gpt-4o-mini"),
68
- temperature=0,
69
- )
70
- text = response.choices[0].message.content or ""
71
- try:
72
- return _json.loads(text).get("result", "reject")
73
- except Exception:
74
- return "match" if "match" in text.lower() else "reject"
75
-
76
- # ── message 节点 ──
77
-
78
- async def _emit_message(self, node: dict, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
79
- """输出模板文本"""
80
- config = node.get("config", {})
81
- template = config.get("content", "")
82
- text = template.format_map(_SafeFormatDict(context.variables))
83
- msg_id = context.message_id or str(uuid4())
84
- yield event.text_message_start(message_id=msg_id, role="assistant")
85
- yield event.text_message_content(message_id=msg_id, delta=text)
86
- yield event.text_message_end(message_id=msg_id)
87
-
88
- # ── 执行入口 ──
89
-
90
- async def run(self, context: "FlowContext") -> dict[str, Any]:
91
- """执行 Flow"""
92
- if not self.edges:
93
- # 向后兼容:无 edges 时按 nodes 列表顺序执行
94
- for node in self.nodes:
95
- node_id = node.get("id")
96
- if not node_id:
97
- continue
98
- context.current_node = node_id
99
- result = await self._execute_node(node, context)
100
- context.set_node_result(node_id, result)
101
- else:
102
- # edge 驱动执行
103
- current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
104
- while current_node_id:
105
- node = self.get_node_config(current_node_id)
106
- if not node:
107
- break
108
- context.current_node = current_node_id
109
- node_type = node.get("type")
110
-
111
- if node_type == "condition":
112
- result = await self._evaluate_condition(node, context)
113
- context.set_node_result(current_node_id, result)
114
- current_node_id = self._resolve_next_node(current_node_id, result)
115
- elif node_type == "message":
116
- config = node.get("config", {})
117
- template = config.get("content", "")
118
- text = template.format_map(_SafeFormatDict(context.variables))
119
- context.set_node_result(current_node_id, text)
120
- current_node_id = self._resolve_next_node(current_node_id, "done")
121
- elif node_type in ("agent", "tool"):
122
- result = await self._execute_node(node, context)
123
- context.set_node_result(current_node_id, result)
124
- current_node_id = self._resolve_next_node(current_node_id, "done")
125
- else:
126
- break
127
-
128
- return context.node_results
129
-
130
- async def stream(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
131
- """流式执行 Flow(输出 AG-UI 标准事件)"""
132
- yield event.step_started(step_name=f"flow:{self.name}")
133
-
134
- if not self.edges:
135
- # 向后兼容:无 edges 时按 nodes 列表顺序执行(原有逻辑)
136
- async for evt in self._stream_sequential(context):
137
- yield evt
138
- else:
139
- # edge 驱动执行
140
- async for evt in self._stream_edge_driven(context):
141
- yield evt
142
-
143
- yield event.step_finished(step_name=f"flow:{self.name}")
144
-
145
- async def _stream_sequential(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
146
- """顺序流式执行(原有逻辑)"""
147
- for node in self.nodes:
148
- node_id = node.get("id")
149
- if not node_id:
150
- continue
151
-
152
- context.current_node = node_id
153
- yield event.step_started(step_name=f"node:{node_id}")
154
-
155
- if node.get("type") == "agent":
156
- agent_name = node.get("config", {}).get("agent_name", "")
157
- yield event.step_started(step_name=f"agent:{agent_name}")
158
- self._set_parameters(node.get("config", {}), context)
159
- ag = self._create_agent(node.get("config", {}))
160
- async for evt in ag.stream(context):
161
- yield evt
162
- result = context.get_last_output(ag.name) or ""
163
- context.set_node_result(node_id, result)
164
- yield event.step_finished(step_name=f"agent:{agent_name}")
165
- else:
166
- tool_name = node.get("config", {}).get("tool_name", "")
167
- yield event.step_started(step_name=f"tool:{tool_name}")
168
- result = await self._execute_node(node, context)
169
- context.set_node_result(node_id, result)
170
- yield event.step_finished(step_name=f"tool:{tool_name}")
171
-
172
- async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
173
- """边驱动流式执行"""
174
- current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
175
-
176
- while current_node_id:
177
- node = self.get_node_config(current_node_id)
178
- if not node:
179
- break
180
-
181
- context.current_node = current_node_id
182
- node_type = node.get("type")
183
-
184
- if node_type == "condition":
185
- result = await self._evaluate_condition(node, context)
186
- context.set_node_result(current_node_id, result)
187
- current_node_id = self._resolve_next_node(current_node_id, result)
188
-
189
- elif node_type == "message":
190
- async for evt in self._emit_message(node, context):
191
- yield evt
192
- current_node_id = self._resolve_next_node(current_node_id, "done")
193
-
194
- elif node_type == "agent":
195
- agent_name = node.get("config", {}).get("agent_name", "")
196
- yield event.step_started(step_name=f"agent:{agent_name}")
197
- self._set_parameters(node.get("config", {}), context)
198
- ag = self._create_agent(node.get("config", {}))
199
- async for evt in ag.stream(context):
200
- yield evt
201
- result = context.get_last_output(ag.name) or ""
202
- context.set_node_result(current_node_id, result)
203
- yield event.step_finished(step_name=f"agent:{agent_name}")
204
- current_node_id = self._resolve_next_node(current_node_id, "done")
205
-
206
- elif node_type == "tool":
207
- tool_name = node.get("config", {}).get("tool_name", "")
208
- yield event.step_started(step_name=f"tool:{tool_name}")
209
- result = await self._execute_node(node, context)
210
- context.set_node_result(current_node_id, result)
211
- yield event.step_finished(step_name=f"tool:{tool_name}")
212
- current_node_id = self._resolve_next_node(current_node_id, "done")
213
-
214
- else:
215
- break
216
-
217
- async def _execute_node(self, node_config: dict, context: "FlowContext") -> Any:
218
- """执行节点"""
219
- node_type = node_config.get("type")
220
- config = node_config.get("config", {})
221
-
222
- # 设置参数到 context
223
- self._set_parameters(config, context)
224
-
225
- # 创建并执行 runnable
226
- if node_type == "agent":
227
- runnable = self._create_agent(config)
228
- elif node_type == "tool":
229
- runnable = self._create_tool(config)
230
- else:
231
- raise FlowError("UNKNOWN_NODE_TYPE", 400, {"type": node_type})
232
-
233
- return await runnable.run(context)
234
-
235
- def _set_parameters(self, config: dict, context: "FlowContext") -> None:
236
- """设置参数到 context"""
237
- parameters = config.get("parameters", {})
238
-
239
- for key, value in parameters.items():
240
- resolved_value = context.resolve_reference(value) if isinstance(value, str) else value
241
- context.set_variable(key, resolved_value)
242
-
243
- def _create_agent(self, config: dict):
244
- """创建 Agent"""
245
- agent_name = config.get("agent_name")
246
- if not agent_name:
247
- raise FlowError("MISSING_AGENT_NAME", 400)
248
-
249
- agent = registry.create_agent(agent_name)
250
- if not agent:
251
- raise FlowError("AGENT_NOT_FOUND", 404, {"agent_name": agent_name})
252
-
253
- return agent
254
-
255
- def _create_tool(self, config: dict):
256
- """创建 Tool"""
257
- tool_name = config.get("tool_name")
258
- if not tool_name:
259
- raise FlowError("MISSING_TOOL_NAME", 400)
260
-
261
- tool = registry.create_tool(tool_name)
262
- if not tool:
263
- raise FlowError("TOOL_NOT_FOUND", 404, {"tool_name": tool_name})
264
-
265
- return tool
266
-
267
- def get_node_config(self, node_id: str) -> dict[str, Any] | None:
268
- """获取节点配置"""
269
- for node in self.nodes:
270
- if node.get("id") == node_id:
271
- return node
272
- return None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes