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.
- dynworkflow/__init__.py +19 -0
- dynworkflow/agent.py +1022 -0
- dynworkflow/cache.py +292 -0
- dynworkflow/flow.py +702 -0
- dynworkflow/py.typed +0 -0
- dynworkflow/reporter.py +250 -0
- dynworkflow/schemas.py +88 -0
- dynworkflow-0.1.0.dist-info/METADATA +93 -0
- dynworkflow-0.1.0.dist-info/RECORD +10 -0
- dynworkflow-0.1.0.dist-info/WHEEL +4 -0
dynworkflow/agent.py
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""ACP v2 客户端与工作流 Agent 基础组件。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import ctypes
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import queue
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, Callable, Literal, Protocol, TypedDict, cast
|
|
18
|
+
|
|
19
|
+
from websockets.sync.client import ClientConnection, connect
|
|
20
|
+
|
|
21
|
+
from .cache import CacheContext
|
|
22
|
+
from .reporter import optional_reporter
|
|
23
|
+
from .schemas import (
|
|
24
|
+
InitializeParams,
|
|
25
|
+
InitializeResult,
|
|
26
|
+
JsonObject,
|
|
27
|
+
JsonRpcMessage,
|
|
28
|
+
JsonValue,
|
|
29
|
+
PromptParams,
|
|
30
|
+
SessionNewParams,
|
|
31
|
+
SessionResult,
|
|
32
|
+
SessionResumeParams,
|
|
33
|
+
TextContentBlock,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentStatusEnum(Enum):
|
|
38
|
+
"""子 Agent 的生命周期状态。"""
|
|
39
|
+
|
|
40
|
+
WAITING = "waiting"
|
|
41
|
+
RUNNING = "running"
|
|
42
|
+
SUCCESS = "success"
|
|
43
|
+
FAILURE = "failure"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolCall(TypedDict, total=False):
|
|
47
|
+
"""ACP 会话上报的一次工具调用。"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
tool_id: str
|
|
51
|
+
args: dict[str, Any]
|
|
52
|
+
output: str
|
|
53
|
+
finished: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AgentStatusSingleAgent:
|
|
58
|
+
"""单次 Agent 调用的实时状态。"""
|
|
59
|
+
|
|
60
|
+
prompt: str
|
|
61
|
+
path: str = "."
|
|
62
|
+
tool_calls: list[ToolCall] = field(default_factory=list[ToolCall])
|
|
63
|
+
session_id: str = "" # 本次调用使用的 ACP 会话 ID(未结束会话缓存依赖)
|
|
64
|
+
node_id: str = "" # 所属节点 ID(工作流调度时填充,独立调用为空)
|
|
65
|
+
call_index: int = 0 # 节点内第几次 Agent/MultiAgent 调用(1 起,0 表示未知)
|
|
66
|
+
agent_index: int = 1 # 本次调用内第几个 agent(1 起)
|
|
67
|
+
agent_count: int = 1 # 本次调用的 agent 总数
|
|
68
|
+
attempt: int = 1 # 当前尝试次数
|
|
69
|
+
# 状态订阅回调:status 赋值或显式 notify() 时触发(stdio 状态上报使用)
|
|
70
|
+
on_update: Callable[["AgentStatusSingleAgent"], None] | None = field(
|
|
71
|
+
default=None, repr=False, compare=False)
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
self._status = AgentStatusEnum.WAITING
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def status(self) -> AgentStatusEnum:
|
|
78
|
+
"""status 生命周期状态;每次赋值自动触发一次 on_update 回调。"""
|
|
79
|
+
return self._status
|
|
80
|
+
|
|
81
|
+
@status.setter
|
|
82
|
+
def status(self, value: AgentStatusEnum) -> None:
|
|
83
|
+
self._status = value
|
|
84
|
+
self.notify()
|
|
85
|
+
|
|
86
|
+
def notify(self) -> None:
|
|
87
|
+
"""notify() 手动触发一次状态上报(嵌套字段如工具调用变化后调用)。"""
|
|
88
|
+
callback = self.on_update
|
|
89
|
+
if callback is not None:
|
|
90
|
+
callback(self)
|
|
91
|
+
|
|
92
|
+
def reset_for_retry(self) -> None:
|
|
93
|
+
"""reset_for_retry() 清理本次状态并开始下一次尝试。"""
|
|
94
|
+
self.attempt += 1
|
|
95
|
+
self.tool_calls.clear()
|
|
96
|
+
self.session_id = ""
|
|
97
|
+
self.status = AgentStatusEnum.WAITING
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
type AgentStatus = list[AgentStatusSingleAgent]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_path(value: str | pathlib.Path) -> str:
|
|
104
|
+
"""把工作流中的相对路径基于当前工作目录解析为绝对路径。"""
|
|
105
|
+
return str(pathlib.Path(value).expanduser().resolve())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AgentClientInterface(Protocol):
|
|
109
|
+
"""Agent 与 Flow 接受的最小客户端契约。"""
|
|
110
|
+
|
|
111
|
+
def run(self, prompt: str, *, model: str = "", cwd: str | pathlib.Path = ".",
|
|
112
|
+
status: AgentStatusSingleAgent | None = None) -> Any:
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _ACPConnection:
|
|
117
|
+
"""单条 ACP v2 stdio 连接,使用换行分隔的 JSON-RPC。"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, command: list[str], cwd: pathlib.Path, timeout: float) -> None:
|
|
120
|
+
self.command = command
|
|
121
|
+
self.timeout = timeout
|
|
122
|
+
self.process = subprocess.Popen(
|
|
123
|
+
command,
|
|
124
|
+
cwd=str(cwd),
|
|
125
|
+
stdin=subprocess.PIPE,
|
|
126
|
+
stdout=subprocess.PIPE,
|
|
127
|
+
stderr=subprocess.PIPE,
|
|
128
|
+
bufsize=0,
|
|
129
|
+
)
|
|
130
|
+
assert self.process.stdin is not None
|
|
131
|
+
assert self.process.stdout is not None
|
|
132
|
+
self._stdin = self.process.stdin
|
|
133
|
+
self._stdout = self.process.stdout
|
|
134
|
+
self._messages: queue.Queue[JsonRpcMessage | Exception] = queue.Queue()
|
|
135
|
+
self._write_lock = threading.Lock()
|
|
136
|
+
self._next_id = 1
|
|
137
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
138
|
+
self._reader.start()
|
|
139
|
+
self._stderr_reader = threading.Thread(
|
|
140
|
+
target=self._drain_stderr, daemon=True)
|
|
141
|
+
self._stderr_reader.start()
|
|
142
|
+
|
|
143
|
+
def _drain_stderr(self) -> None:
|
|
144
|
+
if self.process.stderr is not None:
|
|
145
|
+
while self.process.stderr.readline():
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def _read_loop(self) -> None:
|
|
149
|
+
try:
|
|
150
|
+
while True:
|
|
151
|
+
line: bytes = self._stdout.readline()
|
|
152
|
+
if not line:
|
|
153
|
+
break
|
|
154
|
+
try:
|
|
155
|
+
value: object = json.loads(line.decode("utf-8"))
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
# ACP 诊断信息应输出到 stderr;容忍杂散行,
|
|
158
|
+
# 避免一条坏诊断导致 JSON 流失去同步。
|
|
159
|
+
continue
|
|
160
|
+
if isinstance(value, list):
|
|
161
|
+
messages = cast(list[object], value)
|
|
162
|
+
for message in messages:
|
|
163
|
+
if isinstance(message, dict):
|
|
164
|
+
self._messages.put(cast(JsonRpcMessage, message))
|
|
165
|
+
elif isinstance(value, dict):
|
|
166
|
+
self._messages.put(cast(JsonRpcMessage, value))
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
self._messages.put(exc)
|
|
169
|
+
finally:
|
|
170
|
+
self._messages.put(EOFError("ACP connection closed"))
|
|
171
|
+
|
|
172
|
+
def send(self, message: JsonRpcMessage) -> None:
|
|
173
|
+
payload = (json.dumps(message, separators=(",", ":"),
|
|
174
|
+
ensure_ascii=False) + "\n").encode()
|
|
175
|
+
with self._write_lock:
|
|
176
|
+
if self.process.poll() is not None:
|
|
177
|
+
raise RuntimeError("ACP process exited")
|
|
178
|
+
self._stdin.write(payload)
|
|
179
|
+
self._stdin.flush()
|
|
180
|
+
|
|
181
|
+
def request(self, method: str, params: JsonObject | None = None,
|
|
182
|
+
on_notification: Callable[[JsonRpcMessage], None] | None = None) -> JsonValue:
|
|
183
|
+
request_id = self._next_id
|
|
184
|
+
self._next_id += 1
|
|
185
|
+
self.send({
|
|
186
|
+
"jsonrpc": "2.0",
|
|
187
|
+
"id": request_id,
|
|
188
|
+
"method": method,
|
|
189
|
+
"params": {} if params is None else params,
|
|
190
|
+
})
|
|
191
|
+
return self.wait_for_response(request_id, method, on_notification)
|
|
192
|
+
|
|
193
|
+
def wait_for_response(self, request_id: int | str, method: str,
|
|
194
|
+
on_notification: Callable[[JsonRpcMessage], None] | None = None) -> JsonValue:
|
|
195
|
+
while True:
|
|
196
|
+
message = self._get_message(method)
|
|
197
|
+
if "method" in message:
|
|
198
|
+
self._handle_incoming_request(message)
|
|
199
|
+
if on_notification is not None:
|
|
200
|
+
on_notification(message)
|
|
201
|
+
continue
|
|
202
|
+
if message.get("id") != request_id:
|
|
203
|
+
continue
|
|
204
|
+
if "error" in message:
|
|
205
|
+
error = message["error"]
|
|
206
|
+
detail = error.get("message", "ACP error")
|
|
207
|
+
raise RuntimeError(f"ACP {method} failed: {detail}")
|
|
208
|
+
return message.get("result")
|
|
209
|
+
|
|
210
|
+
def wait_for_notification(self, on_notification: Callable[[JsonRpcMessage], None],
|
|
211
|
+
predicate: Callable[[JsonRpcMessage], bool],
|
|
212
|
+
method: str) -> JsonRpcMessage:
|
|
213
|
+
while True:
|
|
214
|
+
message = self._get_message(method)
|
|
215
|
+
if "method" in message:
|
|
216
|
+
self._handle_incoming_request(message)
|
|
217
|
+
on_notification(message)
|
|
218
|
+
if predicate(message):
|
|
219
|
+
return message
|
|
220
|
+
|
|
221
|
+
def _get_message(self, operation: str) -> JsonRpcMessage:
|
|
222
|
+
try:
|
|
223
|
+
message = self._messages.get(timeout=self.timeout)
|
|
224
|
+
except queue.Empty as exc:
|
|
225
|
+
raise TimeoutError(f"ACP request timed out: {operation}") from exc
|
|
226
|
+
if isinstance(message, Exception):
|
|
227
|
+
raise RuntimeError(str(message)) from message
|
|
228
|
+
return message
|
|
229
|
+
|
|
230
|
+
def _handle_incoming_request(self, message: JsonRpcMessage) -> None:
|
|
231
|
+
"""回复不支持的 Agent→Client 请求,避免协议死锁。"""
|
|
232
|
+
if "id" not in message:
|
|
233
|
+
return
|
|
234
|
+
self.send({
|
|
235
|
+
"jsonrpc": "2.0",
|
|
236
|
+
"id": message["id"],
|
|
237
|
+
"error": {"code": -32601, "message": "Client method not supported"},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
if self.process.poll() is None:
|
|
242
|
+
try:
|
|
243
|
+
self._stdin.close()
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
try:
|
|
247
|
+
self.process.wait(timeout=1)
|
|
248
|
+
except subprocess.TimeoutExpired:
|
|
249
|
+
self.process.terminate()
|
|
250
|
+
try:
|
|
251
|
+
self.process.wait(timeout=1)
|
|
252
|
+
except subprocess.TimeoutExpired:
|
|
253
|
+
self.process.kill()
|
|
254
|
+
self.process.wait()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class _ACPWebSocketConnection:
|
|
258
|
+
"""ACP v2 WebSocket 传输,每帧承载一条 JSON 消息。"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, url: str, timeout: float) -> None:
|
|
261
|
+
self.timeout = timeout
|
|
262
|
+
self._socket: ClientConnection = connect(url, open_timeout=timeout)
|
|
263
|
+
self._write_lock = threading.Lock()
|
|
264
|
+
self._next_id = 1
|
|
265
|
+
|
|
266
|
+
def send(self, message: JsonRpcMessage) -> None:
|
|
267
|
+
payload = json.dumps(message, separators=(
|
|
268
|
+
",", ":"), ensure_ascii=False)
|
|
269
|
+
with self._write_lock:
|
|
270
|
+
self._socket.send(payload)
|
|
271
|
+
|
|
272
|
+
def request(self, method: str, params: JsonObject | None = None,
|
|
273
|
+
on_notification: Callable[[JsonRpcMessage], None] | None = None) -> JsonValue:
|
|
274
|
+
request_id = self._next_id
|
|
275
|
+
self._next_id += 1
|
|
276
|
+
self.send({"jsonrpc": "2.0", "id": request_id, "method": method,
|
|
277
|
+
"params": {} if params is None else params})
|
|
278
|
+
return self.wait_for_response(request_id, method, on_notification)
|
|
279
|
+
|
|
280
|
+
def _receive(self, operation: str) -> JsonRpcMessage:
|
|
281
|
+
try:
|
|
282
|
+
raw = self._socket.recv(timeout=self.timeout)
|
|
283
|
+
except TimeoutError as exc:
|
|
284
|
+
raise TimeoutError(f"ACP request timed out: {operation}") from exc
|
|
285
|
+
if not isinstance(raw, str):
|
|
286
|
+
raise RuntimeError("ACP WebSocket message must be text")
|
|
287
|
+
value: object = json.loads(raw)
|
|
288
|
+
if not isinstance(value, dict):
|
|
289
|
+
raise RuntimeError("ACP WebSocket message must be a JSON object")
|
|
290
|
+
return cast(JsonRpcMessage, value)
|
|
291
|
+
|
|
292
|
+
def wait_for_response(self, request_id: int | str, method: str,
|
|
293
|
+
on_notification: Callable[[JsonRpcMessage], None] | None = None) -> JsonValue:
|
|
294
|
+
while True:
|
|
295
|
+
message = self._receive(method)
|
|
296
|
+
if "method" in message:
|
|
297
|
+
self._handle_incoming_request(message)
|
|
298
|
+
if on_notification is not None:
|
|
299
|
+
on_notification(message)
|
|
300
|
+
continue
|
|
301
|
+
if message.get("id") != request_id:
|
|
302
|
+
continue
|
|
303
|
+
if "error" in message:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
f"ACP {method} failed: {message['error'].get('message', 'ACP error')}")
|
|
306
|
+
return message.get("result")
|
|
307
|
+
|
|
308
|
+
def wait_for_notification(self, on_notification: Callable[[JsonRpcMessage], None],
|
|
309
|
+
predicate: Callable[[JsonRpcMessage], bool],
|
|
310
|
+
method: str) -> JsonRpcMessage:
|
|
311
|
+
while True:
|
|
312
|
+
message = self._receive(method)
|
|
313
|
+
if "method" in message:
|
|
314
|
+
self._handle_incoming_request(message)
|
|
315
|
+
on_notification(message)
|
|
316
|
+
if predicate(message):
|
|
317
|
+
return message
|
|
318
|
+
|
|
319
|
+
def _handle_incoming_request(self, message: JsonRpcMessage) -> None:
|
|
320
|
+
if "id" in message:
|
|
321
|
+
self.send({"jsonrpc": "2.0", "id": message["id"],
|
|
322
|
+
"error": {"code": -32601, "message": "Client method not supported"}})
|
|
323
|
+
|
|
324
|
+
def close(self) -> None:
|
|
325
|
+
self._socket.close()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _UpdateCollector:
|
|
329
|
+
"""收集 v2 session/update 通知,并把工具状态同步到 AgentStatus。"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, status: AgentStatusSingleAgent | None) -> None:
|
|
332
|
+
self.status = status
|
|
333
|
+
self._messages: dict[str, list[JsonValue]] = {}
|
|
334
|
+
self._message_order: list[str] = []
|
|
335
|
+
self._anonymous: list[JsonValue] = []
|
|
336
|
+
self.idle_update: JsonObject | None = None
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _text(content: JsonValue) -> str:
|
|
340
|
+
if isinstance(content, str):
|
|
341
|
+
return content
|
|
342
|
+
if isinstance(content, dict):
|
|
343
|
+
text = content.get("text")
|
|
344
|
+
return text if isinstance(text, str) else ""
|
|
345
|
+
if isinstance(content, list):
|
|
346
|
+
return "".join(_UpdateCollector._text(item) for item in content)
|
|
347
|
+
return ""
|
|
348
|
+
|
|
349
|
+
def _message_content(self, update: JsonObject) -> str:
|
|
350
|
+
message_id = update.get("messageId")
|
|
351
|
+
content = update.get("content")
|
|
352
|
+
if not isinstance(message_id, str):
|
|
353
|
+
return self._text(content)
|
|
354
|
+
if message_id not in self._messages:
|
|
355
|
+
self._messages[message_id] = []
|
|
356
|
+
self._message_order.append(message_id)
|
|
357
|
+
session_update = update.get("sessionUpdate")
|
|
358
|
+
if isinstance(session_update, str) and session_update.endswith("_chunk"):
|
|
359
|
+
if content is not None:
|
|
360
|
+
self._messages[message_id].append(content)
|
|
361
|
+
elif "content" in update:
|
|
362
|
+
self._messages[message_id] = [] if content is None else list(
|
|
363
|
+
content) if isinstance(content, list) else [content]
|
|
364
|
+
return self._text(self._messages[message_id])
|
|
365
|
+
|
|
366
|
+
def _tool(self, update: JsonObject) -> None:
|
|
367
|
+
if self.status is None:
|
|
368
|
+
return
|
|
369
|
+
tool_id = update.get("toolCallId", update.get("tool_call_id"))
|
|
370
|
+
if not isinstance(tool_id, str) or not tool_id:
|
|
371
|
+
return
|
|
372
|
+
tool: ToolCall | None = next(
|
|
373
|
+
(item for item in self.status.tool_calls if item.get("tool_id") == tool_id), None)
|
|
374
|
+
if tool is None:
|
|
375
|
+
new_tool: ToolCall = {"tool_id": tool_id, "name": "",
|
|
376
|
+
"args": {}, "output": "", "finished": False}
|
|
377
|
+
self.status.tool_calls.append(new_tool)
|
|
378
|
+
tool = new_tool
|
|
379
|
+
if "title" in update:
|
|
380
|
+
tool["name"] = str(update["title"])
|
|
381
|
+
if "kind" in update and not tool.get("name"):
|
|
382
|
+
tool["name"] = str(update["kind"])
|
|
383
|
+
if "rawInput" in update:
|
|
384
|
+
raw_input = update["rawInput"]
|
|
385
|
+
tool["args"] = raw_input if isinstance(raw_input, dict) else {}
|
|
386
|
+
if "rawOutput" in update:
|
|
387
|
+
value = update["rawOutput"]
|
|
388
|
+
tool["output"] = value if isinstance(
|
|
389
|
+
value, str) else json.dumps(value, ensure_ascii=False)
|
|
390
|
+
if "content" in update:
|
|
391
|
+
content = update["content"]
|
|
392
|
+
value = self._text(content)
|
|
393
|
+
if value:
|
|
394
|
+
tool["output"] = value
|
|
395
|
+
tool["finished"] = update.get("status") in {
|
|
396
|
+
"completed", "failed", "cancelled"}
|
|
397
|
+
|
|
398
|
+
def handle(self, message: JsonRpcMessage) -> None:
|
|
399
|
+
params = message.get("params")
|
|
400
|
+
if not isinstance(params, dict):
|
|
401
|
+
return
|
|
402
|
+
update_value = params.get("update")
|
|
403
|
+
if not isinstance(update_value, dict):
|
|
404
|
+
return
|
|
405
|
+
update: JsonObject = update_value
|
|
406
|
+
kind = update.get("sessionUpdate")
|
|
407
|
+
if kind in {"agent_message", "agent_message_chunk"}:
|
|
408
|
+
# 仅返回助手消息;思考内容保留在内部。
|
|
409
|
+
self._message_content(update)
|
|
410
|
+
elif kind == "state_update" and update.get("state") == "idle":
|
|
411
|
+
self.idle_update = update
|
|
412
|
+
self._touch()
|
|
413
|
+
elif kind in {"tool_call_update", "tool_call", "tool_call_content_chunk"}:
|
|
414
|
+
self._tool(update)
|
|
415
|
+
if kind == "tool_call_content_chunk" and self.status is not None:
|
|
416
|
+
tool_id = update.get("toolCallId")
|
|
417
|
+
tool = next((item for item in self.status.tool_calls if item.get(
|
|
418
|
+
"tool_id") == tool_id), None)
|
|
419
|
+
if tool is not None:
|
|
420
|
+
content = update.get("content", "")
|
|
421
|
+
tool["output"] = tool.get(
|
|
422
|
+
"output", "") + self._text(content)
|
|
423
|
+
self._touch()
|
|
424
|
+
|
|
425
|
+
def _touch(self) -> None:
|
|
426
|
+
"""_touch() 工具调用/回合状态变化后触发实时上报。"""
|
|
427
|
+
if self.status is not None:
|
|
428
|
+
self.status.notify()
|
|
429
|
+
|
|
430
|
+
def text(self) -> str:
|
|
431
|
+
return "".join(self._text(self._messages[key]) for key in self._message_order) + self._text(self._anonymous)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class AgentClient:
|
|
435
|
+
"""AgentClient 无第三方重依赖的 ACP v2 客户端,支持 stdio 与 WebSocket 两种传输。
|
|
436
|
+
|
|
437
|
+
``path`` 可接受可执行命令、shell 风格命令字符串、含 ``command``/``args``
|
|
438
|
+
的 JSON 描述文件,或 ``ws://``/``wss://`` URL。每次 ``run`` 都会新建连接、
|
|
439
|
+
完成 initialize/initialized 握手,并通过 ``session/new`` 创建全新会话。
|
|
440
|
+
v2 使用 ``session/resume`` 恢复会话;``session_load`` 作为兼容名称保留,
|
|
441
|
+
供将来需要加载/恢复钩子的调用方使用。
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
protocol_version = 2
|
|
445
|
+
|
|
446
|
+
def __init__(self, path: str | pathlib.Path, *, timeout: float = 300.0) -> None:
|
|
447
|
+
self.timeout = timeout
|
|
448
|
+
self._connection: _ACPConnection | _ACPWebSocketConnection | None = None
|
|
449
|
+
self._connection_lock = threading.Lock()
|
|
450
|
+
self.agent_info: dict[str, Any] = {}
|
|
451
|
+
self.agent_capabilities: dict[str, Any] = {}
|
|
452
|
+
raw_path = os.fspath(path)
|
|
453
|
+
self._is_websocket = raw_path.startswith(("ws://", "wss://"))
|
|
454
|
+
if self._is_websocket:
|
|
455
|
+
# WebSocket URL 不是文件系统路径,保持原样。
|
|
456
|
+
self.path: str | pathlib.Path = raw_path
|
|
457
|
+
elif pathlib.Path(raw_path).is_file():
|
|
458
|
+
# 指向真实文件的连接描述(JSON 描述文件 / 脚本)在构造时
|
|
459
|
+
# 即锚定为绝对路径,避免运行期工作目录变化导致找不到。
|
|
460
|
+
self.path = _resolve_path(raw_path)
|
|
461
|
+
else:
|
|
462
|
+
# 其余情况是命令字符串,不能按路径解析,保持原样。
|
|
463
|
+
self.path = raw_path
|
|
464
|
+
self._websocket_url = raw_path if self._is_websocket else ""
|
|
465
|
+
|
|
466
|
+
def _command(self) -> list[str]:
|
|
467
|
+
if self._is_websocket:
|
|
468
|
+
raise RuntimeError("WebSocket ACP endpoint has no command")
|
|
469
|
+
value = os.fspath(self.path)
|
|
470
|
+
candidate = pathlib.Path(value)
|
|
471
|
+
if candidate.is_file() and candidate.suffix.lower() == ".json":
|
|
472
|
+
try:
|
|
473
|
+
loaded: object = json.loads(
|
|
474
|
+
candidate.read_text(encoding="utf-8"))
|
|
475
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
476
|
+
raise ValueError(
|
|
477
|
+
f"Invalid ACP connection file: {value}") from exc
|
|
478
|
+
if not isinstance(loaded, dict):
|
|
479
|
+
raise ValueError(
|
|
480
|
+
"ACP connection file must contain a JSON object")
|
|
481
|
+
info = cast(dict[str, object], loaded)
|
|
482
|
+
command_value = info.get("command", info.get("executable"))
|
|
483
|
+
args_value = info.get("args", [])
|
|
484
|
+
if not isinstance(command_value, str) or not isinstance(args_value, list):
|
|
485
|
+
raise ValueError(
|
|
486
|
+
"ACP connection file requires command and args")
|
|
487
|
+
typed_args = cast(list[object], args_value)
|
|
488
|
+
command_args: list[str] = [command_value]
|
|
489
|
+
for arg in typed_args:
|
|
490
|
+
if isinstance(arg, (str, int, float, bool)):
|
|
491
|
+
command_args.append(str(arg))
|
|
492
|
+
return command_args
|
|
493
|
+
command = shlex.split(value)
|
|
494
|
+
if not command:
|
|
495
|
+
raise ValueError("ACP command cannot be empty")
|
|
496
|
+
if candidate.is_file() and candidate.suffix.lower() == ".py":
|
|
497
|
+
return [sys.executable, *command]
|
|
498
|
+
return command
|
|
499
|
+
|
|
500
|
+
def _open(self, cwd: str | pathlib.Path = ".") -> _ACPConnection | _ACPWebSocketConnection:
|
|
501
|
+
if self._is_websocket:
|
|
502
|
+
return _ACPWebSocketConnection(self._websocket_url, self.timeout)
|
|
503
|
+
workdir = pathlib.Path(cwd).expanduser().resolve()
|
|
504
|
+
if not workdir.is_dir():
|
|
505
|
+
raise FileNotFoundError(
|
|
506
|
+
f"ACP working directory does not exist: {workdir}")
|
|
507
|
+
return _ACPConnection(self._command(), workdir, self.timeout)
|
|
508
|
+
|
|
509
|
+
def _initialize(self, connection: _ACPConnection | _ACPWebSocketConnection) -> InitializeResult:
|
|
510
|
+
params: InitializeParams = {
|
|
511
|
+
"protocolVersion": self.protocol_version,
|
|
512
|
+
"capabilities": {},
|
|
513
|
+
"info": {"name": "dynworkflow", "title": "dynworkflow", "version": "0.1.0"},
|
|
514
|
+
}
|
|
515
|
+
result = connection.request("initialize", cast(JsonObject, params))
|
|
516
|
+
if not isinstance(result, dict) or result.get("protocolVersion") != self.protocol_version:
|
|
517
|
+
raise RuntimeError(
|
|
518
|
+
"ACP agent did not negotiate protocol version 2")
|
|
519
|
+
info = result.get("info")
|
|
520
|
+
capabilities = result.get("capabilities")
|
|
521
|
+
self.agent_info = info if isinstance(info, dict) else {}
|
|
522
|
+
self.agent_capabilities = capabilities if isinstance(
|
|
523
|
+
capabilities, dict) else {}
|
|
524
|
+
connection.send(
|
|
525
|
+
{"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
|
526
|
+
return cast(InitializeResult, result)
|
|
527
|
+
|
|
528
|
+
def _session_params(self, cwd: str | pathlib.Path,
|
|
529
|
+
mcp_servers: list[JsonObject] | None = None) -> SessionNewParams:
|
|
530
|
+
params: SessionNewParams = {
|
|
531
|
+
"cwd": str(pathlib.Path(cwd).expanduser().resolve()),
|
|
532
|
+
"mcpServers": [] if mcp_servers is None else mcp_servers,
|
|
533
|
+
}
|
|
534
|
+
return params
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _session_id(result: JsonValue) -> str:
|
|
538
|
+
if not isinstance(result, dict):
|
|
539
|
+
raise RuntimeError("ACP session response has no sessionId")
|
|
540
|
+
value = result.get("sessionId")
|
|
541
|
+
if not isinstance(value, str):
|
|
542
|
+
raise RuntimeError("ACP session response has no sessionId")
|
|
543
|
+
return value
|
|
544
|
+
|
|
545
|
+
def initialize(self, *, cwd: str | pathlib.Path = ".") -> dict[str, Any]:
|
|
546
|
+
"""初始化一条持久连接,供显式会话操作使用。"""
|
|
547
|
+
with self._connection_lock:
|
|
548
|
+
if self._connection is None:
|
|
549
|
+
connection = self._open(cwd)
|
|
550
|
+
try:
|
|
551
|
+
self._initialize(connection)
|
|
552
|
+
except Exception:
|
|
553
|
+
connection.close()
|
|
554
|
+
raise
|
|
555
|
+
self._connection = connection
|
|
556
|
+
return {"protocolVersion": self.protocol_version, "info": self.agent_info,
|
|
557
|
+
"capabilities": self.agent_capabilities}
|
|
558
|
+
|
|
559
|
+
def _persistent_request(self, method: str, params: JsonObject,
|
|
560
|
+
cwd: str | pathlib.Path) -> JsonValue:
|
|
561
|
+
with self._connection_lock:
|
|
562
|
+
if self._connection is None:
|
|
563
|
+
connection = self._open(cwd)
|
|
564
|
+
try:
|
|
565
|
+
self._initialize(connection)
|
|
566
|
+
except Exception:
|
|
567
|
+
connection.close()
|
|
568
|
+
raise
|
|
569
|
+
self._connection = connection
|
|
570
|
+
return self._connection.request(method, params)
|
|
571
|
+
|
|
572
|
+
def session_new(self, *, cwd: str | pathlib.Path = ".", model: str = "",
|
|
573
|
+
mcp_servers: list[JsonObject] | None = None) -> SessionResult:
|
|
574
|
+
params = self._session_params(cwd, mcp_servers)
|
|
575
|
+
if model:
|
|
576
|
+
params["model"] = model
|
|
577
|
+
result = self._persistent_request(
|
|
578
|
+
"session/new", cast(JsonObject, params), cwd)
|
|
579
|
+
if not isinstance(result, dict):
|
|
580
|
+
raise RuntimeError("ACP session/new returned an invalid response")
|
|
581
|
+
return cast(SessionResult, result)
|
|
582
|
+
|
|
583
|
+
new_session = session_new
|
|
584
|
+
|
|
585
|
+
def session_resume(self, session_id: str, *, cwd: str | pathlib.Path = ".",
|
|
586
|
+
mcp_servers: list[JsonObject] | None = None,
|
|
587
|
+
replay_from: JsonObject | None = None) -> SessionResult:
|
|
588
|
+
params: SessionResumeParams = {
|
|
589
|
+
"cwd": str(pathlib.Path(cwd).expanduser().resolve()),
|
|
590
|
+
"mcpServers": [] if mcp_servers is None else mcp_servers,
|
|
591
|
+
"sessionId": session_id,
|
|
592
|
+
}
|
|
593
|
+
if replay_from is not None:
|
|
594
|
+
params["replayFrom"] = replay_from
|
|
595
|
+
result = self._persistent_request(
|
|
596
|
+
"session/resume", cast(JsonObject, params), cwd)
|
|
597
|
+
if not isinstance(result, dict):
|
|
598
|
+
raise RuntimeError(
|
|
599
|
+
"ACP session/resume returned an invalid response")
|
|
600
|
+
return cast(SessionResult, result)
|
|
601
|
+
|
|
602
|
+
def session_load(self, session_id: str, *, cwd: str | pathlib.Path = ".",
|
|
603
|
+
mcp_servers: list[JsonObject] | None = None) -> SessionResult:
|
|
604
|
+
"""预留的加载接口,映射到 ACP v2 的 session/resume。"""
|
|
605
|
+
return self.session_resume(session_id, cwd=cwd, mcp_servers=mcp_servers,
|
|
606
|
+
replay_from={"type": "start"})
|
|
607
|
+
|
|
608
|
+
load_session = session_load
|
|
609
|
+
|
|
610
|
+
def run(self, prompt: str, *, model: str = "", cwd: str | pathlib.Path = ".",
|
|
611
|
+
status: AgentStatusSingleAgent | None = None) -> str:
|
|
612
|
+
"""在新建的 ACP v2 连接与会话中执行一次 prompt。"""
|
|
613
|
+
connection = self._open(cwd)
|
|
614
|
+
collector = _UpdateCollector(status)
|
|
615
|
+
try:
|
|
616
|
+
self._initialize(connection)
|
|
617
|
+
params = self._session_params(cwd)
|
|
618
|
+
if model:
|
|
619
|
+
params["model"] = model
|
|
620
|
+
session = connection.request(
|
|
621
|
+
"session/new", cast(JsonObject, params))
|
|
622
|
+
session_id = self._session_id(session)
|
|
623
|
+
if status is not None:
|
|
624
|
+
status.session_id = session_id
|
|
625
|
+
status.notify()
|
|
626
|
+
return self._collect_prompt(connection, session_id, prompt,
|
|
627
|
+
collector)
|
|
628
|
+
finally:
|
|
629
|
+
connection.close()
|
|
630
|
+
|
|
631
|
+
execute = run
|
|
632
|
+
|
|
633
|
+
def run_resumed(self, prompt: str, session_id: str, *, model: str = "",
|
|
634
|
+
cwd: str | pathlib.Path = ".",
|
|
635
|
+
status: AgentStatusSingleAgent | None = None) -> str:
|
|
636
|
+
"""在按需加载(ACP v2 ``session/resume``)的既有会话中继续执行 prompt。
|
|
637
|
+
|
|
638
|
+
供工作流缓存命中“未结束会话”条目时调用;加载或执行失败时抛出
|
|
639
|
+
异常,由调用方决定回退策略。
|
|
640
|
+
"""
|
|
641
|
+
connection = self._open(cwd)
|
|
642
|
+
collector = _UpdateCollector(status)
|
|
643
|
+
try:
|
|
644
|
+
self._initialize(connection)
|
|
645
|
+
resume_params: SessionResumeParams = {
|
|
646
|
+
"cwd": str(pathlib.Path(cwd).expanduser().resolve()),
|
|
647
|
+
"mcpServers": [],
|
|
648
|
+
"sessionId": session_id,
|
|
649
|
+
"replayFrom": {"type": "start"},
|
|
650
|
+
}
|
|
651
|
+
resumed = connection.request(
|
|
652
|
+
"session/resume", cast(JsonObject, resume_params))
|
|
653
|
+
active_session_id = session_id
|
|
654
|
+
if isinstance(resumed, dict):
|
|
655
|
+
resumed_id = resumed.get("sessionId")
|
|
656
|
+
if isinstance(resumed_id, str) and resumed_id:
|
|
657
|
+
active_session_id = resumed_id
|
|
658
|
+
if status is not None:
|
|
659
|
+
status.session_id = active_session_id
|
|
660
|
+
status.notify()
|
|
661
|
+
return self._collect_prompt(connection, active_session_id, prompt,
|
|
662
|
+
collector)
|
|
663
|
+
finally:
|
|
664
|
+
connection.close()
|
|
665
|
+
|
|
666
|
+
def _collect_prompt(self,
|
|
667
|
+
connection: _ACPConnection | _ACPWebSocketConnection,
|
|
668
|
+
session_id: str, prompt: str,
|
|
669
|
+
collector: _UpdateCollector) -> str:
|
|
670
|
+
"""发送 session/prompt 并等待回合结束,返回助手回复文本。"""
|
|
671
|
+
prompt_block: TextContentBlock = {"type": "text", "text": prompt}
|
|
672
|
+
prompt_params: PromptParams = {
|
|
673
|
+
"sessionId": session_id, "prompt": [prompt_block]}
|
|
674
|
+
connection.request(
|
|
675
|
+
"session/prompt", cast(JsonObject, prompt_params),
|
|
676
|
+
cast(Callable[[JsonRpcMessage], None], collector.handle),
|
|
677
|
+
)
|
|
678
|
+
if collector.idle_update is None:
|
|
679
|
+
def is_idle(message: JsonRpcMessage) -> bool:
|
|
680
|
+
params = message.get("params", {})
|
|
681
|
+
update_value = params.get("update")
|
|
682
|
+
if not isinstance(update_value, dict):
|
|
683
|
+
return False
|
|
684
|
+
update: JsonObject = update_value
|
|
685
|
+
return update.get("sessionUpdate") == "state_update" and update.get("state") == "idle"
|
|
686
|
+
connection.wait_for_notification(
|
|
687
|
+
cast(Callable[[JsonRpcMessage], None], collector.handle),
|
|
688
|
+
is_idle, "session/prompt")
|
|
689
|
+
update = collector.idle_update or {}
|
|
690
|
+
stop_reason = update.get("stopReason")
|
|
691
|
+
if stop_reason in {"refusal", "cancelled"}:
|
|
692
|
+
raise RuntimeError(f"ACP prompt stopped: {stop_reason}")
|
|
693
|
+
return collector.text()
|
|
694
|
+
|
|
695
|
+
def close(self) -> None:
|
|
696
|
+
with self._connection_lock:
|
|
697
|
+
if self._connection is not None:
|
|
698
|
+
self._connection.close()
|
|
699
|
+
self._connection = None
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _supports_session_load(client: object) -> bool:
|
|
703
|
+
"""_supports_session_load() 探测客户端是否支持按需加载(resume)会话。
|
|
704
|
+
|
|
705
|
+
只有具备 ``run_resumed`` 的客户端才会参与“未结束会话”的缓存与恢复。
|
|
706
|
+
"""
|
|
707
|
+
return callable(getattr(client, "run_resumed", None))
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _agent_lookup(cache_ctx: CacheContext | None, prompt: str,
|
|
711
|
+
path: str) -> tuple[str, str] | None:
|
|
712
|
+
"""_agent_lookup() 查询 Agent 缓存条目,返回 (kind, value) 或 None"""
|
|
713
|
+
if cache_ctx is None or cache_ctx.cache is None:
|
|
714
|
+
return None
|
|
715
|
+
info = cache_ctx.agent_key(prompt, path)
|
|
716
|
+
if info is None:
|
|
717
|
+
return None
|
|
718
|
+
return cache_ctx.cache.get_agent(info)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _store_agent_result(cache_ctx: CacheContext | None, prompt: str,
|
|
722
|
+
path: str, result: Any) -> None:
|
|
723
|
+
"""_store_agent_result() 缓存 Agent 正常结束的运行结果"""
|
|
724
|
+
if cache_ctx is None or cache_ctx.cache is None:
|
|
725
|
+
return
|
|
726
|
+
info = cache_ctx.agent_key(prompt, path)
|
|
727
|
+
if info is not None:
|
|
728
|
+
cache_ctx.cache.put_agent(info, "result", result)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _store_agent_session(cache_ctx: CacheContext | None, prompt: str,
|
|
732
|
+
path: str, session_id: str) -> None:
|
|
733
|
+
"""_store_agent_session() 仅记录未结束会话的 session id"""
|
|
734
|
+
if cache_ctx is None or cache_ctx.cache is None:
|
|
735
|
+
return
|
|
736
|
+
info = cache_ctx.agent_key(prompt, path)
|
|
737
|
+
if info is not None:
|
|
738
|
+
cache_ctx.cache.put_agent(info, "session", session_id)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class Agent:
|
|
742
|
+
"""通过 ACP v2 执行的子 Agent。
|
|
743
|
+
|
|
744
|
+
``path`` 在构造时会立即基于当前工作目录解析为绝对路径,
|
|
745
|
+
之后即使进程切换工作目录也不受影响。
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
def __init__(self, prompt: str, /, *,
|
|
749
|
+
model: Literal["haiku", "sonnet", "opus", ""] | str = "",
|
|
750
|
+
path: str | pathlib.Path = ".", structure_output: bool = False,
|
|
751
|
+
acp: AgentClientInterface | None = None,
|
|
752
|
+
retry: int = 3) -> None:
|
|
753
|
+
if retry < 0:
|
|
754
|
+
raise ValueError("retry must be non-negative")
|
|
755
|
+
self.prompt = prompt
|
|
756
|
+
self.model = model
|
|
757
|
+
self.path = _resolve_path(path)
|
|
758
|
+
self.structure_output = structure_output
|
|
759
|
+
self.acp = acp
|
|
760
|
+
self.retry = retry
|
|
761
|
+
self._call_info: AgentStatusSingleAgent | None = None
|
|
762
|
+
|
|
763
|
+
def run_with_status(self, call_info: AgentStatus,
|
|
764
|
+
status: AgentStatusSingleAgent, acp: AgentClientInterface | None,
|
|
765
|
+
*,
|
|
766
|
+
cache_ctx: CacheContext | None = None,
|
|
767
|
+
report: bool = False) -> Any:
|
|
768
|
+
reporter = optional_reporter(report)
|
|
769
|
+
if reporter is not None:
|
|
770
|
+
# 绑定后,任何状态赋值(含随后的 RUNNING)都会自动上报。
|
|
771
|
+
reporter.bind(status)
|
|
772
|
+
self._call_info = status
|
|
773
|
+
status.status = AgentStatusEnum.RUNNING
|
|
774
|
+
client = acp or self.acp
|
|
775
|
+
if client is None:
|
|
776
|
+
status.status = AgentStatusEnum.FAILURE
|
|
777
|
+
raise RuntimeError("No ACP client configured for Agent")
|
|
778
|
+
# === 缓存查询 ===
|
|
779
|
+
entry = _agent_lookup(cache_ctx, self.prompt, self.path)
|
|
780
|
+
if entry is not None:
|
|
781
|
+
kind, value = entry
|
|
782
|
+
if kind == "result":
|
|
783
|
+
# 正常缓存命中:直接返回结果
|
|
784
|
+
status.status = AgentStatusEnum.SUCCESS
|
|
785
|
+
return value
|
|
786
|
+
# kind == "session":按需使用 ACP load 恢复会话后继续执行;
|
|
787
|
+
# 加载失败则回退到全新会话真实执行。
|
|
788
|
+
if _supports_session_load(client):
|
|
789
|
+
try:
|
|
790
|
+
result = cast(Any, client).run_resumed(
|
|
791
|
+
self.prompt, value, model=self.model,
|
|
792
|
+
cwd=self.path, status=status)
|
|
793
|
+
except Exception:
|
|
794
|
+
pass
|
|
795
|
+
else:
|
|
796
|
+
result = self._apply_structure_output(result)
|
|
797
|
+
_store_agent_result(cache_ctx, self.prompt, self.path,
|
|
798
|
+
result)
|
|
799
|
+
status.status = AgentStatusEnum.SUCCESS
|
|
800
|
+
return result
|
|
801
|
+
try:
|
|
802
|
+
result = client.run(self.prompt, model=self.model,
|
|
803
|
+
cwd=self.path, status=status)
|
|
804
|
+
except Exception:
|
|
805
|
+
# 未正常结束:仅记录 session id 供下次 ACP load 续跑;
|
|
806
|
+
# 客户端不支持加载时不对未结束会话做任何缓存。
|
|
807
|
+
if status.session_id and _supports_session_load(client):
|
|
808
|
+
_store_agent_session(cache_ctx, self.prompt, self.path,
|
|
809
|
+
status.session_id)
|
|
810
|
+
status.status = AgentStatusEnum.FAILURE
|
|
811
|
+
raise
|
|
812
|
+
result = self._apply_structure_output(result)
|
|
813
|
+
_store_agent_result(cache_ctx, self.prompt, self.path, result)
|
|
814
|
+
status.status = AgentStatusEnum.SUCCESS
|
|
815
|
+
return result
|
|
816
|
+
|
|
817
|
+
def _apply_structure_output(self, result: Any) -> Any:
|
|
818
|
+
"""_apply_structure_output() structure_output 开启时尝试解析 JSON"""
|
|
819
|
+
if self.structure_output and isinstance(result, str):
|
|
820
|
+
try:
|
|
821
|
+
return json.loads(result)
|
|
822
|
+
except json.JSONDecodeError:
|
|
823
|
+
return result
|
|
824
|
+
return result
|
|
825
|
+
|
|
826
|
+
def run(self, call_info: AgentStatus, acp: AgentClientInterface | None = None,
|
|
827
|
+
*, cache_ctx: CacheContext | None = None,
|
|
828
|
+
report: bool = False, node_id: str = "",
|
|
829
|
+
call_index: int = 0) -> Any:
|
|
830
|
+
"""启动该 Agent,并在整个 ACP 会话期间持续更新其状态。
|
|
831
|
+
|
|
832
|
+
``node_id``/``call_index`` 标识所属节点与节点内第几次调用,
|
|
833
|
+
会随状态上报一并推送。
|
|
834
|
+
"""
|
|
835
|
+
status = AgentStatusSingleAgent(
|
|
836
|
+
prompt=self.prompt, path=str(self.path), node_id=node_id,
|
|
837
|
+
call_index=call_index, agent_index=1, agent_count=1)
|
|
838
|
+
call_info.append(status)
|
|
839
|
+
group = _AgentGroup([self], [status], acp or self.acp, cache_ctx,
|
|
840
|
+
report, 1, call_info, node_id, call_index)
|
|
841
|
+
_register_agent_group(node_id, call_index, group)
|
|
842
|
+
try:
|
|
843
|
+
return group.wait_all()[0]
|
|
844
|
+
finally:
|
|
845
|
+
_unregister_agent_group(node_id, call_index)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
class _AgentGroup:
|
|
849
|
+
"""一次 Agent/MultiAgent 调用的可控运行组。"""
|
|
850
|
+
|
|
851
|
+
def __init__(self, agents: list[Agent] | tuple[Agent, ...],
|
|
852
|
+
statuses: list[AgentStatusSingleAgent],
|
|
853
|
+
client: AgentClientInterface | None, cache_ctx: CacheContext | None,
|
|
854
|
+
report: bool, concurrency: int, call_info: AgentStatus,
|
|
855
|
+
node_id: str, call_index: int) -> None:
|
|
856
|
+
self.agents = list(agents)
|
|
857
|
+
self.retry_limits = [agent.retry for agent in self.agents]
|
|
858
|
+
self.statuses = statuses
|
|
859
|
+
self.client = client
|
|
860
|
+
self.cache_ctx = cache_ctx
|
|
861
|
+
self.report = report
|
|
862
|
+
self.call_info = call_info
|
|
863
|
+
self.node_id = node_id
|
|
864
|
+
self.call_index = call_index
|
|
865
|
+
self.results: list[Any] = [None] * len(self.agents)
|
|
866
|
+
self.errors: list[BaseException | None] = [None] * len(self.agents)
|
|
867
|
+
self.threads: list[threading.Thread | None] = [None] * len(self.agents)
|
|
868
|
+
self.tids: list[int | None] = [None] * len(self.agents)
|
|
869
|
+
self.cancelled: list[bool] = [False] * len(self.agents)
|
|
870
|
+
self.gate = threading.Semaphore(concurrency)
|
|
871
|
+
|
|
872
|
+
@property
|
|
873
|
+
def done(self) -> bool:
|
|
874
|
+
return all(t is None or not t.is_alive() for t in self.threads)
|
|
875
|
+
|
|
876
|
+
def _spawn(self, index: int) -> None:
|
|
877
|
+
def worker() -> None:
|
|
878
|
+
self.tids[index] = threading.get_ident()
|
|
879
|
+
try:
|
|
880
|
+
with self.gate:
|
|
881
|
+
result = self.agents[index].run_with_status(
|
|
882
|
+
self.call_info, self.statuses[index], self.client,
|
|
883
|
+
cache_ctx=self.cache_ctx, report=self.report)
|
|
884
|
+
self.results[index] = result
|
|
885
|
+
except BaseException as exc:
|
|
886
|
+
if self.cancelled[index]:
|
|
887
|
+
self.statuses[index].status = AgentStatusEnum.FAILURE
|
|
888
|
+
self.results[index] = None
|
|
889
|
+
else:
|
|
890
|
+
self.errors[index] = exc
|
|
891
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
892
|
+
self.threads[index] = thread
|
|
893
|
+
thread.start()
|
|
894
|
+
|
|
895
|
+
def start(self) -> None:
|
|
896
|
+
for index in range(len(self.agents)):
|
|
897
|
+
self._spawn(index)
|
|
898
|
+
|
|
899
|
+
def wait_all(self) -> list[Any]:
|
|
900
|
+
self.start()
|
|
901
|
+
# retry() 可能在等待期间替换某个 worker;持续观察当前线程槽位。
|
|
902
|
+
while True:
|
|
903
|
+
for thread in list(self.threads):
|
|
904
|
+
if thread is not None:
|
|
905
|
+
thread.join(0.1)
|
|
906
|
+
if self.done:
|
|
907
|
+
break
|
|
908
|
+
for error in self.errors:
|
|
909
|
+
if error is not None:
|
|
910
|
+
raise error
|
|
911
|
+
return list(self.results)
|
|
912
|
+
|
|
913
|
+
def terminate(self, index: int) -> bool:
|
|
914
|
+
if self.done or not 0 <= index < len(self.threads):
|
|
915
|
+
return False
|
|
916
|
+
thread = self.threads[index]
|
|
917
|
+
tid = self.tids[index]
|
|
918
|
+
if thread is None or not thread.is_alive() or tid is None:
|
|
919
|
+
return False
|
|
920
|
+
self.cancelled[index] = True
|
|
921
|
+
return raise_in_thread(tid, AgentCancelled)
|
|
922
|
+
|
|
923
|
+
def retry(self, index: int) -> bool:
|
|
924
|
+
if self.done or not 0 <= index < len(self.threads):
|
|
925
|
+
return False
|
|
926
|
+
# retry 表示额外重试次数;attempt=1 是首次执行。
|
|
927
|
+
if self.statuses[index].attempt > self.retry_limits[index]:
|
|
928
|
+
return False
|
|
929
|
+
thread = self.threads[index]
|
|
930
|
+
tid = self.tids[index]
|
|
931
|
+
if thread is not None and thread.is_alive():
|
|
932
|
+
if tid is None:
|
|
933
|
+
return False
|
|
934
|
+
self.cancelled[index] = True
|
|
935
|
+
if not raise_in_thread(tid, AgentCancelled):
|
|
936
|
+
return False
|
|
937
|
+
thread.join(5.0)
|
|
938
|
+
if thread.is_alive():
|
|
939
|
+
return False
|
|
940
|
+
self.statuses[index].reset_for_retry()
|
|
941
|
+
self.cancelled[index] = False
|
|
942
|
+
self.errors[index] = None
|
|
943
|
+
self.results[index] = None
|
|
944
|
+
self._spawn(index)
|
|
945
|
+
return True
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
_AGENT_GROUPS: dict[tuple[str, int], _AgentGroup] = {}
|
|
949
|
+
_AGENT_GROUPS_LOCK = threading.Lock()
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _register_agent_group(node_id: str, call_index: int, group: _AgentGroup) -> None:
|
|
953
|
+
with _AGENT_GROUPS_LOCK:
|
|
954
|
+
_AGENT_GROUPS[(node_id, call_index)] = group
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _unregister_agent_group(node_id: str, call_index: int) -> None:
|
|
958
|
+
with _AGENT_GROUPS_LOCK:
|
|
959
|
+
_AGENT_GROUPS.pop((node_id, call_index), None)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def find_agent_group(node_id: str, call_index: int) -> _AgentGroup | None:
|
|
963
|
+
with _AGENT_GROUPS_LOCK:
|
|
964
|
+
return _AGENT_GROUPS.get((node_id, call_index))
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class AgentCancelled(BaseException):
|
|
968
|
+
"""Agent 被 stdin 控制命令终止。"""
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def raise_in_thread(thread_id: int, exception: type[BaseException]) -> bool:
|
|
972
|
+
"""向指定 Python 线程注入异步异常。"""
|
|
973
|
+
result = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
974
|
+
ctypes.c_ulong(thread_id), ctypes.py_object(exception))
|
|
975
|
+
return int(result) == 1
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class MultiAgent:
|
|
979
|
+
"""并发运行多个子 Agent,遵循配置的并发数。"""
|
|
980
|
+
|
|
981
|
+
def __init__(self, *agents: Agent, concurrency: int = 3,
|
|
982
|
+
model: Literal["haiku", "sonnet", "opus", ""] | str = "",
|
|
983
|
+
path: str | pathlib.Path = ".", structure_output: bool = False,
|
|
984
|
+
acp: AgentClientInterface | None = None) -> None:
|
|
985
|
+
if concurrency < 1:
|
|
986
|
+
raise ValueError("concurrency must be at least 1")
|
|
987
|
+
self.agents = agents
|
|
988
|
+
self.concurrency = concurrency
|
|
989
|
+
self.acp = acp
|
|
990
|
+
resolved_path = _resolve_path(path)
|
|
991
|
+
for agent in self.agents:
|
|
992
|
+
agent.model = model
|
|
993
|
+
agent.path = resolved_path
|
|
994
|
+
agent.structure_output = structure_output
|
|
995
|
+
if acp is not None:
|
|
996
|
+
agent.acp = acp
|
|
997
|
+
|
|
998
|
+
def run(self, call_info: AgentStatus, acp: AgentClientInterface | None = None,
|
|
999
|
+
*, cache_ctx: CacheContext | None = None, report: bool = False,
|
|
1000
|
+
node_id: str = "", call_index: int = 0) -> list[Any]:
|
|
1001
|
+
"""并发运行所有子 Agent,并支持运行中终止或重试。"""
|
|
1002
|
+
client = acp or self.acp
|
|
1003
|
+
total = len(self.agents)
|
|
1004
|
+
statuses = [AgentStatusSingleAgent(
|
|
1005
|
+
prompt=agent.prompt, path=str(agent.path), node_id=node_id,
|
|
1006
|
+
call_index=call_index, agent_index=index + 1, agent_count=total)
|
|
1007
|
+
for index, agent in enumerate(self.agents)]
|
|
1008
|
+
call_info.extend(statuses)
|
|
1009
|
+
if not self.agents:
|
|
1010
|
+
return []
|
|
1011
|
+
group = _AgentGroup(self.agents, statuses, client, cache_ctx, report,
|
|
1012
|
+
self.concurrency, call_info, node_id, call_index)
|
|
1013
|
+
_register_agent_group(node_id, call_index, group)
|
|
1014
|
+
try:
|
|
1015
|
+
return group.wait_all()
|
|
1016
|
+
finally:
|
|
1017
|
+
_unregister_agent_group(node_id, call_index)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def gen_conn_info_from_env() -> AgentClient | None:
|
|
1021
|
+
value = os.environ.get("ALKAID0_WORKFLOW_CONN_INFO")
|
|
1022
|
+
return AgentClient(path=value) if value else None
|