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,35 @@
1
+ """Driver 协议:传输驱动(ACP / CLI)的公共接口。
2
+
3
+ runner 按路由选择 Driver 并把可取消执行交给它;协议保持极简——
4
+ run 是唯一必须成员,cancel/kill 是 runner 主动干预的入口。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Protocol
11
+
12
+ from agent_cli_lib.backends.base import RunOutput, RunRequest
13
+
14
+
15
+ class Driver(Protocol):
16
+ """一次可取消执行的传输驱动。"""
17
+
18
+ def run(self, req: RunRequest, cancel_event: threading.Event) -> RunOutput:
19
+ """执行一次 run/resume 请求(阻塞至完成或被取消)。
20
+
21
+ - 取消经 cancel_event 传入(与 CLI 执行体同机制);
22
+ - 取消完成时抛 RunCancelledError;失败抛 AgentCliError 家族异常。
23
+ """
24
+ ...
25
+
26
+ def cancel(self) -> None:
27
+ """优雅取消(ACP:发 session/cancel;CLI:无优雅层直接树杀)。
28
+
29
+ 必须立即返回(不等待收尾);等待在驱动后台完成。
30
+ """
31
+ ...
32
+
33
+ def kill(self) -> None:
34
+ """兜底强杀:进程组 SIGTERM→SIGKILL。幂等,已退出时静默。"""
35
+ ...
@@ -0,0 +1,40 @@
1
+ """进程树终止工具(自 runner.py 迁入,CLI 执行体与 ACP 驱动共用)。
2
+
3
+ agent CLI 会派生子进程(opencode acp 自带私有 server),只杀父进程会留孤儿握住管道,
4
+ 取消/清理必须整组杀。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+
14
+
15
+ def terminate_tree(proc: subprocess.Popen[bytes] | subprocess.Popen[str]) -> None:
16
+ """SIGTERM 进程及其整棵子进程树。
17
+
18
+ sys.platform 条件用于类型检查器平台收窄(ty 同样支持),与 os.name 在运行时等价。
19
+ """
20
+ if os.name == "nt" or sys.platform == "win32":
21
+ subprocess.run(
22
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
23
+ capture_output=True,
24
+ check=False,
25
+ )
26
+ return
27
+ try:
28
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
29
+ except (ProcessLookupError, PermissionError):
30
+ proc.terminate()
31
+
32
+
33
+ def kill_tree(proc: subprocess.Popen[bytes] | subprocess.Popen[str]) -> None:
34
+ """SIGTERM 无效时的兜底:SIGKILL 整组(Windows 上 taskkill /F 已是强杀)。"""
35
+ if os.name == "nt" or sys.platform == "win32":
36
+ return
37
+ try:
38
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
39
+ except (ProcessLookupError, PermissionError):
40
+ proc.kill()
@@ -0,0 +1,32 @@
1
+ """prompt 处理:字符串 / 文件路径统一解析。
2
+
3
+ resolve_prompt 接受任意字符串:
4
+ - 若是已存在的文件路径 -> 读取文件内容返回
5
+ - 否则 -> 原样作为 prompt 字符串返回
6
+
7
+ 判空、路径合法性等细节收纳在此,调用方只需传字符串。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from agent_cli_lib.errors import PromptError
15
+
16
+ _MAX_PROMPT_BYTES = 10 * 1024 * 1024
17
+
18
+
19
+ def resolve_prompt(prompt: str) -> str:
20
+ """若 prompt 是存在的文件路径则读取内容,否则按字符串原样返回。"""
21
+ if not prompt or not prompt.strip():
22
+ raise PromptError("prompt 不能为空")
23
+
24
+ candidate = Path(prompt)
25
+ if candidate.is_file():
26
+ if candidate.stat().st_size > _MAX_PROMPT_BYTES:
27
+ raise PromptError(f"prompt 文件过大: {candidate} (>10MB)")
28
+ try:
29
+ return candidate.read_text(encoding="utf-8")
30
+ except OSError as e:
31
+ raise PromptError(f"读取 prompt 文件失败: {candidate}: {e}") from e
32
+ return prompt
@@ -0,0 +1,305 @@
1
+ """统一运行入口(聚合层)。
2
+
3
+ 职责(只做聚合,不碰 backend 细节):
4
+ 1. 选 backend(参数切换 opencode/opencode2/pi/hermes)与传输(transport=auto/acp/cli)
5
+ 2. 解析 prompt(字符串或文件路径)-> 委托 core.prompt
6
+ 3. 构造 RunRequest -> ACP 路径委托 core.acp.AcpDriver / CLI 路径委托 backend.build_argv
7
+ 4. 起进程、可取消 -> ACP:driver 双层 cancel;CLI:Popen + Event(run 与 resume 共用)
8
+ 5. 收集输出 -> ACP:driver 聚合事件;CLI:委托 backend.parse_output
9
+ 6. session 导出/恢复 -> 委托 core.session + backend.export_session_file(export 恒走 CLI 子进程)
10
+
11
+ 错误处理委托 errors 模块(ProcessResult.raise_for_status / which_or_raise)。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import threading
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import IO
24
+
25
+ from acp import SessionNotification
26
+
27
+ from agent_cli_lib.backends.base import AcpSpec, Backend, RunOutput, RunRequest
28
+ from agent_cli_lib.backends.hermes.backend import HermesBackend
29
+ from agent_cli_lib.backends.opencode.backend import Opencode2Backend, OpencodeBackend
30
+ from agent_cli_lib.backends.pi.backend import PiBackend
31
+ from agent_cli_lib.core.acp.driver import AcpDriver
32
+ from agent_cli_lib.core.proctree import kill_tree, terminate_tree
33
+ from agent_cli_lib.core.prompt import resolve_prompt
34
+ from agent_cli_lib.core.session import SessionInfo, export_session, load_session
35
+ from agent_cli_lib.errors import (
36
+ BackendLaunchError,
37
+ BackendNotFoundError,
38
+ ProcessResult,
39
+ RunCancelledError,
40
+ SessionError,
41
+ which_or_raise,
42
+ )
43
+
44
+ _BACKENDS: dict[str, Callable[[], Backend]] = {
45
+ "opencode": lambda: OpencodeBackend(),
46
+ "opencode2": lambda: Opencode2Backend(),
47
+ "pi": lambda: PiBackend(),
48
+ "hermes": lambda: HermesBackend(),
49
+ }
50
+
51
+ # 传输取值
52
+ _TRANSPORTS = ("auto", "acp", "cli")
53
+
54
+
55
+ def get_backend(name: str) -> Backend:
56
+ """按名称获取 backend 实例。"""
57
+ factory = _BACKENDS.get(name)
58
+ if factory is None:
59
+ raise BackendNotFoundError(name)
60
+ return factory()
61
+
62
+
63
+ @dataclass
64
+ class AgentResult:
65
+ """一次 agent 运行的结果。"""
66
+
67
+ session_id: str = ""
68
+ output: str = ""
69
+ # export_to 落盘结果:成功为绝对路径/None;失败时 export_error 给出原因(run 主结果不受影响)
70
+ export_path: str | None = None
71
+ export_error: str | None = None
72
+
73
+
74
+ class AgentRunner:
75
+ """统一封装入口。所有后端通过该类以一致的方式调用。run 与 resume 均可被 cancel() 中断。"""
76
+
77
+ def __init__(
78
+ self,
79
+ backend: str = "opencode",
80
+ workdir: str | None = None,
81
+ transport: str = "auto",
82
+ ) -> None:
83
+ if transport not in _TRANSPORTS:
84
+ raise ValueError(f"transport 取值须为 {'/'.join(_TRANSPORTS)},得到 {transport!r}")
85
+ self._backend = get_backend(backend)
86
+ self._workdir = workdir
87
+ self._transport = transport
88
+ self._lock = threading.Lock()
89
+ self._active_cancel: threading.Event | None = None
90
+ self._active_driver: AcpDriver | None = None
91
+
92
+ @property
93
+ def backend(self) -> str:
94
+ return self._backend.name
95
+
96
+ @property
97
+ def workdir(self) -> str | None:
98
+ return self._workdir
99
+
100
+ @property
101
+ def transport(self) -> str:
102
+ return self._transport
103
+
104
+ def run(
105
+ self,
106
+ prompt: str,
107
+ *,
108
+ model: str | None = None,
109
+ export_to: str | None = None,
110
+ on_event: Callable[[SessionNotification], None] | None = None,
111
+ ) -> AgentResult:
112
+ """执行一次任务;prompt 可为字符串或文件路径。可被 cancel() 中断。
113
+
114
+ - export_to:给出时,任务完成后把会话导出文件落到该路径(相对路径按调用方进程
115
+ cwd 解析,父目录自动创建)。导出失败不抛异常——对话已真实发生、结果不可重建,
116
+ 降级为 AgentResult.export_error;成功时 export_path 为落盘绝对路径。
117
+ """
118
+ resolved = resolve_prompt(prompt)
119
+ req = RunRequest(prompt=resolved, workdir=self._workdir, model=model)
120
+ out = self._execute(req, on_event=on_event)
121
+ result = AgentResult(session_id=out.session_id, output=out.text)
122
+ if export_to is not None:
123
+ result.export_path, result.export_error = self._export_safely(out.session_id, export_to)
124
+ return result
125
+
126
+ def resume(
127
+ self,
128
+ session: str,
129
+ *,
130
+ model: str | None = None,
131
+ prompt: str = "",
132
+ on_event: Callable[[SessionNotification], None] | None = None,
133
+ ) -> AgentResult:
134
+ """用 session id 或 session 文件路径恢复会话。可被 cancel() 中断。
135
+
136
+ - session id -> 原样传给 CLI 的恢复 flag(opencode -s / pi --session / hermes --resume)
137
+ - 文件路径 -> 原样传给支持路径的 CLI(pi 原生支持;opencode/hermes 需传 id)
138
+ - prompt 可选,作为恢复后的续写输入
139
+ """
140
+ info: SessionInfo = load_session(session)
141
+ session_value = info.session_id or info.file_path
142
+ resolved_prompt = resolve_prompt(prompt) if prompt.strip() else ""
143
+ req = RunRequest(
144
+ prompt=resolved_prompt, workdir=self._workdir, model=model, session=session_value
145
+ )
146
+ # CLI 兜底路径的前置探测(ACP 路径协议错误自带语义,不需要);
147
+ # 多数 backend 恒 True(CLI 自己报错),仅 opencode v2 兜底真实探测
148
+ if (
149
+ self._select_transport(on_event) == "cli"
150
+ and info.session_id
151
+ and not self._backend.session_exists(info.session_id)
152
+ ):
153
+ raise SessionError(f"会话不存在: {info.session_id}")
154
+ out = self._execute(req, on_event=on_event)
155
+ return AgentResult(session_id=out.session_id, output=out.text)
156
+
157
+ def cancel(self) -> None:
158
+ """终止当前正在运行的任务(并发安全:只作用于在飞的那一次)。
159
+
160
+ ACP 路径:先走协议级优雅取消(session/cancel),驱动内 5s 兜底强杀;
161
+ CLI 路径:直接进程组树杀(与既往行为一致)。
162
+ """
163
+ with self._lock:
164
+ event = self._active_cancel
165
+ driver = self._active_driver
166
+ if event is not None:
167
+ event.set()
168
+ if driver is not None:
169
+ driver.cancel()
170
+
171
+ def export_session(self, session_id: str, target: str) -> SessionInfo:
172
+ """调用 backend 的原生导出命令,把会话数据落到 target 文件(失败抛异常)。"""
173
+ info = export_session(session_id, target)
174
+ self._backend.export_session_file(session_id, self._resolve_export_target(target))
175
+ return info
176
+
177
+ def _resolve_export_target(self, target: str) -> str:
178
+ """导出目标统一处理:resolve 成绝对路径(按调用方进程 cwd)+ 建父目录。"""
179
+ path = Path(target).resolve()
180
+ try:
181
+ path.parent.mkdir(parents=True, exist_ok=True)
182
+ except OSError as e:
183
+ raise SessionError(f"创建导出目录失败: {path.parent}: {e}") from e
184
+ return str(path)
185
+
186
+ def _export_safely(self, session_id: str, target: str) -> tuple[str | None, str | None]:
187
+ """run(export_to=...) 的导出段:失败不抛,返回 (export_path, export_error)。
188
+
189
+ 对话已真实发生(output/session_id 不可重建),导出作为次目标失败时信息
190
+ 无损地留在 export_error,由调用方决策。契约是导出段的**任何**失败都不得
191
+ 毁掉 run 结果,故不逐异常枚举(backend 内部如 Path.home() 等也可能抛
192
+ 非 AgentCliError/OSError 的异常)。
193
+ """
194
+ try:
195
+ if not session_id:
196
+ raise SessionError("未获取到 session_id,无法导出")
197
+ path = self._resolve_export_target(target)
198
+ self._backend.export_session_file(session_id, path)
199
+ return path, None
200
+ except Exception as e:
201
+ return None, f"{type(e).__name__}: {e}"
202
+
203
+ def _execute(
204
+ self, req: RunRequest, *, on_event: Callable[[SessionNotification], None] | None = None
205
+ ) -> RunOutput:
206
+ """run/resume 共用的可取消执行路径:按传输路由到 AcpDriver 或 CLI 子进程。
207
+
208
+ 每次执行用独立的取消事件并登记为 active,cancel() 只影响当前在飞的一次。
209
+ """
210
+ cancel_event = threading.Event()
211
+ driver: AcpDriver | None = None
212
+ if self._select_transport(on_event) == "acp":
213
+ spec = self._require_acp_spec()
214
+ driver = AcpDriver(name=self._backend.name, spec=spec, on_event=on_event)
215
+ with self._lock:
216
+ self._active_cancel = cancel_event
217
+ self._active_driver = driver
218
+ try:
219
+ if driver is not None:
220
+ return driver.run(req, cancel_event)
221
+ return self._execute_inner(req, cancel_event)
222
+ finally:
223
+ with self._lock:
224
+ self._active_cancel = None
225
+ self._active_driver = None
226
+
227
+ def _select_transport(self, on_event: Callable[[SessionNotification], None] | None) -> str:
228
+ """传输路由(v8 设计 §3.2):auto 按 backend 的 ACP 声明;显式覆盖 + 防呆校验。"""
229
+ if self._transport == "cli":
230
+ if on_event is not None:
231
+ raise ValueError("on_event 需要 ACP 传输(当前 transport='cli' 不支持)")
232
+ return "cli"
233
+ spec = self._backend.acp
234
+ if self._transport == "acp":
235
+ if spec is None:
236
+ raise ValueError(
237
+ f"backend '{self._backend.name}' 无 ACP 能力,transport='acp' 不可用"
238
+ )
239
+ return "acp"
240
+ if on_event is not None and spec is None:
241
+ # auto 下无 ACP 声明的 backend 传 on_event:显式报错,不静默无事件
242
+ raise ValueError(f"backend '{self._backend.name}' 无 ACP 能力,on_event 需要 ACP 传输")
243
+ return "acp" if spec is not None else "cli"
244
+
245
+ def _require_acp_spec(self) -> AcpSpec:
246
+ spec = self._backend.acp
247
+ if spec is None: # 路由已保证非 None,防御性兜底
248
+ raise BackendLaunchError(self._backend.name, "ACP 能力声明缺失")
249
+ return spec
250
+
251
+ def _execute_inner(self, req: RunRequest, cancel_event: threading.Event) -> RunOutput:
252
+ binary = which_or_raise(self._backend.binary, self._backend.name)
253
+ argv = [binary, *self._backend.build_argv(req)]
254
+
255
+ # POSIX 下让子进程独立成组,取消时才能整组杀掉(Windows 上 False 是默认行为)
256
+ proc = subprocess.Popen(
257
+ argv,
258
+ cwd=self._workdir,
259
+ stdout=subprocess.PIPE,
260
+ stderr=subprocess.PIPE,
261
+ text=True,
262
+ encoding="utf-8",
263
+ errors="replace",
264
+ start_new_session=(os.name != "nt"),
265
+ )
266
+
267
+ stdout_buf: list[str] = []
268
+ stderr_buf: list[str] = []
269
+
270
+ def _drain(stream: IO[str], buf: list[str]) -> None:
271
+ try:
272
+ for chunk in stream:
273
+ if cancel_event.is_set():
274
+ break
275
+ buf.append(chunk)
276
+ finally:
277
+ stream.close()
278
+
279
+ t_out = threading.Thread(target=_drain, args=(proc.stdout, stdout_buf), daemon=True)
280
+ t_err = threading.Thread(target=_drain, args=(proc.stderr, stderr_buf), daemon=True)
281
+ t_out.start()
282
+ t_err.start()
283
+
284
+ cancelled = False
285
+ while proc.poll() is None:
286
+ if cancel_event.is_set():
287
+ cancelled = True
288
+ terminate_tree(proc)
289
+ try:
290
+ proc.wait(timeout=5)
291
+ except subprocess.TimeoutExpired:
292
+ kill_tree(proc)
293
+ proc.wait()
294
+ break
295
+ time.sleep(0.2)
296
+
297
+ t_out.join(timeout=5)
298
+ t_err.join(timeout=5)
299
+ stdout = "".join(stdout_buf)
300
+ stderr = "".join(stderr_buf)
301
+
302
+ if cancelled:
303
+ raise RunCancelledError(self._backend.name)
304
+ ProcessResult(self._backend.name, proc.returncode, stdout, stderr).raise_for_status()
305
+ return self._backend.parse_output(stdout, stderr)
@@ -0,0 +1,57 @@
1
+ """session 管理:统一抽象 session id 与 session 文件路径。
2
+
3
+ 三 CLI 的 session 机制差异较大(opencode/hermes 有 export 子命令,pi 的 session 即 jsonl 文件),
4
+ 这里统一为 SessionInfo(session_id, file_path):
5
+ - run 之后从 RunOutput.session_id 拿到 id
6
+ - export_session 把会话数据落到指定文件
7
+ - load_session 接受 id 或文件路径,返回 SessionInfo 供 runner 恢复
8
+
9
+ backend 各自的导出/解析细节收纳在 backend 内,本模块只做路径判别与编排。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from agent_cli_lib.errors import SessionError
18
+
19
+
20
+ @dataclass
21
+ class SessionInfo:
22
+ """会话元信息:id 与可选的导出文件路径。"""
23
+
24
+ session_id: str = ""
25
+ file_path: str = ""
26
+
27
+
28
+ def looks_like_session_id(s: str) -> bool:
29
+ """粗判字符串是 session id 还是文件路径。"""
30
+ return not (s.endswith((".json", ".jsonl", ".md", ".html")) or "/" in s or "\\" in s)
31
+
32
+
33
+ def load_session(session: str) -> SessionInfo:
34
+ """按 session id 或 session 文件路径加载会话。
35
+
36
+ - 文件路径 -> 校验存在性,返回 file_path
37
+ - 否则当作 session id
38
+ """
39
+ if not session or not session.strip():
40
+ raise SessionError("session 不能为空")
41
+
42
+ if looks_like_session_id(session):
43
+ return SessionInfo(session_id=session.strip())
44
+
45
+ p = Path(session)
46
+ if not p.is_file():
47
+ raise SessionError(f"session 文件不存在: {session}")
48
+ return SessionInfo(file_path=str(p.resolve()))
49
+
50
+
51
+ def export_session(session_id: str, target: str) -> SessionInfo:
52
+ """导出会话到文件(target 路径)。具体落盘动作由 runner 调 backend 完成。"""
53
+ if not session_id:
54
+ raise SessionError("导出需要有效的 session_id")
55
+ if not target:
56
+ raise SessionError("导出目标路径不能为空")
57
+ return SessionInfo(session_id=session_id, file_path=str(Path(target).resolve()))
@@ -0,0 +1,105 @@
1
+ """统一异常体系 + 进程错误自处理。
2
+
3
+ 所有 agent-cli-lib 抛出的异常都继承 AgentCliError。
4
+ 进程级错误(非零退出、文件未找到、被取消)由 ProcessResult.raise_for_status 统一翻译,
5
+ 避免调用方到处写 if rc != 0。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from dataclasses import dataclass
12
+
13
+
14
+ class AgentCliError(Exception):
15
+ """所有 agent-cli-lib 异常的基类."""
16
+
17
+
18
+ class BackendNotFoundError(AgentCliError):
19
+ """指定的 agent CLI 基座不存在或未安装."""
20
+
21
+ def __init__(self, backend: str) -> None:
22
+ self.backend = backend
23
+ super().__init__(f"backend '{backend}' 未安装或不可用")
24
+
25
+
26
+ class BackendLaunchError(AgentCliError):
27
+ """启动 agent 进程失败(二进制缺失、权限等)."""
28
+
29
+ def __init__(self, backend: str, reason: str) -> None:
30
+ self.backend = backend
31
+ self.reason = reason
32
+ super().__init__(f"启动 {backend} 失败: {reason}")
33
+
34
+
35
+ class BackendRunError(AgentCliError):
36
+ """agent 进程以非零状态退出."""
37
+
38
+ def __init__(self, backend: str, returncode: int, stderr: str) -> None:
39
+ self.backend = backend
40
+ self.returncode = returncode
41
+ self.stderr = stderr
42
+ super().__init__(f"{backend} 退出码 {returncode}: {stderr.strip()[:500] or '(无 stderr)'}")
43
+
44
+
45
+ class PromptError(AgentCliError):
46
+ """prompt 字符串或 prompt 文件不合法."""
47
+
48
+
49
+ class SessionError(AgentCliError):
50
+ """session 导出 / 恢复失败."""
51
+
52
+
53
+ class RunCancelledError(AgentCliError):
54
+ """运行被主动终止(并发场景)."""
55
+
56
+ def __init__(self, backend: str) -> None:
57
+ self.backend = backend
58
+ super().__init__(f"{backend} 运行已被取消")
59
+
60
+
61
+ class AcpProtocolError(AgentCliError):
62
+ """ACP 传输的协议级失败(JSON-RPC error / refusal / 空响应)。
63
+
64
+ 与 BackendRunError(进程退出语义)区分:协议错误没有进程退出码与 stderr 载荷,
65
+ 硬塞进 returncode 会产出"退出码 -32602"的误导消息。
66
+ """
67
+
68
+ def __init__(self, backend: str, code: int | None, message: str) -> None:
69
+ self.backend = backend
70
+ self.code = code
71
+ self.message = message
72
+ prefix = f"ACP 协议错误 code={code}" if code is not None else "ACP 协议错误"
73
+ super().__init__(f"{backend} {prefix}: {message}")
74
+
75
+
76
+ class RemovedAPIError(AgentCliError):
77
+ """调用了已被移除的 API(由 api 层 removed 装饰器抛出)."""
78
+
79
+ def __init__(self, func: str, hint: str) -> None:
80
+ self.func = func
81
+ self.hint = hint
82
+ super().__init__(f"'{func}' 已移除:{hint}")
83
+
84
+
85
+ @dataclass
86
+ class ProcessResult:
87
+ """subprocess.run 结果的结构化包装(充血模型:翻译自己的错误)."""
88
+
89
+ backend: str
90
+ returncode: int
91
+ stdout: str
92
+ stderr: str
93
+
94
+ def raise_for_status(self) -> None:
95
+ """非零退出码翻译为具体异常。被取消的情况由调用方先行判断。"""
96
+ if self.returncode != 0:
97
+ raise BackendRunError(self.backend, self.returncode, self.stderr)
98
+
99
+
100
+ def which_or_raise(binary: str, backend: str) -> str:
101
+ """查找 CLI 二进制路径,找不到抛 BackendLaunchError。"""
102
+ found = shutil.which(binary)
103
+ if not found:
104
+ raise BackendLaunchError(backend, f"未找到可执行文件 '{binary}',请先安装")
105
+ return found
agent_cli_lib/py.typed ADDED
File without changes