agstack 1.4.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 (51) hide show
  1. {agstack-1.4.0 → agstack-1.5.0}/PKG-INFO +2 -2
  2. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/flow.py +128 -63
  3. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/sandbox.py +13 -4
  4. {agstack-1.4.0 → agstack-1.5.0}/agstack.egg-info/PKG-INFO +2 -2
  5. {agstack-1.4.0 → agstack-1.5.0}/agstack.egg-info/requires.txt +1 -1
  6. {agstack-1.4.0 → agstack-1.5.0}/pyproject.toml +2 -2
  7. {agstack-1.4.0 → agstack-1.5.0}/LICENSE +0 -0
  8. {agstack-1.4.0 → agstack-1.5.0}/README.md +0 -0
  9. {agstack-1.4.0 → agstack-1.5.0}/agstack/__init__.py +0 -0
  10. {agstack-1.4.0 → agstack-1.5.0}/agstack/config/__init__.py +0 -0
  11. {agstack-1.4.0 → agstack-1.5.0}/agstack/config/logger.py +0 -0
  12. {agstack-1.4.0 → agstack-1.5.0}/agstack/config/manager.py +0 -0
  13. {agstack-1.4.0 → agstack-1.5.0}/agstack/config/types.py +0 -0
  14. {agstack-1.4.0 → agstack-1.5.0}/agstack/contexts.py +0 -0
  15. {agstack-1.4.0 → agstack-1.5.0}/agstack/decorators.py +0 -0
  16. {agstack-1.4.0 → agstack-1.5.0}/agstack/events.py +0 -0
  17. {agstack-1.4.0 → agstack-1.5.0}/agstack/exceptions.py +0 -0
  18. {agstack-1.4.0 → agstack-1.5.0}/agstack/fastapi/__init__.py +0 -0
  19. {agstack-1.4.0 → agstack-1.5.0}/agstack/fastapi/exception.py +0 -0
  20. {agstack-1.4.0 → agstack-1.5.0}/agstack/fastapi/middleware.py +0 -0
  21. {agstack-1.4.0 → agstack-1.5.0}/agstack/fastapi/offline.py +0 -0
  22. {agstack-1.4.0 → agstack-1.5.0}/agstack/fastapi/sse.py +0 -0
  23. {agstack-1.4.0 → agstack-1.5.0}/agstack/infra/db/__init__.py +0 -0
  24. {agstack-1.4.0 → agstack-1.5.0}/agstack/infra/es/__init__.py +0 -0
  25. {agstack-1.4.0 → agstack-1.5.0}/agstack/infra/kg/__init__.py +0 -0
  26. {agstack-1.4.0 → agstack-1.5.0}/agstack/infra/mq/__init__.py +0 -0
  27. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/__init__.py +0 -0
  28. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/client.py +0 -0
  29. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/__init__.py +0 -0
  30. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/agent.py +0 -0
  31. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/context.py +0 -0
  32. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/event.py +0 -0
  33. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/exceptions.py +0 -0
  34. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/factory.py +0 -0
  35. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/loader.py +0 -0
  36. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/records.py +0 -0
  37. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/registry.py +0 -0
  38. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/state.py +0 -0
  39. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/flow/tool.py +0 -0
  40. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/prompts.py +0 -0
  41. {agstack-1.4.0 → agstack-1.5.0}/agstack/llm/token.py +0 -0
  42. {agstack-1.4.0 → agstack-1.5.0}/agstack/registry.py +0 -0
  43. {agstack-1.4.0 → agstack-1.5.0}/agstack/schema.py +0 -0
  44. {agstack-1.4.0 → agstack-1.5.0}/agstack/security/__init__.py +0 -0
  45. {agstack-1.4.0 → agstack-1.5.0}/agstack/security/casbin.py +0 -0
  46. {agstack-1.4.0 → agstack-1.5.0}/agstack/security/crypt.py +0 -0
  47. {agstack-1.4.0 → agstack-1.5.0}/agstack/status.py +0 -0
  48. {agstack-1.4.0 → agstack-1.5.0}/agstack.egg-info/SOURCES.txt +0 -0
  49. {agstack-1.4.0 → agstack-1.5.0}/agstack.egg-info/dependency_links.txt +0 -0
  50. {agstack-1.4.0 → agstack-1.5.0}/agstack.egg-info/top_level.txt +0 -0
  51. {agstack-1.4.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.4.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>
@@ -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
@@ -9,7 +9,7 @@ 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
12
+ from .exceptions import FlowError, NodeExecutionError
13
13
  from .registry import registry
14
14
 
15
15
 
@@ -17,6 +17,15 @@ if TYPE_CHECKING:
17
17
  from .context import FlowContext
18
18
 
19
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
+
20
29
  class _SafeFormatDict(dict):
21
30
  """安全的模板变量替换,缺失 key 时保留原始占位符"""
22
31
 
@@ -35,6 +44,21 @@ 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
+ # ── 重试策略 ──
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
+
38
62
  # ── 边驱动路由 ──
39
63
 
40
64
  def _resolve_next_node(self, current_id: str, result: str | None = None) -> str | None:
@@ -46,33 +70,22 @@ class Flow:
46
70
  return edge.get("target")
47
71
  return None
48
72
 
49
- # ── condition 节点 ──
73
+ @staticmethod
74
+ def _extract_route_key(result: Any) -> str:
75
+ """从节点执行结果中提取路由键。
50
76
 
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
64
-
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 ""
77
+ 支持 ``{"result": "qa"}`` 形式的 JSON 字符串,
78
+ 以及纯字符串结果。
79
+ """
80
+ if not isinstance(result, str):
81
+ return "done"
72
82
  try:
73
- return _json.loads(text).get("result", "reject")
74
- except Exception:
75
- return "match" if "match" in text.lower() else "reject"
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"
76
89
 
77
90
  # ── message 节点 ──
78
91
 
@@ -86,6 +99,69 @@ class Flow:
86
99
  yield event.text_message_content(message_id=msg_id, delta=text)
87
100
  yield event.text_message_end(message_id=msg_id)
88
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
+
89
165
  # ── 执行入口 ──
90
166
 
91
167
  async def run(self, context: "FlowContext") -> dict[str, Any]:
@@ -109,11 +185,7 @@ class Flow:
109
185
  context.current_node = current_node_id
110
186
  node_type = node.get("type")
111
187
 
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":
188
+ if node_type == "message":
117
189
  config = node.get("config", {})
118
190
  template = config.get("content", "")
119
191
  text = template.format_map(_SafeFormatDict(context.variables))
@@ -122,7 +194,10 @@ class Flow:
122
194
  elif node_type in ("agent", "tool"):
123
195
  result = await self._execute_node(node, context)
124
196
  context.set_node_result(current_node_id, result)
125
- current_node_id = self._resolve_next_node(current_node_id, "done")
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
+ )
126
201
 
127
202
  elif node_type == "parallel":
128
203
  config = node.get("config", {})
@@ -253,16 +328,9 @@ class Flow:
253
328
  context.current_node = node_id
254
329
  yield event.step_started(step_name=f"node:{node_id}")
255
330
 
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):
331
+ if node.get("type") in ("agent", "tool"):
332
+ async for evt in self._execute_node_with_retry(node, context, node_id):
262
333
  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
334
  else:
267
335
  tool_name = node.get("config", {}).get("tool_name", "")
268
336
  yield event.step_started(step_name=f"tool:{tool_name}")
@@ -277,40 +345,37 @@ class Flow:
277
345
  while current_node_id:
278
346
  node = self.get_node_config(current_node_id)
279
347
  if not node:
280
- break
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})
281
353
 
282
354
  context.current_node = current_node_id
283
355
  node_type = node.get("type")
284
356
 
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":
357
+ if node_type == "message":
291
358
  async for evt in self._emit_message(node, context):
292
359
  yield evt
293
360
  current_node_id = self._resolve_next_node(current_node_id, "done")
294
361
 
295
362
  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):
363
+ async for evt in self._execute_node_with_retry(node, context, current_node_id):
301
364
  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")
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
+ )
306
370
 
307
371
  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")
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
+ )
314
379
 
315
380
  elif node_type == "parallel":
316
381
  config = node.get("config", {})
@@ -11,10 +11,19 @@ from RestrictedPython.Guards import guarded_unpack_sequence, safer_getattr
11
11
 
12
12
 
13
13
  # 白名单内置模块
14
- _ALLOWED_MODULES = frozenset({
15
- "json", "re", "math", "datetime", "collections",
16
- "itertools", "functools", "operator", "string",
17
- })
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
+ )
18
27
 
19
28
  _builtins_import = builtins.__import__
20
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.4.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>
@@ -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
@@ -13,6 +13,6 @@ python-multipart>=0.0.20
13
13
  requests>=2.32.5
14
14
  RestrictedPython>=7.0
15
15
  sqlalchemy[asyncio]>=2.0.48
16
- sqlobjects>=1.3.0
16
+ sqlobjects>=1.4.0
17
17
  tiktoken>=0.12.0
18
18
  uvicorn>=0.41.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.4.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"
@@ -54,7 +54,7 @@ dependencies = [
54
54
  "requests>=2.32.5",
55
55
  "RestrictedPython>=7.0",
56
56
  "sqlalchemy[asyncio]>=2.0.48",
57
- "sqlobjects>=1.3.0",
57
+ "sqlobjects>=1.4.0",
58
58
  "tiktoken>=0.12.0",
59
59
  "uvicorn>=0.41.0",
60
60
  ]
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