agent-cli-lib 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.
- agent_cli_lib/__init__.py +48 -0
- agent_cli_lib/api/__init__.py +25 -0
- agent_cli_lib/api/decorators.py +37 -0
- agent_cli_lib/api/prompt.py +10 -0
- agent_cli_lib/api/runner.py +85 -0
- agent_cli_lib/api/session.py +10 -0
- agent_cli_lib/backends/__init__.py +1 -0
- agent_cli_lib/backends/base.py +84 -0
- agent_cli_lib/backends/hermes/__init__.py +5 -0
- agent_cli_lib/backends/hermes/backend.py +85 -0
- agent_cli_lib/backends/opencode/__init__.py +10 -0
- agent_cli_lib/backends/opencode/backend.py +296 -0
- agent_cli_lib/backends/opencode/specs.py +39 -0
- agent_cli_lib/backends/pi/__init__.py +5 -0
- agent_cli_lib/backends/pi/backend.py +116 -0
- agent_cli_lib/core/__init__.py +1 -0
- agent_cli_lib/core/acp/__init__.py +13 -0
- agent_cli_lib/core/acp/client.py +248 -0
- agent_cli_lib/core/acp/driver.py +344 -0
- agent_cli_lib/core/acp/wire.py +73 -0
- agent_cli_lib/core/driver_base.py +35 -0
- agent_cli_lib/core/proctree.py +40 -0
- agent_cli_lib/core/prompt.py +32 -0
- agent_cli_lib/core/runner.py +305 -0
- agent_cli_lib/core/session.py +57 -0
- agent_cli_lib/errors.py +105 -0
- agent_cli_lib/py.typed +0 -0
- agent_cli_lib-0.1.0.dist-info/METADATA +133 -0
- agent_cli_lib-0.1.0.dist-info/RECORD +31 -0
- agent_cli_lib-0.1.0.dist-info/WHEEL +4 -0
- agent_cli_lib-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""ACP stdio 连接(自管泵,v8 设计 §4.2.1 线程职责表)。
|
|
2
|
+
|
|
3
|
+
连接层定案:SDK 连接层(asyncio-only、无进程组、env 裁剪、stderr 无 drain——四项实证
|
|
4
|
+
冲突)不采用;本模块自管子进程与 stdio 泵,编解码用 SDK 的 pydantic 模型(driver 层)。
|
|
5
|
+
|
|
6
|
+
线程模型(强制规格):
|
|
7
|
+
- reader 线程(唯一 stdout 消费者):逐行读 → JSON 解析 → 按 id 分发响应 / 通知入队 /
|
|
8
|
+
server 请求入队;EOF/异常时向所有 pending future 广播失败并置 closed
|
|
9
|
+
- sender 线程(唯一 stdin 写入者):call/notify/权限应答/cancel 全走它的队列——
|
|
10
|
+
调用方线程绝不直接写 stdin(子进程卡死时写阻塞不会传染)
|
|
11
|
+
- stderr drain 线程:持续读 stderr 到缓冲(无 drain 则 agent 日志写满管道即整链挂死;
|
|
12
|
+
缓冲同时是"进程级失败"报错的 stderr 来源)
|
|
13
|
+
- 编码:binary 读写 + 手工 \\n 切帧 + utf-8 解码(对齐仓库 CLI 子进程编码惯例,
|
|
14
|
+
防 Windows locale 解码坑)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import queue
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from concurrent.futures import Future
|
|
26
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
27
|
+
|
|
28
|
+
from agent_cli_lib.core.proctree import kill_tree, terminate_tree
|
|
29
|
+
from agent_cli_lib.errors import BackendRunError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AcpJsonRpcError(Exception):
|
|
33
|
+
"""JSON-RPC 层错误(携带 code/message,由 driver 翻译为库异常家族)。"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: int, message: str) -> None:
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
super().__init__(f"code={code}: {message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AcpConnection:
|
|
42
|
+
"""一个 ACP agent 子进程连接(per-run 短生命周期,调用方负责 close)。"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, command: Sequence[str], cwd: str | None = None) -> None:
|
|
45
|
+
self._closed = threading.Event()
|
|
46
|
+
# POSIX 独立进程组:cancel/清理整树杀(树杀前提)
|
|
47
|
+
self._proc = subprocess.Popen(
|
|
48
|
+
list(command),
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
stdin=subprocess.PIPE,
|
|
51
|
+
stdout=subprocess.PIPE,
|
|
52
|
+
stderr=subprocess.PIPE,
|
|
53
|
+
start_new_session=(os.name != "nt"),
|
|
54
|
+
)
|
|
55
|
+
self._pending: dict[int, Future[dict]] = {}
|
|
56
|
+
self._pending_lock = threading.Lock()
|
|
57
|
+
self._notifications: queue.Queue[dict] = queue.Queue()
|
|
58
|
+
self._server_requests: queue.Queue[dict] = queue.Queue()
|
|
59
|
+
self._outbound: queue.Queue[str | None] = queue.Queue()
|
|
60
|
+
self._stderr_lines: list[str] = []
|
|
61
|
+
self._stderr_lock = threading.Lock()
|
|
62
|
+
self._next_id = 0
|
|
63
|
+
|
|
64
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
65
|
+
self._sender = threading.Thread(target=self._send_loop, daemon=True)
|
|
66
|
+
self._drain = threading.Thread(target=self._drain_loop, daemon=True)
|
|
67
|
+
self._reader.start()
|
|
68
|
+
self._sender.start()
|
|
69
|
+
self._drain.start()
|
|
70
|
+
|
|
71
|
+
# ── 对外 API ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def stderr_text(self) -> str:
|
|
75
|
+
"""stderr 缓冲(进程级失败报错来源)。"""
|
|
76
|
+
with self._stderr_lock:
|
|
77
|
+
return "".join(self._stderr_lines)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def closed(self) -> bool:
|
|
81
|
+
return self._closed.is_set()
|
|
82
|
+
|
|
83
|
+
def submit(self, method: str, params: dict | None = None) -> Future[dict]:
|
|
84
|
+
"""发起 JSON-RPC 请求,返回 future(driver 的泵循环边等边处理事件)。"""
|
|
85
|
+
if self._closed.is_set():
|
|
86
|
+
raise BackendRunError("acp", -1, "连接已关闭")
|
|
87
|
+
with self._pending_lock:
|
|
88
|
+
self._next_id += 1
|
|
89
|
+
rid = self._next_id
|
|
90
|
+
fut: Future[dict] = Future()
|
|
91
|
+
self._pending[rid] = fut
|
|
92
|
+
self._outbound.put(
|
|
93
|
+
json.dumps(
|
|
94
|
+
{"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}},
|
|
95
|
+
ensure_ascii=False,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return fut
|
|
99
|
+
|
|
100
|
+
def request(self, method: str, params: dict | None = None, timeout: float = 30.0) -> dict:
|
|
101
|
+
"""同步请求(initialize/new/resume 等阶段调用;超时抛内建 TimeoutError)。"""
|
|
102
|
+
fut = self.submit(method, params)
|
|
103
|
+
try:
|
|
104
|
+
return fut.result(timeout=timeout)
|
|
105
|
+
except FutureTimeoutError as e:
|
|
106
|
+
# py3.10 上 concurrent.futures.TimeoutError 与内建不同类,归一化
|
|
107
|
+
raise TimeoutError from e
|
|
108
|
+
|
|
109
|
+
def notify(self, method: str, params: dict | None = None) -> None:
|
|
110
|
+
"""发通知(session/cancel 等),不期望响应。"""
|
|
111
|
+
self._outbound.put(
|
|
112
|
+
json.dumps(
|
|
113
|
+
{"jsonrpc": "2.0", "method": method, "params": params or {}}, ensure_ascii=False
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def respond(self, request_id: int, result: dict) -> None:
|
|
118
|
+
"""应答 server → client 请求(权限应答)。"""
|
|
119
|
+
self._outbound.put(
|
|
120
|
+
json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}, ensure_ascii=False)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def pop_notification(self, timeout: float = 0.0) -> dict | None:
|
|
124
|
+
"""取一条通知 params(session/update);无则 None。"""
|
|
125
|
+
try:
|
|
126
|
+
return self._notifications.get(timeout=timeout)
|
|
127
|
+
except queue.Empty:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def pop_server_request(self, timeout: float = 0.0) -> dict | None:
|
|
131
|
+
"""取一条 server → client 请求(session/request_permission);无则 None。"""
|
|
132
|
+
try:
|
|
133
|
+
return self._server_requests.get(timeout=timeout)
|
|
134
|
+
except queue.Empty:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
138
|
+
"""清理矩阵:关 stdin → wait(5) → 超时树杀。幂等(EOF/写失败路径也要停 sender 收尸)。"""
|
|
139
|
+
first = not self._closed.is_set()
|
|
140
|
+
self._closed.set()
|
|
141
|
+
self._outbound.put(None) # 停 sender(不论谁先置 closed)
|
|
142
|
+
if first:
|
|
143
|
+
try:
|
|
144
|
+
if self._proc.stdin is not None:
|
|
145
|
+
self._proc.stdin.close()
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
if self._proc.poll() is None: # agent 仍存活:等待/树杀(写失败路径进程可能未死)
|
|
149
|
+
try:
|
|
150
|
+
self._proc.wait(timeout=timeout)
|
|
151
|
+
except subprocess.TimeoutExpired:
|
|
152
|
+
terminate_tree(self._proc)
|
|
153
|
+
try:
|
|
154
|
+
self._proc.wait(timeout=2)
|
|
155
|
+
except subprocess.TimeoutExpired:
|
|
156
|
+
kill_tree(self._proc)
|
|
157
|
+
self._proc.wait()
|
|
158
|
+
else:
|
|
159
|
+
self._proc.wait() # 已退出:回收僵尸
|
|
160
|
+
self._fail_pending("连接已关闭")
|
|
161
|
+
|
|
162
|
+
def kill(self) -> None:
|
|
163
|
+
"""兜底强杀(cancel 超时路径)。幂等。"""
|
|
164
|
+
if self._proc.poll() is not None:
|
|
165
|
+
return
|
|
166
|
+
terminate_tree(self._proc)
|
|
167
|
+
try:
|
|
168
|
+
self._proc.wait(timeout=2)
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
kill_tree(self._proc)
|
|
171
|
+
self._proc.wait()
|
|
172
|
+
self._closed.set()
|
|
173
|
+
|
|
174
|
+
# ── 内部线程 ───────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _read_loop(self) -> None:
|
|
177
|
+
assert self._proc.stdout is not None
|
|
178
|
+
try:
|
|
179
|
+
for raw in self._proc.stdout:
|
|
180
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
181
|
+
if not line:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
msg = json.loads(line)
|
|
185
|
+
except json.JSONDecodeError:
|
|
186
|
+
continue # 半行/非 JSON 容错
|
|
187
|
+
if not isinstance(msg, dict):
|
|
188
|
+
continue
|
|
189
|
+
self._dispatch(msg)
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
finally:
|
|
193
|
+
self._closed.set()
|
|
194
|
+
self._fail_pending("agent 进程退出")
|
|
195
|
+
|
|
196
|
+
def _dispatch(self, msg: dict) -> None:
|
|
197
|
+
if "method" in msg:
|
|
198
|
+
if "id" in msg: # server → client 请求(权限等)
|
|
199
|
+
self._server_requests.put(msg)
|
|
200
|
+
else: # 通知(session/update 等)
|
|
201
|
+
self._notifications.put(msg.get("params") or {})
|
|
202
|
+
return
|
|
203
|
+
rid = msg.get("id")
|
|
204
|
+
fut: Future[dict] | None = None
|
|
205
|
+
if isinstance(rid, int):
|
|
206
|
+
with self._pending_lock:
|
|
207
|
+
fut = self._pending.pop(rid, None)
|
|
208
|
+
if fut is None or fut.done():
|
|
209
|
+
return
|
|
210
|
+
if "error" in msg:
|
|
211
|
+
err = msg["error"] or {}
|
|
212
|
+
fut.set_exception(
|
|
213
|
+
AcpJsonRpcError(int(err.get("code", -32000)), str(err.get("message", "")))
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
fut.set_result(msg.get("result") or {})
|
|
217
|
+
|
|
218
|
+
def _send_loop(self) -> None:
|
|
219
|
+
assert self._proc.stdin is not None
|
|
220
|
+
while True:
|
|
221
|
+
item = self._outbound.get()
|
|
222
|
+
if item is None:
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
self._proc.stdin.write((item + "\n").encode("utf-8"))
|
|
226
|
+
self._proc.stdin.flush()
|
|
227
|
+
except (OSError, ValueError):
|
|
228
|
+
self._closed.set()
|
|
229
|
+
self._fail_pending("stdin 写入失败(agent 进程异常)")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
def _drain_loop(self) -> None:
|
|
233
|
+
assert self._proc.stderr is not None
|
|
234
|
+
try:
|
|
235
|
+
for raw in self._proc.stderr:
|
|
236
|
+
with self._stderr_lock:
|
|
237
|
+
self._stderr_lines.append(raw.decode("utf-8", errors="replace"))
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
def _fail_pending(self, reason: str) -> None:
|
|
242
|
+
with self._pending_lock:
|
|
243
|
+
futures = list(self._pending.values())
|
|
244
|
+
self._pending.clear()
|
|
245
|
+
for fut in futures:
|
|
246
|
+
if not fut.done():
|
|
247
|
+
stderr_head = self.stderr_text[:500]
|
|
248
|
+
fut.set_exception(BackendRunError("acp", -1, f"{reason};stderr: {stderr_head}"))
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""AcpDriver:ACP 传输的会话编排(v8 设计 §4.4,本设计的核心新逻辑)。
|
|
2
|
+
|
|
3
|
+
per-run 生命周期(短连接,跨进程 resume 已实测无损):
|
|
4
|
+
spawn <binary> acp → initialize → session/new | session/resume →(model 设置)
|
|
5
|
+
→ session/prompt(任务线程泵循环:事件队列消费 + 权限应答 + cancel 监视)
|
|
6
|
+
→ prompt 响应 → 清理矩阵退出 → RunOutput
|
|
7
|
+
|
|
8
|
+
on_event 交付(v8 定字):reader 线程把通知入队,**任务线程**在等待 prompt 响应的
|
|
9
|
+
循环里边等边泵——回调永远不在 reader 线程执行,慢回调拖慢的只是事件消费,
|
|
10
|
+
帧读取、权限应答、cancel 写入永不被用户回调阻塞。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
21
|
+
|
|
22
|
+
from acp import SessionNotification
|
|
23
|
+
from acp.schema import PromptResponse
|
|
24
|
+
|
|
25
|
+
from agent_cli_lib.backends.base import AcpSpec, RunOutput, RunRequest
|
|
26
|
+
from agent_cli_lib.core.acp import wire
|
|
27
|
+
from agent_cli_lib.core.acp.client import AcpConnection, AcpJsonRpcError
|
|
28
|
+
from agent_cli_lib.errors import (
|
|
29
|
+
AcpProtocolError,
|
|
30
|
+
BackendLaunchError,
|
|
31
|
+
BackendRunError,
|
|
32
|
+
RunCancelledError,
|
|
33
|
+
SessionError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# 超时矩阵(v8 设计 §4.4):initialize/new/resume/set_config 30s;清理等待在 client.close
|
|
37
|
+
_PHASE_TIMEOUT = 30.0
|
|
38
|
+
# cancel 优雅层等待 prompt 返回 stopReason:"cancelled" 的兜底上限,超时进程组强杀
|
|
39
|
+
_CANCEL_GRACE = 5.0
|
|
40
|
+
# 泵循环的轮询间隔
|
|
41
|
+
_PUMP_INTERVAL = 0.05
|
|
42
|
+
|
|
43
|
+
OnEvent = Callable[[SessionNotification], None]
|
|
44
|
+
|
|
45
|
+
# 初始化请求的固定参数(协议 v1;fs 能力按本库不提供声明)
|
|
46
|
+
_INITIALIZE_PARAMS: dict = {
|
|
47
|
+
"protocolVersion": 1,
|
|
48
|
+
"clientCapabilities": {"fs": {"readTextFile": False, "writeTextFile": False}},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AcpDriver:
|
|
53
|
+
"""一次 run/resume 的 ACP 传输执行体(runner 按路由构造,per-run 生命周期)。"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
name: str,
|
|
59
|
+
spec: AcpSpec,
|
|
60
|
+
on_event: OnEvent | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.name = name
|
|
63
|
+
self.spec = spec
|
|
64
|
+
self._on_event = on_event
|
|
65
|
+
self._conn: AcpConnection | None = None
|
|
66
|
+
self._prompt_session_id: str | None = None
|
|
67
|
+
self._cancel_sent = False
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
# ── Driver 协议(runner 侧主动干预入口) ───────────────────────────────
|
|
71
|
+
|
|
72
|
+
def cancel(self) -> None:
|
|
73
|
+
"""优雅取消:发 session/cancel(幂等,立即返回,不等待收尾)。"""
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._cancel_sent or self._conn is None or self._prompt_session_id is None:
|
|
76
|
+
return
|
|
77
|
+
self._cancel_sent = True
|
|
78
|
+
conn, sid = self._conn, self._prompt_session_id
|
|
79
|
+
# 发送失败由兜底强杀收尾(连接已死时)
|
|
80
|
+
with contextlib.suppress(Exception):
|
|
81
|
+
conn.notify("session/cancel", {"sessionId": sid})
|
|
82
|
+
|
|
83
|
+
def kill(self) -> None:
|
|
84
|
+
"""兜底强杀(进程组)。幂等。"""
|
|
85
|
+
with self._lock:
|
|
86
|
+
conn = self._conn
|
|
87
|
+
if conn is not None:
|
|
88
|
+
conn.kill()
|
|
89
|
+
|
|
90
|
+
# ── 执行 ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def run(self, req: RunRequest, cancel_event: threading.Event) -> RunOutput:
|
|
93
|
+
cwd = os.path.abspath(req.workdir or os.getcwd())
|
|
94
|
+
self._check_request(req)
|
|
95
|
+
try:
|
|
96
|
+
conn = AcpConnection(self.spec.command, cwd=cwd)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
raise BackendLaunchError(self.name, f"ACP agent 进程启动失败: {e}") from e
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._conn = conn
|
|
101
|
+
try:
|
|
102
|
+
self._initialize(conn, cancel_event)
|
|
103
|
+
self._raise_if_cancelled(cancel_event, conn)
|
|
104
|
+
session_id = self._open_session(conn, req, cwd, cancel_event)
|
|
105
|
+
self._raise_if_cancelled(cancel_event, conn)
|
|
106
|
+
if req.model:
|
|
107
|
+
self._apply_model(conn, session_id, req.model, cancel_event)
|
|
108
|
+
return self._prompt(conn, session_id, req, cancel_event)
|
|
109
|
+
finally:
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._conn = None
|
|
112
|
+
self._prompt_session_id = None
|
|
113
|
+
conn.close()
|
|
114
|
+
|
|
115
|
+
# ── 阶段实现 ───────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _check_request(self, req: RunRequest) -> None:
|
|
118
|
+
"""调用期参数校验(与 CLI 兜底同语义的前置拒绝)。"""
|
|
119
|
+
if req.session and not req.session.strip():
|
|
120
|
+
raise SessionError("session 不能为空")
|
|
121
|
+
if req.session and not _looks_like_session_id(req.session):
|
|
122
|
+
raise SessionError(
|
|
123
|
+
f"{self.name} 恢复会话仅支持 session id(文件路径恢复见二期 session import)"
|
|
124
|
+
)
|
|
125
|
+
if req.session and not req.prompt.strip():
|
|
126
|
+
raise SessionError(f"{self.name} 恢复会话必须带 prompt(ACP 的 prompt 为必填)")
|
|
127
|
+
|
|
128
|
+
def _initialize(self, conn: AcpConnection, cancel_event: threading.Event) -> None:
|
|
129
|
+
try:
|
|
130
|
+
conn.request("initialize", _INITIALIZE_PARAMS, timeout=_PHASE_TIMEOUT)
|
|
131
|
+
except AcpJsonRpcError as e:
|
|
132
|
+
raise wire.translate_error(self.name, e.code, e.message) from e
|
|
133
|
+
except FutureTimeoutError as e:
|
|
134
|
+
self._raise_if_cancelled(cancel_event, conn) # cancel 在飞优先报取消
|
|
135
|
+
raise BackendLaunchError(self.name, f"initialize 超时({_PHASE_TIMEOUT}s)") from e
|
|
136
|
+
except BackendRunError as e:
|
|
137
|
+
raise BackendLaunchError(self.name, str(e)) from e
|
|
138
|
+
|
|
139
|
+
def _open_session(
|
|
140
|
+
self, conn: AcpConnection, req: RunRequest, cwd: str, cancel_event: threading.Event
|
|
141
|
+
) -> str:
|
|
142
|
+
if req.session:
|
|
143
|
+
params = {"cwd": cwd, "mcpServers": [], "sessionId": req.session}
|
|
144
|
+
try:
|
|
145
|
+
conn.request("session/resume", params, timeout=_PHASE_TIMEOUT)
|
|
146
|
+
except AcpJsonRpcError as e:
|
|
147
|
+
raise wire.translate_error(self.name, e.code, e.message) from e
|
|
148
|
+
except FutureTimeoutError as e:
|
|
149
|
+
self._raise_if_cancelled(cancel_event, conn)
|
|
150
|
+
raise BackendRunError(
|
|
151
|
+
self.name, 0, f"session/resume 超时({_PHASE_TIMEOUT}s)"
|
|
152
|
+
) from e
|
|
153
|
+
# opencode 的 resume 响应实测带 sessionId(hermes 0.19.0 才缺);
|
|
154
|
+
# 沿用传入 id(协议语义:resume 即续用该会话)
|
|
155
|
+
return req.session
|
|
156
|
+
params = {"cwd": cwd, "mcpServers": []}
|
|
157
|
+
try:
|
|
158
|
+
result = conn.request("session/new", params, timeout=_PHASE_TIMEOUT)
|
|
159
|
+
except AcpJsonRpcError as e:
|
|
160
|
+
raise wire.translate_error(self.name, e.code, e.message) from e
|
|
161
|
+
except FutureTimeoutError as e:
|
|
162
|
+
self._raise_if_cancelled(cancel_event, conn)
|
|
163
|
+
raise BackendRunError(self.name, 0, f"session/new 超时({_PHASE_TIMEOUT}s)") from e
|
|
164
|
+
session_id = str(result.get("sessionId") or "")
|
|
165
|
+
if not session_id:
|
|
166
|
+
raise AcpProtocolError(self.name, None, "session/new 未返回 sessionId")
|
|
167
|
+
return session_id
|
|
168
|
+
|
|
169
|
+
def _apply_model(
|
|
170
|
+
self,
|
|
171
|
+
conn: AcpConnection,
|
|
172
|
+
session_id: str,
|
|
173
|
+
model: str,
|
|
174
|
+
cancel_event: threading.Event,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""model 的 ACP 映射:configOptions 找 id=="model" → set_config_option(v8 §4.4)。
|
|
177
|
+
|
|
178
|
+
无匹配 option → 显式报错,绝不静默用默认模型(错误发生在最贵的 token 上)。
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
result = conn.request(
|
|
182
|
+
"session/set_config_option",
|
|
183
|
+
{"sessionId": session_id, "configId": "model", "value": model},
|
|
184
|
+
timeout=_PHASE_TIMEOUT,
|
|
185
|
+
)
|
|
186
|
+
except AcpJsonRpcError as e:
|
|
187
|
+
raise wire.translate_error(self.name, e.code, e.message) from e
|
|
188
|
+
except FutureTimeoutError as e:
|
|
189
|
+
self._raise_if_cancelled(cancel_event, conn)
|
|
190
|
+
raise BackendRunError(self.name, 0, "session/set_config_option 超时") from e
|
|
191
|
+
# 响应回带 config_options,确认 model 生效
|
|
192
|
+
options = _extract_config_options(result)
|
|
193
|
+
current = next(
|
|
194
|
+
(str(o.get("currentValue", "")) for o in options if o.get("id") == "model"), ""
|
|
195
|
+
)
|
|
196
|
+
if not current or model not in current:
|
|
197
|
+
raise BackendRunError(
|
|
198
|
+
self.name,
|
|
199
|
+
0,
|
|
200
|
+
f"模型设置未生效:期望 {model},实际 {current or '(响应未回带 model 配置)'}",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _prompt(
|
|
204
|
+
self,
|
|
205
|
+
conn: AcpConnection,
|
|
206
|
+
session_id: str,
|
|
207
|
+
req: RunRequest,
|
|
208
|
+
cancel_event: threading.Event,
|
|
209
|
+
) -> RunOutput:
|
|
210
|
+
with self._lock:
|
|
211
|
+
self._prompt_session_id = session_id
|
|
212
|
+
params = {"sessionId": session_id, "prompt": [{"type": "text", "text": req.prompt}]}
|
|
213
|
+
fut = conn.submit("session/prompt", params)
|
|
214
|
+
|
|
215
|
+
text_parts: list[str] = []
|
|
216
|
+
saw_message = False
|
|
217
|
+
grace_deadline: float | None = None
|
|
218
|
+
pump_failure: BaseException | None = None
|
|
219
|
+
cancelled = False
|
|
220
|
+
|
|
221
|
+
while True:
|
|
222
|
+
if pump_failure is not None:
|
|
223
|
+
break
|
|
224
|
+
if cancel_event.is_set():
|
|
225
|
+
# 幂等:外部 cancel() 已发送过通知时为 no-op;兜底计时不论谁先触发都武装
|
|
226
|
+
# (否则 agent 无视 session/cancel 时 run 会永久挂起)
|
|
227
|
+
self.cancel()
|
|
228
|
+
if grace_deadline is None:
|
|
229
|
+
grace_deadline = time.monotonic() + _CANCEL_GRACE
|
|
230
|
+
if grace_deadline is not None and time.monotonic() > grace_deadline:
|
|
231
|
+
conn.kill()
|
|
232
|
+
cancelled = True
|
|
233
|
+
break
|
|
234
|
+
# 泵通知(顺序保持;回调异常 → 任务失败上抛)
|
|
235
|
+
while (ev := conn.pop_notification()) is not None and pump_failure is None:
|
|
236
|
+
notification = _safe_parse(ev)
|
|
237
|
+
if notification is None:
|
|
238
|
+
continue # 未知变体/载荷不合法:跳过(宽容线格式演进),空响应守卫兜底
|
|
239
|
+
if self._on_event is not None:
|
|
240
|
+
try:
|
|
241
|
+
self._on_event(notification)
|
|
242
|
+
except Exception as e: # 回调异常视同任务失败(不静默吞错)
|
|
243
|
+
pump_failure = e
|
|
244
|
+
break
|
|
245
|
+
if wire.is_agent_message(notification):
|
|
246
|
+
saw_message = True
|
|
247
|
+
text_parts.append(wire.message_text(notification))
|
|
248
|
+
if pump_failure is not None:
|
|
249
|
+
break
|
|
250
|
+
# 泵 server 请求(权限默认应答 allow_once)
|
|
251
|
+
while (srv := conn.pop_server_request()) is not None:
|
|
252
|
+
self._answer_permission(conn, srv)
|
|
253
|
+
if fut.done():
|
|
254
|
+
break
|
|
255
|
+
if conn.closed:
|
|
256
|
+
# reader 已把 EOF 广播给 pending future,break 后从 fut 拿异常
|
|
257
|
+
break
|
|
258
|
+
time.sleep(_PUMP_INTERVAL)
|
|
259
|
+
|
|
260
|
+
if pump_failure is not None:
|
|
261
|
+
raise pump_failure
|
|
262
|
+
if cancelled:
|
|
263
|
+
raise RunCancelledError(self.name)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
result = fut.result(timeout=_PHASE_TIMEOUT)
|
|
267
|
+
except AcpJsonRpcError as e:
|
|
268
|
+
raise wire.translate_error(self.name, e.code, e.message) from e
|
|
269
|
+
except FutureTimeoutError as e:
|
|
270
|
+
raise BackendRunError(self.name, 0, "session/prompt 等待超时") from e
|
|
271
|
+
|
|
272
|
+
# 迟到事件 flush:reader 可能在响应行之后还有已入队的通知(含正文尾块),排空再返回
|
|
273
|
+
while (ev := conn.pop_notification(timeout=0.5)) is not None:
|
|
274
|
+
notification = _safe_parse(ev)
|
|
275
|
+
if notification is None:
|
|
276
|
+
continue
|
|
277
|
+
if self._on_event is not None:
|
|
278
|
+
self._on_event(notification) # 回调异常在此阶段同样视同任务失败上抛
|
|
279
|
+
if wire.is_agent_message(notification):
|
|
280
|
+
saw_message = True
|
|
281
|
+
text_parts.append(wire.message_text(notification))
|
|
282
|
+
|
|
283
|
+
# Fix 5: 响应校验失败按协议错误翻译(不裸抛 pydantic ValidationError)
|
|
284
|
+
try:
|
|
285
|
+
response = PromptResponse.model_validate(result)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
raise AcpProtocolError(self.name, None, f"session/prompt 响应不合法: {e}") from e
|
|
288
|
+
return self._finalize_response(response, text_parts, saw_message, session_id)
|
|
289
|
+
|
|
290
|
+
def _finalize_response(
|
|
291
|
+
self,
|
|
292
|
+
response: PromptResponse,
|
|
293
|
+
text_parts: list[str],
|
|
294
|
+
saw_message: bool,
|
|
295
|
+
session_id: str,
|
|
296
|
+
) -> RunOutput:
|
|
297
|
+
stop = response.stop_reason
|
|
298
|
+
if stop == "cancelled":
|
|
299
|
+
raise RunCancelledError(self.name)
|
|
300
|
+
if stop == "refusal":
|
|
301
|
+
# hermes 实证的空成功轮:无文本无报错退出正常,不得静默当正常结果
|
|
302
|
+
raise AcpProtocolError(self.name, None, "agent 拒绝执行(stopReason=refusal)")
|
|
303
|
+
if stop == "end_turn" and not saw_message:
|
|
304
|
+
# 空响应守卫:provider 故障/配额可能不发 error 事件而以空 end_turn 收场
|
|
305
|
+
raise AcpProtocolError(self.name, None, "空响应(end_turn 且无 agent_message_chunk)")
|
|
306
|
+
return RunOutput(text="".join(text_parts), session_id=session_id)
|
|
307
|
+
|
|
308
|
+
def _answer_permission(self, conn: AcpConnection, srv: dict) -> None:
|
|
309
|
+
"""权限默认应答:allow_once(deny/policy 由 agent 侧执行,客户端无需实现)。"""
|
|
310
|
+
if srv.get("method") != "session/request_permission":
|
|
311
|
+
return # 未知 server 请求忽略(协议演进宽容),不阻塞
|
|
312
|
+
params = srv.get("params") or {}
|
|
313
|
+
options = params.get("options") or []
|
|
314
|
+
result = wire.permission_response(options)
|
|
315
|
+
rid = srv.get("id")
|
|
316
|
+
if isinstance(rid, int):
|
|
317
|
+
conn.respond(rid, result)
|
|
318
|
+
|
|
319
|
+
def _raise_if_cancelled(self, cancel_event: threading.Event, conn: AcpConnection) -> None:
|
|
320
|
+
"""initialize/new/resume 阶段的 cancel 到达:直接走兜底强杀(无 prompt 可等)。"""
|
|
321
|
+
if cancel_event.is_set():
|
|
322
|
+
conn.kill()
|
|
323
|
+
raise RunCancelledError(self.name)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _looks_like_session_id(s: str) -> bool:
|
|
327
|
+
"""session id / 文件路径粗判(镜像 core/session.py 启发式,避免 backends→core 反向依赖)。"""
|
|
328
|
+
return not (s.endswith((".json", ".jsonl", ".md", ".html")) or "/" in s or "\\" in s)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _safe_parse(params: dict) -> SessionNotification | None:
|
|
332
|
+
"""解析通知;线格式漂移(未知变体)返回 None 跳过,不失败整个 run。"""
|
|
333
|
+
try:
|
|
334
|
+
return SessionNotification.model_validate(params)
|
|
335
|
+
except Exception:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _extract_config_options(result: dict) -> list:
|
|
340
|
+
"""从响应里取 configOptions(兼容 dict/模型两种形态)。"""
|
|
341
|
+
options = result.get("configOptions")
|
|
342
|
+
if isinstance(options, list):
|
|
343
|
+
return [o if isinstance(o, dict) else o.model_dump(by_alias=True) for o in options]
|
|
344
|
+
return []
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""ACP 线格式薄适配(SDK 类型之上的提取与翻译,v8 设计 §4.2)。
|
|
2
|
+
|
|
3
|
+
变体判别由 SDK pydantic discriminator(session_update 字段)自动完成,本模块只做:
|
|
4
|
+
- SessionNotification 的解析(wire → SDK 类型)
|
|
5
|
+
- 文本块提取(AgentMessageChunk 的拼接即最终文本)
|
|
6
|
+
- JSON-RPC error → 异常翻译(SessionError / AcpProtocolError)
|
|
7
|
+
- 权限应答 payload 构造
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from acp import SessionNotification
|
|
13
|
+
from acp.schema import (
|
|
14
|
+
AgentMessageChunk,
|
|
15
|
+
AgentThoughtChunk,
|
|
16
|
+
TextContentBlock,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from agent_cli_lib.errors import AcpProtocolError, SessionError
|
|
20
|
+
|
|
21
|
+
# JSON-RPC error code(协议级)
|
|
22
|
+
_INVALID_PARAMS = -32602
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_notification(params: dict) -> SessionNotification:
|
|
26
|
+
"""把 session/update 通知的 params 解析为官方 SDK 类型(校验失败按协议错误处理)。"""
|
|
27
|
+
try:
|
|
28
|
+
return SessionNotification.model_validate(params)
|
|
29
|
+
except Exception as e: # pydantic ValidationError:线格式漂移按协议错误上报
|
|
30
|
+
raise AcpProtocolError("acp", None, f"session/update 载荷不合法: {e}") from e
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def message_text(notification: SessionNotification) -> str:
|
|
34
|
+
"""提取文本块的文本(thought 与 message 的 content 同型,由 is_agent_message 区分正文)。"""
|
|
35
|
+
update = notification.update
|
|
36
|
+
if isinstance(update, AgentMessageChunk | AgentThoughtChunk):
|
|
37
|
+
content = update.content
|
|
38
|
+
if isinstance(content, TextContentBlock):
|
|
39
|
+
return content.text
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_agent_message(notification: SessionNotification) -> bool:
|
|
44
|
+
return isinstance(notification.update, AgentMessageChunk)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def translate_error(backend: str, code: int | None, message: str) -> Exception:
|
|
48
|
+
"""JSON-RPC error → 异常(v8 设计 §4.4 错误翻译)。
|
|
49
|
+
|
|
50
|
+
- session not found → SessionError(-32602 且消息明示)
|
|
51
|
+
- does not belong to cwd → 可读 cwd 错误(含 transport="cli" 逃生提示)
|
|
52
|
+
- 其余 → AcpProtocolError
|
|
53
|
+
"""
|
|
54
|
+
msg = str(message)
|
|
55
|
+
if "session not found" in msg.lower():
|
|
56
|
+
return SessionError(msg)
|
|
57
|
+
if code == _INVALID_PARAMS and "does not belong to cwd" in msg.lower():
|
|
58
|
+
return SessionError(
|
|
59
|
+
f"{msg}(opencode v2 的 ACP resume 绑定会话原目录;"
|
|
60
|
+
f'如需跨目录恢复可显式 transport="cli",CLI 的 -s 为全局查找)'
|
|
61
|
+
)
|
|
62
|
+
return AcpProtocolError(backend, code, msg)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def permission_response(options: list) -> dict:
|
|
66
|
+
"""构造默认应答:挑 allow_once 选中;无匹配选项则拒绝(不猜测)。
|
|
67
|
+
|
|
68
|
+
options 为线上的原始 dict 列表(optionId/kind),避免在 client 层二次建模。
|
|
69
|
+
"""
|
|
70
|
+
for opt in options or []:
|
|
71
|
+
if isinstance(opt, dict) and opt.get("kind") == "allow_once":
|
|
72
|
+
return {"outcome": {"outcome": "selected", "optionId": opt.get("optionId", "")}}
|
|
73
|
+
return {"outcome": {"outcome": "rejected"}}
|