dynworkflow 0.1.3__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dynworkflow
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Dynamic Workflow Library for Alkaid0
5
5
  Author: cxykevin
6
6
  Author-email: cxykevin <cxykevin@yeah.net>
@@ -40,18 +40,18 @@ def summarize() -> d.TaskGenerator:
40
40
  flow.execute(summarize())
41
41
  ```
42
42
 
43
- 使用 `@flow.node(name)` 声明节点。普通节点可以返回 `None`、`Result` 或一个或多个下游 `Execute` 任务。生成器节点可以 `yield Agent` 或 `MultiAgent`,接收 Agent 结果后返回节点结果。
43
+ 使用 `@flow.node(name)` 声明节点。普通节点可以返回 `None`、`Result`、一个或多个下游 `Execute` 任务,或它们的组合(例如 `return d.Result(value), next_node(x=value)`)。`Result` 只记录本节点结果,不会阻止下游调度。生成器节点可以 `yield Agent` 或 `MultiAgent`,接收 Agent 结果后返回节点结果。
44
44
 
45
45
  ## 公共 API
46
46
 
47
47
  - `Flow`:创建并执行工作流。
48
48
  - `Flow.node(name)`:注册节点函数。
49
49
  - `Flow.execute(*tasks)`:在当前进程中执行任务,并在所有节点结束后抛出节点异常。
50
- - `Flow.run(*tasks)`:在 fork 子进程中运行工作流,并监听 stdin 中的 JSONL 控制命令。
50
+ - `Flow.run(*tasks)`:运行工作流,并监听 stdin 中的 JSONL 控制命令。支持 fork 的平台在 fork 子进程中运行,其他平台(如 Windows)在当前进程中运行。
51
51
  - `Agent`:描述一次 Agent 请求。
52
52
  - `MultiAgent`:并发运行多个 Agent 请求。
53
53
  - `AgentClient`:连接 ACP WebSocket 端点。
54
- - `Result`:保存节点的最终结果。
54
+ - `Result`:保存节点结果;可与下游任务同时返回。
55
55
 
56
56
  ## 执行语义
57
57
 
@@ -30,18 +30,18 @@ def summarize() -> d.TaskGenerator:
30
30
  flow.execute(summarize())
31
31
  ```
32
32
 
33
- 使用 `@flow.node(name)` 声明节点。普通节点可以返回 `None`、`Result` 或一个或多个下游 `Execute` 任务。生成器节点可以 `yield Agent` 或 `MultiAgent`,接收 Agent 结果后返回节点结果。
33
+ 使用 `@flow.node(name)` 声明节点。普通节点可以返回 `None`、`Result`、一个或多个下游 `Execute` 任务,或它们的组合(例如 `return d.Result(value), next_node(x=value)`)。`Result` 只记录本节点结果,不会阻止下游调度。生成器节点可以 `yield Agent` 或 `MultiAgent`,接收 Agent 结果后返回节点结果。
34
34
 
35
35
  ## 公共 API
36
36
 
37
37
  - `Flow`:创建并执行工作流。
38
38
  - `Flow.node(name)`:注册节点函数。
39
39
  - `Flow.execute(*tasks)`:在当前进程中执行任务,并在所有节点结束后抛出节点异常。
40
- - `Flow.run(*tasks)`:在 fork 子进程中运行工作流,并监听 stdin 中的 JSONL 控制命令。
40
+ - `Flow.run(*tasks)`:运行工作流,并监听 stdin 中的 JSONL 控制命令。支持 fork 的平台在 fork 子进程中运行,其他平台(如 Windows)在当前进程中运行。
41
41
  - `Agent`:描述一次 Agent 请求。
42
42
  - `MultiAgent`:并发运行多个 Agent 请求。
43
43
  - `AgentClient`:连接 ACP WebSocket 端点。
44
- - `Result`:保存节点的最终结果。
44
+ - `Result`:保存节点结果;可与下游任务同时返回。
45
45
 
46
46
  ## 执行语义
47
47
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dynworkflow"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Dynamic Workflow Library for Alkaid0"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "cxykevin", email = "cxykevin@yeah.net" }]
@@ -4,8 +4,9 @@
4
4
  缓存内容分两类:
5
5
  - ``agent_cache``:Agent 运行结果(MultiAgent 按单个 agent 分别缓存),
6
6
  以及未正常结束会话的 ACP sessionId(供下次按需 ACP load 续跑);
7
- - ``node_cache``:节点执行结果——终值(Result/None)或下游调度描述
8
- (Execute 链序列化为 [目标节点 id + 调用时参数],命中时重建重放)。
7
+ - ``node_cache``:节点执行结果——终值(Result/None)、下游调度
8
+ (Execute 链序列化为 [目标节点 id + 调用时参数]),或两者的组合;
9
+ 命中时重建重放。
9
10
 
10
11
  联合键统一为 [会话作用域 + 工作流名 + 节点 ID + 载荷 hash];节点载荷
11
12
  包含函数源码体 hash(代码变更即失效)与全部有效参数。
@@ -204,11 +205,24 @@ class WorkflowCache:
204
205
  return False
205
206
  return self._put("agent_cache", info, serialized, extra_kind=kind)
206
207
 
208
+ @staticmethod
209
+ def _valid_execute_items(items: Any) -> bool:
210
+ """_valid_execute_items() 校验下游调度条目列表是否合法"""
211
+ if not isinstance(items, list):
212
+ return False
213
+ for item in items:
214
+ if (not isinstance(item, dict)
215
+ or not isinstance(item.get("node_id"), str)
216
+ or not isinstance(item.get("args"), dict)):
217
+ return False
218
+ return True
219
+
207
220
  def get_node(self, info: CacheKey) -> dict[str, Any] | None:
208
221
  """get_node() 查询节点条目。
209
222
 
210
- 返回形如 ``{"kind": "none"}``、``{"kind": "result", "value": ...}``
211
- 或 ``{"kind": "executes", "items": [{"node_id": ..., "args": ...}]}``;
223
+ 返回形如 ``{"kind": "none"}``、``{"kind": "result", "value": ...}``、
224
+ ``{"kind": "executes", "items": [{"node_id": ..., "args": ...}]}``
225
+ 或 ``{"kind": "items", "items": [...]}``(Result 与下游调度组合);
212
226
  格式非法视为未命中。
213
227
  """
214
228
  conn = self._conn
@@ -232,17 +246,32 @@ class WorkflowCache:
232
246
  if not isinstance(parsed, dict):
233
247
  return None
234
248
  kind = parsed.get("kind")
235
- if kind not in {"none", "result", "executes"}:
249
+ if kind not in {"none", "result", "executes", "items"}:
236
250
  return None
237
251
  if kind == "executes":
252
+ if not self._valid_execute_items(parsed.get("items")):
253
+ return None
254
+ elif kind == "items":
238
255
  items = parsed.get("items")
239
- if not isinstance(items, list):
256
+ if not isinstance(items, list) or not items:
240
257
  return None
258
+ result_count = 0
241
259
  for item in items:
242
- if (not isinstance(item, dict)
243
- or not isinstance(item.get("node_id"), str)
244
- or not isinstance(item.get("args"), dict)):
260
+ if not isinstance(item, dict):
261
+ return None
262
+ item_kind = item.get("kind")
263
+ if item_kind == "result":
264
+ if "value" not in item:
265
+ return None
266
+ result_count += 1
267
+ elif item_kind == "execute":
268
+ if (not isinstance(item.get("node_id"), str)
269
+ or not isinstance(item.get("args"), dict)):
270
+ return None
271
+ else:
245
272
  return None
273
+ if result_count > 1:
274
+ return None
246
275
  return parsed
247
276
 
248
277
  def put_node(self, info: CacheKey, descriptor: dict[str, Any]) -> bool:
@@ -11,6 +11,7 @@ import threading
11
11
  import time
12
12
  import ast
13
13
  import textwrap
14
+ import traceback
14
15
  from enum import Enum
15
16
 
16
17
  from .agent import (Agent, AgentClientInterface, MultiAgent, AgentStatus,
@@ -43,7 +44,7 @@ class Graph(TypedDict):
43
44
 
44
45
 
45
46
  class Result:
46
- "Result 向 Main Agent 返回结果"
47
+ "Result 保存节点结果;节点可同时继续调度下游任务"
47
48
 
48
49
  def __init__(self, result: Any) -> None:
49
50
  self.result_val = result
@@ -52,7 +53,7 @@ class Result:
52
53
  return f"Result({self.result_val})"
53
54
 
54
55
  def __str__(self) -> str:
55
- return str(self.result)
56
+ return str(self.result_val)
56
57
 
57
58
  def result(self) -> Any:
58
59
  "Result.result() 获取结果"
@@ -76,20 +77,54 @@ class NodeStatus(Enum):
76
77
  ERROR = "error" # 完成
77
78
 
78
79
 
80
+ def _split_node_result(
81
+ ret: 'NodeResult') -> "tuple[list[Any], list[Execute]]":
82
+ '"_split_node_result() 拆分节点返回值为 (Result 值列表, 下游任务列表)"'
83
+
84
+ if ret is None:
85
+ return [], []
86
+ if isinstance(ret, (Result, Execute)):
87
+ items: list[Any] = [ret]
88
+ elif isinstance(ret, (list, tuple)):
89
+ items = list(ret)
90
+ else:
91
+ raise ValueError(f"Invalid node result: {ret!r}")
92
+ results: list[Any] = []
93
+ executes: list[Execute] = []
94
+ for item in items:
95
+ if isinstance(item, Result):
96
+ results.append(item.result())
97
+ elif isinstance(item, Execute):
98
+ executes.append(item)
99
+ else:
100
+ raise ValueError(f"Invalid node result item: {item!r}")
101
+ if len(results) > 1:
102
+ raise ValueError("A node may return at most one Result")
103
+ return results, executes
104
+
105
+
79
106
  def _describe_node_result(ret: 'NodeResult') -> "dict[str, Any] | None":
80
107
  '"_describe_node_result() 把节点返回值序列化为缓存描述符;不可缓存时返回 None'
81
108
 
82
- if ret is None:
83
- return {"kind": "none"}
84
- if isinstance(ret, Result):
109
+ results, executes = _split_node_result(ret)
110
+ exec_items: list[dict[str, Any]] = [
111
+ {"node_id": item._node._node_id, "args": dict(item._args)}
112
+ for item in executes
113
+ ]
114
+ if not results:
115
+ if not exec_items:
116
+ return {"kind": "none"}
117
+ return {"kind": "executes", "items": exec_items}
118
+ if not exec_items:
85
119
  # value 含不可序列化对象时由 put_node 整体放弃写入
86
- return {"kind": "result", "value": ret.result()}
87
- items = list(ret) if isinstance(ret, (list, tuple)) else [ret]
88
- exec_items: list[dict[str, Any]] = []
89
- for item in items:
90
- exec_items.append(
91
- {"node_id": item._node._node_id, "args": dict(item._args)})
92
- return {"kind": "executes", "items": exec_items}
120
+ return {"kind": "result", "value": results[0]}
121
+ items: list[dict[str, Any]] = [
122
+ {"kind": "result", "value": results[0]}]
123
+ items.extend(
124
+ {"kind": "execute", "node_id": item._node._node_id,
125
+ "args": dict(item._args)}
126
+ for item in executes)
127
+ return {"kind": "items", "items": items}
93
128
 
94
129
 
95
130
  class Node():
@@ -307,6 +342,9 @@ class Node():
307
342
  self._report_status(flow, NodeStatus.ERROR, dic)
308
343
  return
309
344
 
345
+ # 先校验返回值:多个 Result 或非法元素在此失败并转为节点错误
346
+ results, executes = _split_node_result(ret)
347
+
310
348
  self.status = NodeStatus.DONE
311
349
  # 推送:节点执行完成
312
350
  self._report_status(flow, NodeStatus.DONE, dic)
@@ -317,55 +355,70 @@ class Node():
317
355
  if descriptor is not None:
318
356
  flow.cache.put_node(node_info, descriptor)
319
357
 
320
- # 无返回值
321
- if ret is None:
322
- return
323
- # 返回值处理
324
- if isinstance(ret, Result):
325
- value = ret.result()
358
+ # 记录 Result;与下游调度互不排斥,可同时生效
359
+ if results:
360
+ value = results[0]
326
361
  flow._result[self._node_id] = value
327
362
  # 推送:节点终值(宿主据此在 workflow 视图里展示 Result)
328
363
  self._report_result(flow, value)
329
- return
330
364
 
331
365
  # 执行流程下一步
332
- if isinstance(ret, Execute):
333
- ret = [ret]
334
- for i in ret:
335
- i.run(flow)
366
+ for item in executes:
367
+ item.run(flow)
336
368
 
337
369
  def _replay_cached(self, flow: 'Flow', cached: dict[str, Any]) -> None:
338
- "Node._replay_cached() 重放缓存的节点结果(终值或下游调度)"
370
+ "Node._replay_cached() 重放缓存的节点结果(终值/下游调度/两者组合)"
339
371
  kind = cached.get("kind")
340
372
  if kind == "none":
341
373
  return
342
374
  if kind == "result":
343
- value = cached.get("value")
344
- flow._result[self._node_id] = value
345
- # 缓存命中同样上报终值,宿主视图与真实执行保持一致
346
- self._report_result(flow, value)
375
+ self._replay_result(flow, cached.get("value"))
347
376
  return
377
+ known = {node._node_id: node for node in flow._nodes}
348
378
  if kind == "executes":
349
- known = {node._node_id: node for node in flow._nodes}
350
379
  items = cached.get("items")
351
380
  if not isinstance(items, list):
352
381
  items = []
353
- items = cast(list[dict[str, Any]], items)
354
- for item in items:
355
- target_id = str(item.get("node_id"))
356
- target = known.get(target_id)
357
- if target is None:
382
+ self._replay_executes(flow, known,
383
+ cast(list[dict[str, Any]], items))
384
+ return
385
+ if kind == "items":
386
+ raw_items = cached.get("items")
387
+ if not isinstance(raw_items, list):
388
+ raw_items = []
389
+ for item in cast(list[dict[str, Any]], raw_items):
390
+ item_kind = item.get("kind")
391
+ if item_kind == "result":
392
+ self._replay_result(flow, item.get("value"))
393
+ elif item_kind == "execute":
394
+ self._replay_executes(flow, known, [item])
395
+ else:
358
396
  raise ValueError(
359
- f"Cached target node not found: {target_id}")
360
- # 重建调用点参数并走正常填充/调度路径,
361
- # 完整保留跨节点部分参数累积语义。
362
- args: Any = item.get("args")
363
- args_value: dict[str, Any] = dict(cast(dict[str, Any], args)) if isinstance(
364
- args, dict) else {}
365
- Execute(target, args_value).run(flow)
397
+ f"Invalid cached node item kind: {item_kind!r}")
366
398
  return
367
399
  raise ValueError(f"Invalid cached node result kind: {kind!r}")
368
400
 
401
+ def _replay_result(self, flow: 'Flow', value: Any) -> None:
402
+ "_replay_result() 写入并上报缓存中的节点终值"
403
+ flow._result[self._node_id] = value
404
+ # 缓存命中同样上报终值,宿主视图与真实执行保持一致
405
+ self._report_result(flow, value)
406
+
407
+ def _replay_executes(self, flow: 'Flow', known: "dict[str, Node]",
408
+ items: list[dict[str, Any]]) -> None:
409
+ "_replay_executes() 重放缓存中的下游调度"
410
+ for item in items:
411
+ target_id = str(item.get("node_id"))
412
+ target = known.get(target_id)
413
+ if target is None:
414
+ raise ValueError(f"Cached target node not found: {target_id}")
415
+ # 重建调用点参数并走正常填充/调度路径,
416
+ # 完整保留跨节点部分参数累积语义。
417
+ args: Any = item.get("args")
418
+ args_value: dict[str, Any] = dict(
419
+ cast(dict[str, Any], args)) if isinstance(args, dict) else {}
420
+ Execute(target, args_value).run(flow)
421
+
369
422
  def _create_thread_task(self, flow: 'Flow') -> threading.Thread:
370
423
  "Node.thread_tast() 启动线程"
371
424
  if (self._thread is not None and self._thread.is_alive()):
@@ -440,11 +493,14 @@ type Agents = (
440
493
  MultiAgent |
441
494
  Agent
442
495
  ) # Agents 子 Agent 对象
443
- type NodeResult = (
496
+ type NodeResultItem = (
444
497
  Execute |
445
- list[Execute] |
446
- tuple[Execute, ...] |
447
- Result |
498
+ Result
499
+ ) # NodeResultItem 节点返回的单个元素
500
+ type NodeResult = (
501
+ NodeResultItem |
502
+ list[NodeResultItem] |
503
+ tuple[NodeResultItem, ...] |
448
504
  None
449
505
  ) # Nodes 节点返回对象
450
506
  type TaskGenerator = (Generator[
@@ -627,7 +683,11 @@ class Flow:
627
683
  self._raise_exceptions()
628
684
 
629
685
  def run(self, *tasks: Execute) -> NoReturn:
630
- """Node.run() 在 fork 子进程执行工作流,主进程监听 stdin JSONL。"""
686
+ """Flow.run() 运行工作流,同时监听 stdin JSONL 控制命令。
687
+
688
+ 支持 fork 的平台(Linux/macOS)在 fork 子进程内执行工作流;
689
+ 没有 fork 的平台(如 Windows)在当前进程内执行工作流。
690
+ """
631
691
  sys.stdout.write("\x1edynworkflow\x1f\n")
632
692
  sys.stdout.flush()
633
693
  self._report = True
@@ -635,6 +695,26 @@ class Flow:
635
695
  self._reporter = optional_reporter(True)
636
696
  if self._agent is None:
637
697
  raise ValueError("No availble agent found!")
698
+ if "fork" in multiprocessing.get_all_start_methods():
699
+ self._run_forked(tasks)
700
+ else:
701
+ # Windows 等平台既没有 fork,spawn 又无法序列化 Flow 状态与
702
+ # 节点函数,因此退化为在当前进程内执行工作流。
703
+ self._run_in_current_process(tasks)
704
+
705
+ def _exit(self, code: int) -> NoReturn:
706
+ "_exit() 强制刷新输出并短暂等待后退出,避免宿主漏读最后一批事件"
707
+ try:
708
+ sys.stdout.flush()
709
+ sys.stderr.flush()
710
+ except Exception:
711
+ # 管道已关闭等情况不影响退出流程。
712
+ pass
713
+ time.sleep(0.2)
714
+ sys.exit(code)
715
+
716
+ def _run_forked(self, tasks: tuple[Execute, ...]) -> NoReturn:
717
+ "_run_forked() 在 fork 子进程执行工作流,当前进程监听 stdin JSONL。"
638
718
  control: Any = multiprocessing.get_context("fork").Queue()
639
719
  process = multiprocessing.get_context("fork").Process(
640
720
  target=self._execute_child, args=(tasks, control))
@@ -670,7 +750,37 @@ class Flow:
670
750
  process.terminate()
671
751
  process.join(2)
672
752
  # Preserve a non-zero exit status when the child failed unexpectedly.
673
- sys.exit(process.exitcode if process.exitcode is not None else 0)
753
+ self._exit(process.exitcode if process.exitcode is not None else 0)
754
+
755
+ def _run_in_current_process(self, tasks: tuple[Execute, ...]) -> NoReturn:
756
+ "_run_in_current_process() 在没有 fork 的平台于当前进程内执行工作流,"
757
+ "stdin 由 daemon 线程转发到控制队列。"
758
+ control: Any = queue.Queue()
759
+
760
+ def read_stdin() -> None:
761
+ try:
762
+ for line in sys.stdin:
763
+ try:
764
+ command = json.loads(line)
765
+ except json.JSONDecodeError:
766
+ continue
767
+ if isinstance(command, dict):
768
+ command = cast(dict[str, str], command)
769
+ control.put(command)
770
+ if command.get("cmd") == "shutdown":
771
+ return
772
+ finally:
773
+ # stdin 结束视为关闭请求,与 fork 版本父进程的清理行为一致。
774
+ control.put({"cmd": "shutdown"})
775
+
776
+ threading.Thread(target=read_stdin, daemon=True).start()
777
+ try:
778
+ self._execute_child(tasks, control)
779
+ except BaseException:
780
+ # 保留非零退出码,行为与 fork 子进程异常退出保持一致。
781
+ traceback.print_exc()
782
+ self._exit(1)
783
+ self._exit(0)
674
784
 
675
785
  def _execute_child(self, tasks: tuple[Execute, ...], control: Any) -> None:
676
786
  def listen() -> None: