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.
@@ -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"}}