codebee 0.1.103 → 0.1.104
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.
- package/CHANGELOG.md +4 -0
- package/LICENSE +21 -21
- package/README.md +2 -3
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -457
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +285 -181
- package/app/core/openapi.py +217 -217
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +55 -3
- package/app/core/planner.py +7 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -659
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1185
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -95
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/artdiff.js +74 -74
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
package/app/core/acp_client.py
CHANGED
|
@@ -1,457 +1,457 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""Minimal ACP v1 stdio client for locally configured agent processes."""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import queue
|
|
8
|
-
import subprocess
|
|
9
|
-
import threading
|
|
10
|
-
import time
|
|
11
|
-
import uuid
|
|
12
|
-
|
|
13
|
-
from . import runner
|
|
14
|
-
from .env_scrub import scrub_env
|
|
15
|
-
|
|
16
|
-
_MAX_LINE = 2 * 1024 * 1024
|
|
17
|
-
_MAX_TEXT = 2 * 1024 * 1024
|
|
18
|
-
_MAX_ACP_EVENTS = 200
|
|
19
|
-
_ACP_EVENT_TYPES = {
|
|
20
|
-
"agent_thought_chunk", "tool_call", "tool_call_update", "plan",
|
|
21
|
-
"current_mode_update", "config_option_update", "session_info_update",
|
|
22
|
-
"available_commands_update", "usage_update", "session_notification",
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _record_session_update(events, update):
|
|
27
|
-
"""Keep bounded, redacted ACP event metadata without tool payloads."""
|
|
28
|
-
if not isinstance(update, dict) or len(events) >= _MAX_ACP_EVENTS:
|
|
29
|
-
return
|
|
30
|
-
kind = str(update.get("sessionUpdate") or "")[:60]
|
|
31
|
-
if kind not in _ACP_EVENT_TYPES:
|
|
32
|
-
return
|
|
33
|
-
from .redact import scrub_text
|
|
34
|
-
item = {"sessionUpdate": kind}
|
|
35
|
-
for key in ("toolCallId", "title", "status", "modeId", "configId", "commandId"):
|
|
36
|
-
value = update.get(key)
|
|
37
|
-
if isinstance(value, (str, int, float, bool)):
|
|
38
|
-
item[key] = scrub_text(str(value), limit=160)
|
|
39
|
-
entries = update.get("entries")
|
|
40
|
-
if kind == "plan" and isinstance(entries, list):
|
|
41
|
-
item["entries"] = [{
|
|
42
|
-
"content": scrub_text(str(entry.get("content") or ""), limit=160),
|
|
43
|
-
"status": scrub_text(str(entry.get("status") or ""), limit=40),
|
|
44
|
-
} for entry in entries[:12] if isinstance(entry, dict)]
|
|
45
|
-
events.append(item)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class ACPError(Exception):
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class _Connection:
|
|
53
|
-
def __init__(self, process):
|
|
54
|
-
self.process = process
|
|
55
|
-
self.incoming = queue.Queue()
|
|
56
|
-
self.write_lock = threading.Lock()
|
|
57
|
-
self.next_id = 1
|
|
58
|
-
self.closed = False
|
|
59
|
-
self.reader = threading.Thread(target=self._read_lines,
|
|
60
|
-
name="acp-stdio-reader", daemon=True)
|
|
61
|
-
self.reader.start()
|
|
62
|
-
|
|
63
|
-
def _read_lines(self):
|
|
64
|
-
try:
|
|
65
|
-
while True:
|
|
66
|
-
line = self.process.stdout.readline()
|
|
67
|
-
if not line:
|
|
68
|
-
break
|
|
69
|
-
if len(line) > _MAX_LINE:
|
|
70
|
-
self.incoming.put({"_transport_error": "ACP 响应超过 2 MiB"})
|
|
71
|
-
break
|
|
72
|
-
try:
|
|
73
|
-
message = json.loads(line.decode("utf-8") if isinstance(line, bytes)
|
|
74
|
-
else line)
|
|
75
|
-
if isinstance(message, dict):
|
|
76
|
-
self.incoming.put(message)
|
|
77
|
-
except (ValueError, UnicodeDecodeError):
|
|
78
|
-
self.incoming.put({"_transport_error": "ACP 返回了无效 JSON"})
|
|
79
|
-
except Exception as exc:
|
|
80
|
-
self.incoming.put({"_transport_error": "ACP 读取失败:%s" % exc})
|
|
81
|
-
finally:
|
|
82
|
-
self.incoming.put(None)
|
|
83
|
-
|
|
84
|
-
def send(self, message):
|
|
85
|
-
raw = (json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
86
|
-
with self.write_lock:
|
|
87
|
-
if self.closed or self.process.poll() is not None:
|
|
88
|
-
raise ACPError("ACP 进程已退出")
|
|
89
|
-
try:
|
|
90
|
-
self.process.stdin.write(raw.encode("utf-8"))
|
|
91
|
-
except TypeError:
|
|
92
|
-
self.process.stdin.write(raw)
|
|
93
|
-
self.process.stdin.flush()
|
|
94
|
-
|
|
95
|
-
def request(self, method, params, deadline, cancel_event, on_message):
|
|
96
|
-
request_id = self.next_id
|
|
97
|
-
self.next_id += 1
|
|
98
|
-
self.send({"jsonrpc": "2.0", "id": request_id,
|
|
99
|
-
"method": method, "params": params})
|
|
100
|
-
while True:
|
|
101
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
102
|
-
self.send({"jsonrpc": "2.0", "method": "session/cancel",
|
|
103
|
-
"params": {"sessionId": params.get("sessionId", "")}})
|
|
104
|
-
raise InterruptedError("ACP 任务已取消")
|
|
105
|
-
if deadline is not None and time.monotonic() >= deadline:
|
|
106
|
-
self.send({"jsonrpc": "2.0", "method": "session/cancel",
|
|
107
|
-
"params": {"sessionId": params.get("sessionId", "")}})
|
|
108
|
-
raise TimeoutError("ACP 任务总时限已到")
|
|
109
|
-
wait = 0.2
|
|
110
|
-
if deadline is not None:
|
|
111
|
-
wait = min(wait, max(0.01, deadline - time.monotonic()))
|
|
112
|
-
try:
|
|
113
|
-
message = self.incoming.get(timeout=wait)
|
|
114
|
-
except queue.Empty:
|
|
115
|
-
if self.process.poll() is not None and self.incoming.empty():
|
|
116
|
-
raise ACPError("ACP 进程退出码 %s" % self.process.poll())
|
|
117
|
-
continue
|
|
118
|
-
if message is None:
|
|
119
|
-
raise ACPError("ACP 连接已关闭")
|
|
120
|
-
if message.get("_transport_error"):
|
|
121
|
-
raise ACPError(message["_transport_error"])
|
|
122
|
-
if message.get("id") == request_id and ("result" in message or "error" in message):
|
|
123
|
-
if "error" in message:
|
|
124
|
-
error = message.get("error") or {}
|
|
125
|
-
raise ACPError("ACP %s 失败:%s" %
|
|
126
|
-
(method, str(error.get("message") or error)[:500]))
|
|
127
|
-
return message.get("result") or {}
|
|
128
|
-
if message.get("method"):
|
|
129
|
-
response = on_message(message)
|
|
130
|
-
if response is not None:
|
|
131
|
-
self.send(response)
|
|
132
|
-
|
|
133
|
-
def close(self):
|
|
134
|
-
self.closed = True
|
|
135
|
-
try:
|
|
136
|
-
if self.process.poll() is None:
|
|
137
|
-
self.process.stdin.close()
|
|
138
|
-
except Exception:
|
|
139
|
-
pass
|
|
140
|
-
try:
|
|
141
|
-
self.process.wait(timeout=0.5)
|
|
142
|
-
except Exception:
|
|
143
|
-
try:
|
|
144
|
-
runner._kill_tree(self.process.pid)
|
|
145
|
-
except Exception:
|
|
146
|
-
try:
|
|
147
|
-
self.process.kill()
|
|
148
|
-
except Exception:
|
|
149
|
-
pass
|
|
150
|
-
for stream_name in ("stdout", "stderr"):
|
|
151
|
-
try:
|
|
152
|
-
getattr(self.process, stream_name).close()
|
|
153
|
-
except Exception:
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _profile_runtime(agent):
|
|
158
|
-
if not agent.get("profile_managed"):
|
|
159
|
-
return dict(agent)
|
|
160
|
-
from . import backend_profiles
|
|
161
|
-
profile = backend_profiles.get_runtime(agent.get("profile_id"))
|
|
162
|
-
if not profile:
|
|
163
|
-
raise ACPError("ACP Backend Profile 不存在或已停用")
|
|
164
|
-
merged = dict(agent)
|
|
165
|
-
merged.update({"command": profile["command"], "args": profile["args"],
|
|
166
|
-
"env": profile["env"],
|
|
167
|
-
"workspace_path": profile.get("workspace_path") or ""})
|
|
168
|
-
return merged
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _path(workdir, configured_root, requested):
|
|
172
|
-
from pathlib import Path
|
|
173
|
-
root = Path(workdir or ".").expanduser().resolve(strict=True)
|
|
174
|
-
raw = str(requested or "")
|
|
175
|
-
if not raw or "\x00" in raw:
|
|
176
|
-
raise ValueError("ACP 文件路径无效")
|
|
177
|
-
configured = str(configured_root or "").rstrip("/\\")
|
|
178
|
-
if configured and (raw == configured or raw.startswith(configured + "/") or
|
|
179
|
-
raw.startswith(configured + "\\")):
|
|
180
|
-
rel = raw[len(configured):].lstrip("/\\")
|
|
181
|
-
target = root.joinpath(*rel.replace("\\", "/").split("/"))
|
|
182
|
-
else:
|
|
183
|
-
target = Path(raw).expanduser()
|
|
184
|
-
if not target.is_absolute():
|
|
185
|
-
raise ValueError("ACP 文件路径必须映射到当前工作区")
|
|
186
|
-
resolved = target.resolve(strict=False)
|
|
187
|
-
if resolved != root and root not in resolved.parents:
|
|
188
|
-
raise ValueError("ACP 文件路径超出当前工作区")
|
|
189
|
-
return root, resolved
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _fs_request(method, params, workdir, configured_root, readonly, checkpoint_run_id):
|
|
193
|
-
from . import checkpoints
|
|
194
|
-
root, target = _path(workdir, configured_root, params.get("path"))
|
|
195
|
-
if method == "fs/read_text_file":
|
|
196
|
-
try:
|
|
197
|
-
content = target.read_text(encoding="utf-8")
|
|
198
|
-
except (OSError, UnicodeError) as exc:
|
|
199
|
-
raise ValueError("文件不可读取:%s" % exc)
|
|
200
|
-
lines = content.splitlines(keepends=True)
|
|
201
|
-
line = max(1, int(params.get("line") or 1))
|
|
202
|
-
limit = max(1, min(10000, int(params.get("limit") or 10000)))
|
|
203
|
-
if line > 1 or len(lines) > limit:
|
|
204
|
-
content = "".join(lines[line - 1:line - 1 + limit])
|
|
205
|
-
return {"content": content}
|
|
206
|
-
if method == "fs/write_text_file":
|
|
207
|
-
if readonly:
|
|
208
|
-
raise ValueError("此步骤为只读模式")
|
|
209
|
-
content = params.get("content")
|
|
210
|
-
if not isinstance(content, str) or len(content.encode("utf-8")) > 16 * 1024 * 1024:
|
|
211
|
-
raise ValueError("写入内容无效或超过 16 MiB")
|
|
212
|
-
if checkpoint_run_id:
|
|
213
|
-
saved = checkpoints.capture_file(checkpoint_run_id, str(root), target)
|
|
214
|
-
if not saved.get("ok"):
|
|
215
|
-
raise ValueError("检查点无法保存原始文件,拒绝写入")
|
|
216
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
-
# Keep Python 3.8 compatibility: pathlib.Path.write_text did not yet
|
|
218
|
-
# accept ``newline`` on all supported runtimes.
|
|
219
|
-
with target.open("w", encoding="utf-8", newline="") as handle:
|
|
220
|
-
handle.write(content)
|
|
221
|
-
if checkpoint_run_id and not checkpoints.mark_file_after(
|
|
222
|
-
checkpoint_run_id, str(root), target):
|
|
223
|
-
raise ValueError("检查点无法记录写入后的状态")
|
|
224
|
-
return {}
|
|
225
|
-
raise ValueError("不支持的 ACP 文件系统方法")
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _agent_request(message, *, connection, session_id, workdir, configured_root,
|
|
229
|
-
readonly, checkpoint_run_id, cancel_event, deadline, sandbox,
|
|
230
|
-
command_context, terminals):
|
|
231
|
-
method = message.get("method")
|
|
232
|
-
params = message.get("params") or {}
|
|
233
|
-
request_id = message.get("id")
|
|
234
|
-
try:
|
|
235
|
-
if method in ("fs/read_text_file", "fs/write_text_file"):
|
|
236
|
-
blocked = set(sandbox.get("disabled_tools") or [])
|
|
237
|
-
required = "read_file" if method == "fs/read_text_file" else "write_file"
|
|
238
|
-
if required in blocked:
|
|
239
|
-
raise ValueError("沙箱策略已禁用 %s" % required)
|
|
240
|
-
result = _fs_request(method, params, workdir, configured_root,
|
|
241
|
-
readonly, checkpoint_run_id)
|
|
242
|
-
elif method == "session/request_permission":
|
|
243
|
-
result = {"outcome": {"outcome": "cancelled"}}
|
|
244
|
-
elif method == "terminal/create":
|
|
245
|
-
if readonly:
|
|
246
|
-
raise ValueError("此步骤禁止执行终端命令")
|
|
247
|
-
if not command_context.get("allow_terminal", True):
|
|
248
|
-
raise ValueError("ACP 终端能力已禁用")
|
|
249
|
-
command = str(params.get("command") or "").strip()
|
|
250
|
-
args = params.get("args") or []
|
|
251
|
-
if not command or not isinstance(args, list) or len(args) > 100:
|
|
252
|
-
raise ValueError("ACP 命令参数无效")
|
|
253
|
-
if any(not isinstance(arg, str) or "\x00" in arg or len(arg) > 8192
|
|
254
|
-
for arg in args):
|
|
255
|
-
raise ValueError("ACP 命令参数无效")
|
|
256
|
-
cwd = str(params.get("cwd") or workdir or "")
|
|
257
|
-
from . import policy
|
|
258
|
-
if not policy.path_allowed(cwd, sandbox):
|
|
259
|
-
raise ValueError("沙箱拒绝:终端工作目录超出允许范围")
|
|
260
|
-
if os.path.realpath(cwd) != os.path.realpath(workdir or "."):
|
|
261
|
-
raise ValueError("ACP 终端目前仅允许在任务工作目录执行")
|
|
262
|
-
from .builtin_agent import _exec_tool
|
|
263
|
-
import shlex
|
|
264
|
-
terminal_id = "term-" + uuid.uuid4().hex[:16]
|
|
265
|
-
terminal = {"output": "", "done": threading.Event(),
|
|
266
|
-
"cancel": threading.Event(), "released": False}
|
|
267
|
-
terminals[terminal_id] = terminal
|
|
268
|
-
|
|
269
|
-
def _run_terminal():
|
|
270
|
-
rendered = shlex.join([command] + args)
|
|
271
|
-
terminal["output"] = _exec_tool(
|
|
272
|
-
workdir, "run_command", {"command": rendered},
|
|
273
|
-
cancel_event=terminal["cancel"], deadline=deadline,
|
|
274
|
-
disabled_tools=sandbox.get("disabled_tools"), sandbox=sandbox,
|
|
275
|
-
checkpoint_run_id=command_context.get("run_id", ""))
|
|
276
|
-
terminal["done"].set()
|
|
277
|
-
|
|
278
|
-
threading.Thread(target=_run_terminal, name="acp-terminal", daemon=True).start()
|
|
279
|
-
result = {"terminalId": terminal_id}
|
|
280
|
-
elif method == "terminal/output":
|
|
281
|
-
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
282
|
-
if not terminal:
|
|
283
|
-
raise ValueError("terminalId 不存在或已释放")
|
|
284
|
-
output = terminal["output"]
|
|
285
|
-
marker = "退出码: "
|
|
286
|
-
code = None
|
|
287
|
-
if marker in output:
|
|
288
|
-
try:
|
|
289
|
-
code = int(output.split(marker, 1)[1].splitlines()[0].strip())
|
|
290
|
-
except (ValueError, IndexError):
|
|
291
|
-
pass
|
|
292
|
-
limit = max(1, min(4 * 1024 * 1024,
|
|
293
|
-
int(params.get("outputByteLimit") or 1024 * 1024)))
|
|
294
|
-
encoded = output.encode("utf-8")
|
|
295
|
-
truncated = len(encoded) > limit
|
|
296
|
-
if truncated:
|
|
297
|
-
output = encoded[-limit:].decode("utf-8", errors="ignore")
|
|
298
|
-
result = {"output": output, "truncated": truncated,
|
|
299
|
-
"exitStatus": {"exitCode": code, "signal": None}}
|
|
300
|
-
elif method == "terminal/wait_for_exit":
|
|
301
|
-
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
302
|
-
if not terminal:
|
|
303
|
-
raise ValueError("terminalId 不存在或已释放")
|
|
304
|
-
while not terminal["done"].wait(0.1):
|
|
305
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
306
|
-
terminal["cancel"].set()
|
|
307
|
-
if deadline is not None and time.monotonic() >= deadline:
|
|
308
|
-
terminal["cancel"].set()
|
|
309
|
-
raise TimeoutError("ACP 终端等待超过任务时限")
|
|
310
|
-
code = None
|
|
311
|
-
if "退出码: " in terminal["output"]:
|
|
312
|
-
try:
|
|
313
|
-
code = int(terminal["output"].split("退出码: ", 1)[1].splitlines()[0])
|
|
314
|
-
except (ValueError, IndexError):
|
|
315
|
-
pass
|
|
316
|
-
result = {"exitCode": code, "signal": None}
|
|
317
|
-
elif method == "terminal/kill":
|
|
318
|
-
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
319
|
-
if not terminal:
|
|
320
|
-
raise ValueError("terminalId 不存在或已释放")
|
|
321
|
-
terminal["cancel"].set()
|
|
322
|
-
result = {}
|
|
323
|
-
elif method == "terminal/release":
|
|
324
|
-
terminal = terminals.pop(str(params.get("terminalId") or ""), None)
|
|
325
|
-
if not terminal:
|
|
326
|
-
raise ValueError("terminalId 不存在或已释放")
|
|
327
|
-
if not terminal["done"].is_set():
|
|
328
|
-
terminal["cancel"].set()
|
|
329
|
-
result = {}
|
|
330
|
-
else:
|
|
331
|
-
raise ValueError("不支持的 ACP client 方法:%s" % str(method or "")[:80])
|
|
332
|
-
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
333
|
-
except Exception as exc:
|
|
334
|
-
return {"jsonrpc": "2.0", "id": request_id,
|
|
335
|
-
"error": {"code": -32000, "message": str(exc)[:500]}}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
|
|
339
|
-
cancel_event=None, deadline=None, command_context=None,
|
|
340
|
-
checkpoint_run_id="", resume=None):
|
|
341
|
-
"""Run an ACP v1 agent over stdio and return the common runner result shape."""
|
|
342
|
-
started = time.monotonic()
|
|
343
|
-
process = None
|
|
344
|
-
connection = None
|
|
345
|
-
command_context = dict(command_context or {})
|
|
346
|
-
try:
|
|
347
|
-
profile = _profile_runtime(agent)
|
|
348
|
-
cwd = os.path.realpath(workdir or ".")
|
|
349
|
-
if not os.path.isdir(cwd):
|
|
350
|
-
raise ACPError("ACP 工作目录不可用")
|
|
351
|
-
command = str(profile.get("command") or "").strip()
|
|
352
|
-
args = profile.get("args") or []
|
|
353
|
-
if not command or not isinstance(args, list) or any(not isinstance(x, str) for x in args):
|
|
354
|
-
raise ACPError("ACP 启动命令配置无效")
|
|
355
|
-
env = scrub_env(os.environ.copy())
|
|
356
|
-
env.update({str(k): str(v) for k, v in (profile.get("env") or {}).items()})
|
|
357
|
-
argv = runner.resolve_command(command) + args
|
|
358
|
-
process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
359
|
-
stderr=subprocess.PIPE, cwd=cwd, env=env,
|
|
360
|
-
bufsize=0, start_new_session=(os.name != "nt"),
|
|
361
|
-
creationflags=runner.CREATE_NO_WINDOW)
|
|
362
|
-
connection = _Connection(process)
|
|
363
|
-
absolute_deadline = deadline
|
|
364
|
-
if absolute_deadline is None:
|
|
365
|
-
absolute_deadline = started + max(1.0, float(timeout or 1200))
|
|
366
|
-
text_parts = []
|
|
367
|
-
acp_events = []
|
|
368
|
-
terminals = {}
|
|
369
|
-
session_id = str(resume or "")
|
|
370
|
-
|
|
371
|
-
def on_message(message):
|
|
372
|
-
nonlocal session_id
|
|
373
|
-
if message.get("method") == "session/update":
|
|
374
|
-
params = message.get("params") or {}
|
|
375
|
-
update = params.get("update") or {}
|
|
376
|
-
if params.get("sessionId") == session_id:
|
|
377
|
-
_record_session_update(acp_events, update)
|
|
378
|
-
if params.get("sessionId") == session_id and update.get("sessionUpdate") == "agent_message_chunk":
|
|
379
|
-
content = update.get("content") or {}
|
|
380
|
-
if content.get("type") == "text":
|
|
381
|
-
piece = str(content.get("text") or "")
|
|
382
|
-
if sum(map(len, text_parts)) < _MAX_TEXT:
|
|
383
|
-
text_parts.append(piece[:_MAX_TEXT - sum(map(len, text_parts))])
|
|
384
|
-
return None
|
|
385
|
-
return _agent_request(
|
|
386
|
-
message, connection=connection, session_id=session_id,
|
|
387
|
-
workdir=cwd, configured_root=profile.get("workspace_path"),
|
|
388
|
-
readonly=readonly, checkpoint_run_id=checkpoint_run_id,
|
|
389
|
-
cancel_event=cancel_event, deadline=absolute_deadline,
|
|
390
|
-
sandbox=command_context.get("sandbox") or {},
|
|
391
|
-
command_context=command_context, terminals=terminals)
|
|
392
|
-
|
|
393
|
-
client_capabilities = {"fs": {"readTextFile": True,
|
|
394
|
-
"writeTextFile": not readonly},
|
|
395
|
-
"terminal": not readonly and
|
|
396
|
-
command_context.get("allow_terminal", True)}
|
|
397
|
-
initialized = connection.request("initialize", {
|
|
398
|
-
"protocolVersion": 1, "clientCapabilities": client_capabilities,
|
|
399
|
-
"clientInfo": {"name": "CodeBee", "title": "CodeBee", "version": "1"}},
|
|
400
|
-
absolute_deadline, cancel_event, on_message)
|
|
401
|
-
if int(initialized.get("protocolVersion") or 0) != 1:
|
|
402
|
-
raise ACPError("ACP 版本不兼容:服务端未协商 protocolVersion 1")
|
|
403
|
-
mcp_servers = []
|
|
404
|
-
if session_id:
|
|
405
|
-
capabilities = initialized.get("agentCapabilities") or {}
|
|
406
|
-
if not capabilities.get("loadSession"):
|
|
407
|
-
raise ACPError("此 ACP Agent 不支持恢复会话")
|
|
408
|
-
connection.request("session/load", {
|
|
409
|
-
"sessionId": session_id,
|
|
410
|
-
"cwd": profile.get("workspace_path") or cwd,
|
|
411
|
-
"mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
|
|
412
|
-
else:
|
|
413
|
-
session = connection.request("session/new", {
|
|
414
|
-
"cwd": profile.get("workspace_path") or cwd,
|
|
415
|
-
"mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
|
|
416
|
-
session_id = str(session.get("sessionId") or "")
|
|
417
|
-
if not session_id:
|
|
418
|
-
raise ACPError("ACP 未返回 sessionId")
|
|
419
|
-
connection.request("session/prompt", {
|
|
420
|
-
"sessionId": session_id,
|
|
421
|
-
"prompt": [{"type": "text", "text": str(prompt or "")}]},
|
|
422
|
-
absolute_deadline, cancel_event, on_message)
|
|
423
|
-
if not text_parts:
|
|
424
|
-
raise ACPError("ACP 完成但未返回文本消息")
|
|
425
|
-
return {"ok": True, "text": "".join(text_parts)[:_MAX_TEXT], "json": None,
|
|
426
|
-
"cost_usd": 0.0, "tokens": 0, "usage": None, "error": "",
|
|
427
|
-
"error_code": "", "raw": {"exit_code": 0,
|
|
428
|
-
"duration": time.monotonic() - started,
|
|
429
|
-
"acp_events": acp_events},
|
|
430
|
-
"kind": "acp", "model": None, "sid": session_id,
|
|
431
|
-
"attempts": []}
|
|
432
|
-
except InterruptedError as exc:
|
|
433
|
-
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
434
|
-
"tokens": 0, "usage": None, "error": str(exc),
|
|
435
|
-
"raw": {"exit_code": None, "cancelled": True,
|
|
436
|
-
"duration": time.monotonic() - started},
|
|
437
|
-
"kind": "acp", "model": None, "attempts": []}
|
|
438
|
-
except TimeoutError as exc:
|
|
439
|
-
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
440
|
-
"tokens": 0, "usage": None, "error": str(exc),
|
|
441
|
-
"raw": {"exit_code": None, "timed_out": True,
|
|
442
|
-
"deadline_exceeded": deadline is not None,
|
|
443
|
-
"duration": time.monotonic() - started},
|
|
444
|
-
"kind": "acp", "model": None, "attempts": []}
|
|
445
|
-
except Exception as exc:
|
|
446
|
-
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
447
|
-
"tokens": 0, "usage": None, "error": str(exc)[:1000],
|
|
448
|
-
"raw": {"exit_code": None, "duration": time.monotonic() - started},
|
|
449
|
-
"kind": "acp", "model": None, "attempts": []}
|
|
450
|
-
finally:
|
|
451
|
-
if connection is not None:
|
|
452
|
-
connection.close()
|
|
453
|
-
elif process is not None:
|
|
454
|
-
try:
|
|
455
|
-
runner._kill_tree(process.pid)
|
|
456
|
-
except Exception:
|
|
457
|
-
pass
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Minimal ACP v1 stdio client for locally configured agent processes."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from . import runner
|
|
14
|
+
from .env_scrub import scrub_env
|
|
15
|
+
|
|
16
|
+
_MAX_LINE = 2 * 1024 * 1024
|
|
17
|
+
_MAX_TEXT = 2 * 1024 * 1024
|
|
18
|
+
_MAX_ACP_EVENTS = 200
|
|
19
|
+
_ACP_EVENT_TYPES = {
|
|
20
|
+
"agent_thought_chunk", "tool_call", "tool_call_update", "plan",
|
|
21
|
+
"current_mode_update", "config_option_update", "session_info_update",
|
|
22
|
+
"available_commands_update", "usage_update", "session_notification",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _record_session_update(events, update):
|
|
27
|
+
"""Keep bounded, redacted ACP event metadata without tool payloads."""
|
|
28
|
+
if not isinstance(update, dict) or len(events) >= _MAX_ACP_EVENTS:
|
|
29
|
+
return
|
|
30
|
+
kind = str(update.get("sessionUpdate") or "")[:60]
|
|
31
|
+
if kind not in _ACP_EVENT_TYPES:
|
|
32
|
+
return
|
|
33
|
+
from .redact import scrub_text
|
|
34
|
+
item = {"sessionUpdate": kind}
|
|
35
|
+
for key in ("toolCallId", "title", "status", "modeId", "configId", "commandId"):
|
|
36
|
+
value = update.get(key)
|
|
37
|
+
if isinstance(value, (str, int, float, bool)):
|
|
38
|
+
item[key] = scrub_text(str(value), limit=160)
|
|
39
|
+
entries = update.get("entries")
|
|
40
|
+
if kind == "plan" and isinstance(entries, list):
|
|
41
|
+
item["entries"] = [{
|
|
42
|
+
"content": scrub_text(str(entry.get("content") or ""), limit=160),
|
|
43
|
+
"status": scrub_text(str(entry.get("status") or ""), limit=40),
|
|
44
|
+
} for entry in entries[:12] if isinstance(entry, dict)]
|
|
45
|
+
events.append(item)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ACPError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Connection:
|
|
53
|
+
def __init__(self, process):
|
|
54
|
+
self.process = process
|
|
55
|
+
self.incoming = queue.Queue()
|
|
56
|
+
self.write_lock = threading.Lock()
|
|
57
|
+
self.next_id = 1
|
|
58
|
+
self.closed = False
|
|
59
|
+
self.reader = threading.Thread(target=self._read_lines,
|
|
60
|
+
name="acp-stdio-reader", daemon=True)
|
|
61
|
+
self.reader.start()
|
|
62
|
+
|
|
63
|
+
def _read_lines(self):
|
|
64
|
+
try:
|
|
65
|
+
while True:
|
|
66
|
+
line = self.process.stdout.readline()
|
|
67
|
+
if not line:
|
|
68
|
+
break
|
|
69
|
+
if len(line) > _MAX_LINE:
|
|
70
|
+
self.incoming.put({"_transport_error": "ACP 响应超过 2 MiB"})
|
|
71
|
+
break
|
|
72
|
+
try:
|
|
73
|
+
message = json.loads(line.decode("utf-8") if isinstance(line, bytes)
|
|
74
|
+
else line)
|
|
75
|
+
if isinstance(message, dict):
|
|
76
|
+
self.incoming.put(message)
|
|
77
|
+
except (ValueError, UnicodeDecodeError):
|
|
78
|
+
self.incoming.put({"_transport_error": "ACP 返回了无效 JSON"})
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
self.incoming.put({"_transport_error": "ACP 读取失败:%s" % exc})
|
|
81
|
+
finally:
|
|
82
|
+
self.incoming.put(None)
|
|
83
|
+
|
|
84
|
+
def send(self, message):
|
|
85
|
+
raw = (json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
86
|
+
with self.write_lock:
|
|
87
|
+
if self.closed or self.process.poll() is not None:
|
|
88
|
+
raise ACPError("ACP 进程已退出")
|
|
89
|
+
try:
|
|
90
|
+
self.process.stdin.write(raw.encode("utf-8"))
|
|
91
|
+
except TypeError:
|
|
92
|
+
self.process.stdin.write(raw)
|
|
93
|
+
self.process.stdin.flush()
|
|
94
|
+
|
|
95
|
+
def request(self, method, params, deadline, cancel_event, on_message):
|
|
96
|
+
request_id = self.next_id
|
|
97
|
+
self.next_id += 1
|
|
98
|
+
self.send({"jsonrpc": "2.0", "id": request_id,
|
|
99
|
+
"method": method, "params": params})
|
|
100
|
+
while True:
|
|
101
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
102
|
+
self.send({"jsonrpc": "2.0", "method": "session/cancel",
|
|
103
|
+
"params": {"sessionId": params.get("sessionId", "")}})
|
|
104
|
+
raise InterruptedError("ACP 任务已取消")
|
|
105
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
106
|
+
self.send({"jsonrpc": "2.0", "method": "session/cancel",
|
|
107
|
+
"params": {"sessionId": params.get("sessionId", "")}})
|
|
108
|
+
raise TimeoutError("ACP 任务总时限已到")
|
|
109
|
+
wait = 0.2
|
|
110
|
+
if deadline is not None:
|
|
111
|
+
wait = min(wait, max(0.01, deadline - time.monotonic()))
|
|
112
|
+
try:
|
|
113
|
+
message = self.incoming.get(timeout=wait)
|
|
114
|
+
except queue.Empty:
|
|
115
|
+
if self.process.poll() is not None and self.incoming.empty():
|
|
116
|
+
raise ACPError("ACP 进程退出码 %s" % self.process.poll())
|
|
117
|
+
continue
|
|
118
|
+
if message is None:
|
|
119
|
+
raise ACPError("ACP 连接已关闭")
|
|
120
|
+
if message.get("_transport_error"):
|
|
121
|
+
raise ACPError(message["_transport_error"])
|
|
122
|
+
if message.get("id") == request_id and ("result" in message or "error" in message):
|
|
123
|
+
if "error" in message:
|
|
124
|
+
error = message.get("error") or {}
|
|
125
|
+
raise ACPError("ACP %s 失败:%s" %
|
|
126
|
+
(method, str(error.get("message") or error)[:500]))
|
|
127
|
+
return message.get("result") or {}
|
|
128
|
+
if message.get("method"):
|
|
129
|
+
response = on_message(message)
|
|
130
|
+
if response is not None:
|
|
131
|
+
self.send(response)
|
|
132
|
+
|
|
133
|
+
def close(self):
|
|
134
|
+
self.closed = True
|
|
135
|
+
try:
|
|
136
|
+
if self.process.poll() is None:
|
|
137
|
+
self.process.stdin.close()
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
self.process.wait(timeout=0.5)
|
|
142
|
+
except Exception:
|
|
143
|
+
try:
|
|
144
|
+
runner._kill_tree(self.process.pid)
|
|
145
|
+
except Exception:
|
|
146
|
+
try:
|
|
147
|
+
self.process.kill()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
for stream_name in ("stdout", "stderr"):
|
|
151
|
+
try:
|
|
152
|
+
getattr(self.process, stream_name).close()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _profile_runtime(agent):
|
|
158
|
+
if not agent.get("profile_managed"):
|
|
159
|
+
return dict(agent)
|
|
160
|
+
from . import backend_profiles
|
|
161
|
+
profile = backend_profiles.get_runtime(agent.get("profile_id"))
|
|
162
|
+
if not profile:
|
|
163
|
+
raise ACPError("ACP Backend Profile 不存在或已停用")
|
|
164
|
+
merged = dict(agent)
|
|
165
|
+
merged.update({"command": profile["command"], "args": profile["args"],
|
|
166
|
+
"env": profile["env"],
|
|
167
|
+
"workspace_path": profile.get("workspace_path") or ""})
|
|
168
|
+
return merged
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _path(workdir, configured_root, requested):
|
|
172
|
+
from pathlib import Path
|
|
173
|
+
root = Path(workdir or ".").expanduser().resolve(strict=True)
|
|
174
|
+
raw = str(requested or "")
|
|
175
|
+
if not raw or "\x00" in raw:
|
|
176
|
+
raise ValueError("ACP 文件路径无效")
|
|
177
|
+
configured = str(configured_root or "").rstrip("/\\")
|
|
178
|
+
if configured and (raw == configured or raw.startswith(configured + "/") or
|
|
179
|
+
raw.startswith(configured + "\\")):
|
|
180
|
+
rel = raw[len(configured):].lstrip("/\\")
|
|
181
|
+
target = root.joinpath(*rel.replace("\\", "/").split("/"))
|
|
182
|
+
else:
|
|
183
|
+
target = Path(raw).expanduser()
|
|
184
|
+
if not target.is_absolute():
|
|
185
|
+
raise ValueError("ACP 文件路径必须映射到当前工作区")
|
|
186
|
+
resolved = target.resolve(strict=False)
|
|
187
|
+
if resolved != root and root not in resolved.parents:
|
|
188
|
+
raise ValueError("ACP 文件路径超出当前工作区")
|
|
189
|
+
return root, resolved
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _fs_request(method, params, workdir, configured_root, readonly, checkpoint_run_id):
|
|
193
|
+
from . import checkpoints
|
|
194
|
+
root, target = _path(workdir, configured_root, params.get("path"))
|
|
195
|
+
if method == "fs/read_text_file":
|
|
196
|
+
try:
|
|
197
|
+
content = target.read_text(encoding="utf-8")
|
|
198
|
+
except (OSError, UnicodeError) as exc:
|
|
199
|
+
raise ValueError("文件不可读取:%s" % exc)
|
|
200
|
+
lines = content.splitlines(keepends=True)
|
|
201
|
+
line = max(1, int(params.get("line") or 1))
|
|
202
|
+
limit = max(1, min(10000, int(params.get("limit") or 10000)))
|
|
203
|
+
if line > 1 or len(lines) > limit:
|
|
204
|
+
content = "".join(lines[line - 1:line - 1 + limit])
|
|
205
|
+
return {"content": content}
|
|
206
|
+
if method == "fs/write_text_file":
|
|
207
|
+
if readonly:
|
|
208
|
+
raise ValueError("此步骤为只读模式")
|
|
209
|
+
content = params.get("content")
|
|
210
|
+
if not isinstance(content, str) or len(content.encode("utf-8")) > 16 * 1024 * 1024:
|
|
211
|
+
raise ValueError("写入内容无效或超过 16 MiB")
|
|
212
|
+
if checkpoint_run_id:
|
|
213
|
+
saved = checkpoints.capture_file(checkpoint_run_id, str(root), target)
|
|
214
|
+
if not saved.get("ok"):
|
|
215
|
+
raise ValueError("检查点无法保存原始文件,拒绝写入")
|
|
216
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
# Keep Python 3.8 compatibility: pathlib.Path.write_text did not yet
|
|
218
|
+
# accept ``newline`` on all supported runtimes.
|
|
219
|
+
with target.open("w", encoding="utf-8", newline="") as handle:
|
|
220
|
+
handle.write(content)
|
|
221
|
+
if checkpoint_run_id and not checkpoints.mark_file_after(
|
|
222
|
+
checkpoint_run_id, str(root), target):
|
|
223
|
+
raise ValueError("检查点无法记录写入后的状态")
|
|
224
|
+
return {}
|
|
225
|
+
raise ValueError("不支持的 ACP 文件系统方法")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _agent_request(message, *, connection, session_id, workdir, configured_root,
|
|
229
|
+
readonly, checkpoint_run_id, cancel_event, deadline, sandbox,
|
|
230
|
+
command_context, terminals):
|
|
231
|
+
method = message.get("method")
|
|
232
|
+
params = message.get("params") or {}
|
|
233
|
+
request_id = message.get("id")
|
|
234
|
+
try:
|
|
235
|
+
if method in ("fs/read_text_file", "fs/write_text_file"):
|
|
236
|
+
blocked = set(sandbox.get("disabled_tools") or [])
|
|
237
|
+
required = "read_file" if method == "fs/read_text_file" else "write_file"
|
|
238
|
+
if required in blocked:
|
|
239
|
+
raise ValueError("沙箱策略已禁用 %s" % required)
|
|
240
|
+
result = _fs_request(method, params, workdir, configured_root,
|
|
241
|
+
readonly, checkpoint_run_id)
|
|
242
|
+
elif method == "session/request_permission":
|
|
243
|
+
result = {"outcome": {"outcome": "cancelled"}}
|
|
244
|
+
elif method == "terminal/create":
|
|
245
|
+
if readonly:
|
|
246
|
+
raise ValueError("此步骤禁止执行终端命令")
|
|
247
|
+
if not command_context.get("allow_terminal", True):
|
|
248
|
+
raise ValueError("ACP 终端能力已禁用")
|
|
249
|
+
command = str(params.get("command") or "").strip()
|
|
250
|
+
args = params.get("args") or []
|
|
251
|
+
if not command or not isinstance(args, list) or len(args) > 100:
|
|
252
|
+
raise ValueError("ACP 命令参数无效")
|
|
253
|
+
if any(not isinstance(arg, str) or "\x00" in arg or len(arg) > 8192
|
|
254
|
+
for arg in args):
|
|
255
|
+
raise ValueError("ACP 命令参数无效")
|
|
256
|
+
cwd = str(params.get("cwd") or workdir or "")
|
|
257
|
+
from . import policy
|
|
258
|
+
if not policy.path_allowed(cwd, sandbox):
|
|
259
|
+
raise ValueError("沙箱拒绝:终端工作目录超出允许范围")
|
|
260
|
+
if os.path.realpath(cwd) != os.path.realpath(workdir or "."):
|
|
261
|
+
raise ValueError("ACP 终端目前仅允许在任务工作目录执行")
|
|
262
|
+
from .builtin_agent import _exec_tool
|
|
263
|
+
import shlex
|
|
264
|
+
terminal_id = "term-" + uuid.uuid4().hex[:16]
|
|
265
|
+
terminal = {"output": "", "done": threading.Event(),
|
|
266
|
+
"cancel": threading.Event(), "released": False}
|
|
267
|
+
terminals[terminal_id] = terminal
|
|
268
|
+
|
|
269
|
+
def _run_terminal():
|
|
270
|
+
rendered = shlex.join([command] + args)
|
|
271
|
+
terminal["output"] = _exec_tool(
|
|
272
|
+
workdir, "run_command", {"command": rendered},
|
|
273
|
+
cancel_event=terminal["cancel"], deadline=deadline,
|
|
274
|
+
disabled_tools=sandbox.get("disabled_tools"), sandbox=sandbox,
|
|
275
|
+
checkpoint_run_id=command_context.get("run_id", ""))
|
|
276
|
+
terminal["done"].set()
|
|
277
|
+
|
|
278
|
+
threading.Thread(target=_run_terminal, name="acp-terminal", daemon=True).start()
|
|
279
|
+
result = {"terminalId": terminal_id}
|
|
280
|
+
elif method == "terminal/output":
|
|
281
|
+
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
282
|
+
if not terminal:
|
|
283
|
+
raise ValueError("terminalId 不存在或已释放")
|
|
284
|
+
output = terminal["output"]
|
|
285
|
+
marker = "退出码: "
|
|
286
|
+
code = None
|
|
287
|
+
if marker in output:
|
|
288
|
+
try:
|
|
289
|
+
code = int(output.split(marker, 1)[1].splitlines()[0].strip())
|
|
290
|
+
except (ValueError, IndexError):
|
|
291
|
+
pass
|
|
292
|
+
limit = max(1, min(4 * 1024 * 1024,
|
|
293
|
+
int(params.get("outputByteLimit") or 1024 * 1024)))
|
|
294
|
+
encoded = output.encode("utf-8")
|
|
295
|
+
truncated = len(encoded) > limit
|
|
296
|
+
if truncated:
|
|
297
|
+
output = encoded[-limit:].decode("utf-8", errors="ignore")
|
|
298
|
+
result = {"output": output, "truncated": truncated,
|
|
299
|
+
"exitStatus": {"exitCode": code, "signal": None}}
|
|
300
|
+
elif method == "terminal/wait_for_exit":
|
|
301
|
+
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
302
|
+
if not terminal:
|
|
303
|
+
raise ValueError("terminalId 不存在或已释放")
|
|
304
|
+
while not terminal["done"].wait(0.1):
|
|
305
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
306
|
+
terminal["cancel"].set()
|
|
307
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
308
|
+
terminal["cancel"].set()
|
|
309
|
+
raise TimeoutError("ACP 终端等待超过任务时限")
|
|
310
|
+
code = None
|
|
311
|
+
if "退出码: " in terminal["output"]:
|
|
312
|
+
try:
|
|
313
|
+
code = int(terminal["output"].split("退出码: ", 1)[1].splitlines()[0])
|
|
314
|
+
except (ValueError, IndexError):
|
|
315
|
+
pass
|
|
316
|
+
result = {"exitCode": code, "signal": None}
|
|
317
|
+
elif method == "terminal/kill":
|
|
318
|
+
terminal = terminals.get(str(params.get("terminalId") or ""))
|
|
319
|
+
if not terminal:
|
|
320
|
+
raise ValueError("terminalId 不存在或已释放")
|
|
321
|
+
terminal["cancel"].set()
|
|
322
|
+
result = {}
|
|
323
|
+
elif method == "terminal/release":
|
|
324
|
+
terminal = terminals.pop(str(params.get("terminalId") or ""), None)
|
|
325
|
+
if not terminal:
|
|
326
|
+
raise ValueError("terminalId 不存在或已释放")
|
|
327
|
+
if not terminal["done"].is_set():
|
|
328
|
+
terminal["cancel"].set()
|
|
329
|
+
result = {}
|
|
330
|
+
else:
|
|
331
|
+
raise ValueError("不支持的 ACP client 方法:%s" % str(method or "")[:80])
|
|
332
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
return {"jsonrpc": "2.0", "id": request_id,
|
|
335
|
+
"error": {"code": -32000, "message": str(exc)[:500]}}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
|
|
339
|
+
cancel_event=None, deadline=None, command_context=None,
|
|
340
|
+
checkpoint_run_id="", resume=None):
|
|
341
|
+
"""Run an ACP v1 agent over stdio and return the common runner result shape."""
|
|
342
|
+
started = time.monotonic()
|
|
343
|
+
process = None
|
|
344
|
+
connection = None
|
|
345
|
+
command_context = dict(command_context or {})
|
|
346
|
+
try:
|
|
347
|
+
profile = _profile_runtime(agent)
|
|
348
|
+
cwd = os.path.realpath(workdir or ".")
|
|
349
|
+
if not os.path.isdir(cwd):
|
|
350
|
+
raise ACPError("ACP 工作目录不可用")
|
|
351
|
+
command = str(profile.get("command") or "").strip()
|
|
352
|
+
args = profile.get("args") or []
|
|
353
|
+
if not command or not isinstance(args, list) or any(not isinstance(x, str) for x in args):
|
|
354
|
+
raise ACPError("ACP 启动命令配置无效")
|
|
355
|
+
env = scrub_env(os.environ.copy())
|
|
356
|
+
env.update({str(k): str(v) for k, v in (profile.get("env") or {}).items()})
|
|
357
|
+
argv = runner.resolve_command(command) + args
|
|
358
|
+
process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
359
|
+
stderr=subprocess.PIPE, cwd=cwd, env=env,
|
|
360
|
+
bufsize=0, start_new_session=(os.name != "nt"),
|
|
361
|
+
creationflags=runner.CREATE_NO_WINDOW)
|
|
362
|
+
connection = _Connection(process)
|
|
363
|
+
absolute_deadline = deadline
|
|
364
|
+
if absolute_deadline is None:
|
|
365
|
+
absolute_deadline = started + max(1.0, float(timeout or 1200))
|
|
366
|
+
text_parts = []
|
|
367
|
+
acp_events = []
|
|
368
|
+
terminals = {}
|
|
369
|
+
session_id = str(resume or "")
|
|
370
|
+
|
|
371
|
+
def on_message(message):
|
|
372
|
+
nonlocal session_id
|
|
373
|
+
if message.get("method") == "session/update":
|
|
374
|
+
params = message.get("params") or {}
|
|
375
|
+
update = params.get("update") or {}
|
|
376
|
+
if params.get("sessionId") == session_id:
|
|
377
|
+
_record_session_update(acp_events, update)
|
|
378
|
+
if params.get("sessionId") == session_id and update.get("sessionUpdate") == "agent_message_chunk":
|
|
379
|
+
content = update.get("content") or {}
|
|
380
|
+
if content.get("type") == "text":
|
|
381
|
+
piece = str(content.get("text") or "")
|
|
382
|
+
if sum(map(len, text_parts)) < _MAX_TEXT:
|
|
383
|
+
text_parts.append(piece[:_MAX_TEXT - sum(map(len, text_parts))])
|
|
384
|
+
return None
|
|
385
|
+
return _agent_request(
|
|
386
|
+
message, connection=connection, session_id=session_id,
|
|
387
|
+
workdir=cwd, configured_root=profile.get("workspace_path"),
|
|
388
|
+
readonly=readonly, checkpoint_run_id=checkpoint_run_id,
|
|
389
|
+
cancel_event=cancel_event, deadline=absolute_deadline,
|
|
390
|
+
sandbox=command_context.get("sandbox") or {},
|
|
391
|
+
command_context=command_context, terminals=terminals)
|
|
392
|
+
|
|
393
|
+
client_capabilities = {"fs": {"readTextFile": True,
|
|
394
|
+
"writeTextFile": not readonly},
|
|
395
|
+
"terminal": not readonly and
|
|
396
|
+
command_context.get("allow_terminal", True)}
|
|
397
|
+
initialized = connection.request("initialize", {
|
|
398
|
+
"protocolVersion": 1, "clientCapabilities": client_capabilities,
|
|
399
|
+
"clientInfo": {"name": "CodeBee", "title": "CodeBee", "version": "1"}},
|
|
400
|
+
absolute_deadline, cancel_event, on_message)
|
|
401
|
+
if int(initialized.get("protocolVersion") or 0) != 1:
|
|
402
|
+
raise ACPError("ACP 版本不兼容:服务端未协商 protocolVersion 1")
|
|
403
|
+
mcp_servers = []
|
|
404
|
+
if session_id:
|
|
405
|
+
capabilities = initialized.get("agentCapabilities") or {}
|
|
406
|
+
if not capabilities.get("loadSession"):
|
|
407
|
+
raise ACPError("此 ACP Agent 不支持恢复会话")
|
|
408
|
+
connection.request("session/load", {
|
|
409
|
+
"sessionId": session_id,
|
|
410
|
+
"cwd": profile.get("workspace_path") or cwd,
|
|
411
|
+
"mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
|
|
412
|
+
else:
|
|
413
|
+
session = connection.request("session/new", {
|
|
414
|
+
"cwd": profile.get("workspace_path") or cwd,
|
|
415
|
+
"mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
|
|
416
|
+
session_id = str(session.get("sessionId") or "")
|
|
417
|
+
if not session_id:
|
|
418
|
+
raise ACPError("ACP 未返回 sessionId")
|
|
419
|
+
connection.request("session/prompt", {
|
|
420
|
+
"sessionId": session_id,
|
|
421
|
+
"prompt": [{"type": "text", "text": str(prompt or "")}]},
|
|
422
|
+
absolute_deadline, cancel_event, on_message)
|
|
423
|
+
if not text_parts:
|
|
424
|
+
raise ACPError("ACP 完成但未返回文本消息")
|
|
425
|
+
return {"ok": True, "text": "".join(text_parts)[:_MAX_TEXT], "json": None,
|
|
426
|
+
"cost_usd": 0.0, "tokens": 0, "usage": None, "error": "",
|
|
427
|
+
"error_code": "", "raw": {"exit_code": 0,
|
|
428
|
+
"duration": time.monotonic() - started,
|
|
429
|
+
"acp_events": acp_events},
|
|
430
|
+
"kind": "acp", "model": None, "sid": session_id,
|
|
431
|
+
"attempts": []}
|
|
432
|
+
except InterruptedError as exc:
|
|
433
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
434
|
+
"tokens": 0, "usage": None, "error": str(exc),
|
|
435
|
+
"raw": {"exit_code": None, "cancelled": True,
|
|
436
|
+
"duration": time.monotonic() - started},
|
|
437
|
+
"kind": "acp", "model": None, "attempts": []}
|
|
438
|
+
except TimeoutError as exc:
|
|
439
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
440
|
+
"tokens": 0, "usage": None, "error": str(exc),
|
|
441
|
+
"raw": {"exit_code": None, "timed_out": True,
|
|
442
|
+
"deadline_exceeded": deadline is not None,
|
|
443
|
+
"duration": time.monotonic() - started},
|
|
444
|
+
"kind": "acp", "model": None, "attempts": []}
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
447
|
+
"tokens": 0, "usage": None, "error": str(exc)[:1000],
|
|
448
|
+
"raw": {"exit_code": None, "duration": time.monotonic() - started},
|
|
449
|
+
"kind": "acp", "model": None, "attempts": []}
|
|
450
|
+
finally:
|
|
451
|
+
if connection is not None:
|
|
452
|
+
connection.close()
|
|
453
|
+
elif process is not None:
|
|
454
|
+
try:
|
|
455
|
+
runner._kill_tree(process.pid)
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|