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/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