dynworkflow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ # coding: utf-8
2
+ """基于 stdio 输出的 JSONL 状态上报。
3
+
4
+ 工作流运行期间把四类事件以 JSONL(每行一个 JSON 对象)写到 stdout:
5
+
6
+ - ``graph``:``Flow.execute`` 生成的流程图(nodes/edges/start);
7
+ - ``node``:节点状态变更(running/done/error,含全部已填充参数,
8
+ 缓存命中同样推送并以 ``cached`` 标记);
9
+ - ``agents_start``:节点即将启动 Agent/MultiAgent(含本次调用的
10
+ 序号、prompt 列表);
11
+ - ``agent``:子 Agent 的实时状态(waiting/running/success/failure、
12
+ 工具调用列表、session id、所属节点与序号等),每次状态变化立即上报;
13
+ - ``node_code``:节点启动时广播其源码;
14
+ - ``node_log``:节点线程内 ``print`` 的输出转发(替换内置 print 实现,按 ``\n`` 成行缓冲,正确处理多参数、sep、end 与 flush)。
15
+
16
+ 每次输出 JSONL 时,先写入 ``\\x1b[?2004h``、结尾写入 ``\\x1b[?2004l``,
17
+ 把整块上报输出包裹为宿主可识别的区段。上报过程绝不抛出异常干扰工作流执行。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import builtins
23
+ import json
24
+ import sys
25
+ import threading
26
+ from collections.abc import Iterable
27
+ from datetime import datetime, timezone
28
+ from typing import TYPE_CHECKING, Any, TextIO
29
+
30
+ if TYPE_CHECKING:
31
+ from .agent import AgentStatusSingleAgent
32
+ from .flow import Graph
33
+
34
+ BRACKETED_PASTE_ENABLE = "\x1b[?2004h" # JSONL 输出块起始标记
35
+ BRACKETED_PASTE_DISABLE = "\x1b[?2004l" # JSONL 输出块结束标记
36
+
37
+ type ReportEvent = dict[str, Any] # 单条上报事件
38
+
39
+
40
+ def _utc_now() -> str:
41
+ "_utc_now() 当前 UTC 时间的 ISO8601 字符串"
42
+ return datetime.now(timezone.utc).isoformat()
43
+
44
+
45
+ class StatusReporter:
46
+ """StatusReporter 把生成的图与 Agent 实时状态以 JSONL 上报到 stdout。"""
47
+
48
+ # 类级写锁:不同实例(例如并发子 Agent 各自创建的实例)写出互不交错
49
+ _write_lock = threading.Lock()
50
+
51
+ def __init__(self, stream: TextIO | None = None, *,
52
+ enabled: bool = True) -> None:
53
+ self.stream: TextIO = sys.stdout if stream is None else stream
54
+ self.enabled = enabled
55
+
56
+ def emit(self, events: Iterable[ReportEvent]) -> None:
57
+ """emit() 把一批事件序列化为 JSONL,整体包裹起止标记后写出到 stdout。"""
58
+ if not self.enabled:
59
+ return
60
+ payload = "".join(
61
+ json.dumps(event, ensure_ascii=False, default=str) + "\n"
62
+ for event in events)
63
+ if not payload:
64
+ return
65
+ with StatusReporter._write_lock:
66
+ try:
67
+ self.stream.write(BRACKETED_PASTE_ENABLE)
68
+ self.stream.write(payload)
69
+ self.stream.write(BRACKETED_PASTE_DISABLE)
70
+ self.stream.flush()
71
+ except Exception:
72
+ # 状态上报不得干扰工作流执行(如管道关闭时的 BrokenPipe)。
73
+ pass
74
+
75
+ def bind(self, status: AgentStatusSingleAgent) -> None:
76
+ """bind() 订阅单个 Agent 状态对象:之后每次状态变化都会自动上报。"""
77
+ status.on_update = self.report_agent
78
+ self.report_agent(status) # 先上报一次初始 waiting 状态
79
+
80
+ def report_agent(self, status: AgentStatusSingleAgent) -> None:
81
+ """report_agent() 上报一次子 Agent 的当前完整快照。"""
82
+ self.emit([{
83
+ "type": "agent",
84
+ "time": _utc_now(),
85
+ "prompt": status.prompt,
86
+ "path": status.path,
87
+ "state": status.status.value,
88
+ "sessionId": status.session_id,
89
+ "nodeId": status.node_id,
90
+ "callIndex": status.call_index,
91
+ "agentIndex": status.agent_index,
92
+ "agentCount": status.agent_count,
93
+ "attempt": status.attempt,
94
+ "tools": [dict(tool) for tool in list(status.tool_calls)],
95
+ }])
96
+
97
+ def report_node(self, workflow_id: str, node_id: str, state: str,
98
+ args: dict[str, Any], *, cached: bool = False) -> None:
99
+ """report_node() 上报节点状态变更(含全部已填充参数)。"""
100
+ self.emit([{
101
+ "type": "node",
102
+ "time": _utc_now(),
103
+ "workflow": workflow_id,
104
+ "nodeId": node_id,
105
+ "state": state,
106
+ "cached": cached,
107
+ "args": dict(args),
108
+ }])
109
+
110
+ def report_agents_start(self, workflow_id: str, node_id: str,
111
+ call_index: int, agents: Any) -> None:
112
+ """report_agents_start() 在节点即将启动 Agent/MultiAgent 前上报。
113
+
114
+ ``agents`` 为 Agent 或 MultiAgent(鸭子类型,取其 prompt(s))。
115
+ """
116
+ prompts: list[str] = []
117
+ single = getattr(agents, "prompt", None)
118
+ if isinstance(single, str):
119
+ prompts.append(single)
120
+ group = getattr(agents, "agents", None)
121
+ if isinstance(group, (list, tuple)):
122
+ for item in group:
123
+ value = getattr(item, "prompt", None)
124
+ if isinstance(value, str):
125
+ prompts.append(value)
126
+ self.emit([{
127
+ "type": "agents_start",
128
+ "time": _utc_now(),
129
+ "workflow": workflow_id,
130
+ "nodeId": node_id,
131
+ "callIndex": call_index,
132
+ "count": len(prompts),
133
+ "prompts": prompts,
134
+ }])
135
+
136
+ def report_node_log(self, workflow_id: str, node_id: str,
137
+ message: str) -> None:
138
+ """report_node_log() 把节点线程内 print 的输出转发为 node_log 事件。"""
139
+ self.emit([{
140
+ "type": "node_log",
141
+ "time": _utc_now(),
142
+ "workflow": workflow_id,
143
+ "nodeId": node_id,
144
+ "message": message,
145
+ }])
146
+
147
+ def report_node_code(self, workflow_id: str, node_id: str, name: str,
148
+ code: str | None) -> None:
149
+ """report_node_code() 在节点启动时广播其源码(不可得时为 null)。"""
150
+ self.emit([{
151
+ "type": "node_code",
152
+ "time": _utc_now(),
153
+ "workflow": workflow_id,
154
+ "nodeId": node_id,
155
+ "name": name,
156
+ "code": code,
157
+ }])
158
+
159
+ def report_graph(self, workflow_id: str, graph: Graph) -> None:
160
+ """report_graph() 上报 Flow 生成的流程图(set 序列化为有序列表)。"""
161
+ self.emit([{
162
+ "type": "graph",
163
+ "time": _utc_now(),
164
+ "workflow": workflow_id,
165
+ "graph": {
166
+ "nodes": dict(graph["nodes"]),
167
+ "edges": {node_id: sorted(targets)
168
+ for node_id, targets in graph["edges"].items()},
169
+ "start": sorted(graph["start"]),
170
+ },
171
+ }])
172
+
173
+
174
+ def optional_reporter(enabled: bool) -> StatusReporter | None:
175
+ """optional_reporter() 按布尔开关创建默认 stdout 上报器,关闭时返回 None。"""
176
+ return StatusReporter() if enabled else None
177
+
178
+ # === 节点线程 print 钩子:节点内 print 转为 node_log,其余线程照常 ===
179
+ # 语义与真实 stdout 对齐:sep 连接多参数后追加 end;按 \n 成行推送,
180
+ # 未成行的片段在线程结束或 flush=True 时补发为一条事件。
181
+
182
+ _REAL_PRINT = print # 替换前捕获的原始内置 print
183
+ _NODE_PRINT_CONTEXTS: dict[int, tuple[StatusReporter, str, str]] = {}
184
+ _NODE_PRINT_BUFFERS: dict[int, list[str]] = {} # 每线程待续写的未成行片段
185
+ _NODE_PRINT_LOCK = threading.Lock()
186
+
187
+
188
+ def _node_print(*args: Any, sep: str = " ", end: str = "\n",
189
+ file: Any = None, flush: bool = False) -> None:
190
+ "_node_print() 内置 print 的替身:节点线程内转为 node_log 事件"
191
+ tid = threading.get_ident()
192
+ context = _NODE_PRINT_CONTEXTS.get(tid)
193
+ if context is None or (file is not None and file is not sys.stdout):
194
+ # 非节点线程 / 显式重定向到其他流时保持原生行为
195
+ _REAL_PRINT(*args, sep=sep, end=end, file=file, flush=flush)
196
+ return
197
+ reporter, workflow_id, node_id = context
198
+ # 与内置 print 一致:str() 逐个转换参数,用 sep 连接,末尾追加 end
199
+ buffer = _NODE_PRINT_BUFFERS.setdefault(tid, [])
200
+ buffer.append(sep.join(str(arg) for arg in args) + end)
201
+ data = "".join(buffer)
202
+ *lines, pending = data.split("\n")
203
+ for line in lines:
204
+ reporter.report_node_log(workflow_id, node_id, line)
205
+ buffer.clear()
206
+ if pending:
207
+ buffer.append(pending)
208
+ if flush and pending:
209
+ # flush=True:尚未成行的残段立即作为一条事件发出
210
+ reporter.report_node_log(workflow_id, node_id, pending)
211
+ buffer.clear()
212
+
213
+
214
+ def _flush_node_print_buffer(reporter: StatusReporter, workflow_id: str,
215
+ node_id: str, fragments: list[str]) -> None:
216
+ "_flush_node_print_buffer() 把未成行的残段补发为一条 node_log 事件"
217
+ text = "".join(fragments)
218
+ if text:
219
+ reporter.report_node_log(workflow_id, node_id, text)
220
+
221
+
222
+ def bind_node_print(reporter: StatusReporter, workflow_id: str,
223
+ node_id: str) -> None:
224
+ """bind_node_print() 注册当前线程:该线程内的 print 转为 node_log。
225
+
226
+ 进程内首个注册会整体替换 ``builtins.print``;未注册线程的 print
227
+ 原样透传,行为不变。
228
+ """
229
+ with _NODE_PRINT_LOCK:
230
+ if not _NODE_PRINT_CONTEXTS:
231
+ setattr(builtins, "print", _node_print)
232
+ _NODE_PRINT_CONTEXTS[threading.get_ident()] = (
233
+ reporter, workflow_id, node_id)
234
+
235
+
236
+ def unbind_node_print() -> None:
237
+ """unbind_node_print() 解除当前线程注册;全部解除后还原内置 print。
238
+
239
+ 解除前把该线程尚未成行的输出残段补发为最后一条 node_log,
240
+ 保证 ``end=""`` 之类不完整输出不丢失。
241
+ """
242
+ tid = threading.get_ident()
243
+ with _NODE_PRINT_LOCK:
244
+ context = _NODE_PRINT_CONTEXTS.pop(tid, None)
245
+ fragments = _NODE_PRINT_BUFFERS.pop(tid, [])
246
+ if not _NODE_PRINT_CONTEXTS:
247
+ setattr(builtins, "print", _REAL_PRINT)
248
+ if context is not None:
249
+ _flush_node_print_buffer(context[0], context[1], context[2],
250
+ fragments)
dynworkflow/schemas.py ADDED
@@ -0,0 +1,88 @@
1
+ """ACP v2 客户端使用的类型化 JSON schema 定义。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, NotRequired, TypeAlias, TypedDict
6
+
7
+
8
+ JsonPrimitive: TypeAlias = None | bool | int | float | str
9
+ JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
10
+ JsonObject: TypeAlias = dict[str, JsonValue]
11
+
12
+
13
+ class JsonRpcError(TypedDict):
14
+ code: int
15
+ message: str
16
+ data: NotRequired[JsonValue]
17
+
18
+
19
+ class JsonRpcMessage(TypedDict, total=False):
20
+ jsonrpc: Literal["2.0"]
21
+ id: int | str | None
22
+ method: str
23
+ params: JsonObject
24
+ result: JsonValue
25
+ error: JsonRpcError
26
+
27
+
28
+ class ClientInfo(TypedDict):
29
+ name: str
30
+ title: NotRequired[str]
31
+ version: str
32
+
33
+
34
+ class InitializeParams(TypedDict):
35
+ protocolVersion: int
36
+ capabilities: JsonObject
37
+ info: ClientInfo
38
+
39
+
40
+ class InitializeResult(TypedDict):
41
+ protocolVersion: int
42
+ capabilities: JsonObject
43
+ info: JsonObject
44
+ authMethods: NotRequired[list[JsonObject]]
45
+
46
+
47
+ class TextContentBlock(TypedDict):
48
+ type: Literal["text"]
49
+ text: str
50
+
51
+
52
+ class SessionNewParams(TypedDict):
53
+ cwd: str
54
+ mcpServers: list[JsonObject]
55
+ model: NotRequired[str]
56
+
57
+
58
+ class SessionResumeParams(SessionNewParams):
59
+ sessionId: str
60
+ replayFrom: NotRequired[JsonObject]
61
+
62
+
63
+ class SessionResult(TypedDict):
64
+ sessionId: str
65
+
66
+
67
+ class PromptParams(TypedDict):
68
+ sessionId: str
69
+ prompt: list[TextContentBlock]
70
+
71
+
72
+ class SessionUpdate(TypedDict, total=False):
73
+ sessionUpdate: str
74
+ messageId: str
75
+ content: JsonValue
76
+ state: str
77
+ stopReason: str
78
+ toolCallId: str
79
+ title: str
80
+ kind: str
81
+ status: str
82
+ rawInput: JsonValue
83
+ rawOutput: JsonValue
84
+
85
+
86
+ class SessionUpdateParams(TypedDict):
87
+ sessionId: str
88
+ update: SessionUpdate
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.3
2
+ Name: dynworkflow
3
+ Version: 0.1.0
4
+ Summary: Dynamic Workflow Library for Alkaid0
5
+ Author: cxykevin
6
+ Author-email: cxykevin <cxykevin@yeah.net>
7
+ Requires-Dist: websockets>=15.0
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # dynworkflow
12
+
13
+ `dynworkflow` 是一个 Python 3.12 库,用于编排动态工作流,工作流节点可以调用一个或多个 ACP Agent。
14
+
15
+ ## 安装
16
+
17
+ 使用项目包管理器安装:
18
+
19
+ ```bash
20
+ uv sync
21
+ ```
22
+
23
+ 运行时依赖为 `websockets`,开发依赖包括 `pytest` 和 `mypy`。
24
+
25
+ ## 快速开始
26
+
27
+ ```python
28
+ import dynworkflow as d
29
+
30
+ flow = d.Flow(
31
+ "document-flow",
32
+ agent=d.AgentClient("ws://127.0.0.1:7433/acp?k=a"),
33
+ )
34
+
35
+ @flow.node("summarize")
36
+ def summarize() -> d.TaskGenerator:
37
+ result = yield d.Agent("Summarize the documents in docs/test", path="docs/test")
38
+ return d.Result(result)
39
+
40
+ flow.execute(summarize())
41
+ ```
42
+
43
+ 使用 `@flow.node(name)` 声明节点。普通节点可以返回 `None`、`Result` 或一个或多个下游 `Execute` 任务。生成器节点可以 `yield Agent` 或 `MultiAgent`,接收 Agent 结果后返回节点结果。
44
+
45
+ ## 公共 API
46
+
47
+ - `Flow`:创建并执行工作流。
48
+ - `Flow.node(name)`:注册节点函数。
49
+ - `Flow.execute(*tasks)`:在当前进程中执行任务,并在所有节点结束后抛出节点异常。
50
+ - `Flow.run(*tasks)`:在 fork 子进程中运行工作流,并监听 stdin 中的 JSONL 控制命令。
51
+ - `Agent`:描述一次 Agent 请求。
52
+ - `MultiAgent`:并发运行多个 Agent 请求。
53
+ - `AgentClient`:连接 ACP WebSocket 端点。
54
+ - `Result`:保存节点的最终结果。
55
+
56
+ ## 执行语义
57
+
58
+ - 相互独立的节点会并发运行。
59
+ - 节点失败后会立即变为 `error` 状态,并在启用上报时立即发送状态。
60
+ - 失败节点不会停止兄弟节点。
61
+ - 所有节点异常会在最后统一以 `ExceptionGroup` 抛出。
62
+ - 节点和 Agent 的终止命令只影响指定目标;工作流关闭会终止整个工作流。
63
+
64
+ ## 缓存与状态上报
65
+
66
+ 缓存默认启用。可以使用 `Flow(..., cache=False)` 关闭缓存。缓存文件位于 `.alkaid0/workflow.db`;设置 `ALKAID0_WORKFLOW_SESSION_ID` 可以隔离不同会话。
67
+
68
+ 可以通过 `Flow(..., report=True)` 或环境变量 `ALKAID0_WORKFLOW_REPORT=1` 开启 JSONL 状态上报。`Flow.run()` 会自动开启上报。事件包括 `graph`、`node`、`agent`、`agents_start`、`node_code` 和 `node_log`。
69
+
70
+ ## 示例
71
+
72
+ 参见 `example.py`,其中包含使用 `AgentClient`、`Agent`、`MultiAgent`、分支和回退参数的完整工作流示例。
73
+
74
+ ## 测试
75
+
76
+ 运行测试套件:
77
+
78
+ ```bash
79
+ python -m pytest -q
80
+ ```
81
+
82
+ 运行静态编译检查:
83
+
84
+ ```bash
85
+ python -m compileall -q src tests
86
+ ```
87
+
88
+ ## 文档索引
89
+
90
+ - `docs/SKILL.md`:仅供 AI Agent 读取的使用说明,保持英文。
91
+ - `docs/architecture.md`:面向人的实现与生命周期说明。
92
+ - `docs/protocol.md`:stdin 控制命令和 JSONL 上报事件说明。
93
+ - `docs/test/ai_must_read.md`:示例工作流读取的测试文档。
@@ -0,0 +1,10 @@
1
+ dynworkflow/__init__.py,sha256=s9TjO6XUkcAZDxJEv0s7WVP9MhGBKrHuWRpgxxmtvbY,317
2
+ dynworkflow/agent.py,sha256=7FmxKSNYNIwljZ-IzxRgbpIhCSErXtvazEAX-JLVSjY,42600
3
+ dynworkflow/cache.py,sha256=W9GLvLq1wMRcq0hIMVk5BO5FIuzaXcRluckvR5XQCdk,10412
4
+ dynworkflow/flow.py,sha256=rEbm8HmZRMCLwE9_QW1_Vq2ei3JW0cXct5UlwtnRTgo,27861
5
+ dynworkflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ dynworkflow/reporter.py,sha256=5zX7UU0nXj1vpx2RR5R_OgLh9nROCdJUM6Wx2zVXgxQ,10277
7
+ dynworkflow/schemas.py,sha256=-if9rPA1fDZ7wcPGK4Rr9TDOyUxS9O2xLOpPPAgtNXo,1774
8
+ dynworkflow-0.1.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
9
+ dynworkflow-0.1.0.dist-info/METADATA,sha256=gdzAfRgWdFBMqXOD_5aVok3mBQDsUEk-ANDMPf_G6hw,3032
10
+ dynworkflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.32
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any