codebee 0.1.104 → 0.1.106
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 +17 -0
- package/README.md +10 -4
- package/app/core/activity.py +36 -0
- package/app/core/builtin_agent.py +6 -1
- package/app/core/checkpoints.py +48 -5
- package/app/core/jobs.py +7 -0
- package/app/core/manager.py +1 -0
- package/app/core/market_remote.py +19 -7
- package/app/core/mcp_client.py +102 -63
- package/app/core/openapi.py +29 -0
- package/app/core/pipeline.py +40 -14
- package/app/core/project_memory.py +47 -1
- package/app/core/publish/auto.py +74 -3
- package/app/core/publish/ledger.py +5 -0
- package/app/core/publish/manager.py +5 -1
- package/app/core/registry.py +35 -0
- package/app/core/review_requests.py +119 -0
- package/app/core/runner.py +5 -1
- package/app/core/runtime_lock.py +75 -0
- package/app/core/selfupdate.py +8 -0
- package/app/core/store.py +32 -2
- package/app/core/usage.py +31 -26
- package/app/core/webhooks.py +17 -13
- package/app/desktop_host.py +190 -0
- package/app/main.py +131 -4
- package/app/pet.py +1 -1
- package/app/ui/activity.css +34 -0
- package/app/ui/activity.js +199 -0
- package/app/ui/app.js +813 -66
- package/app/ui/hive3d.js +214 -72
- package/app/ui/i18n.js +162 -2
- package/app/ui/index.html +155 -75
- package/app/ui/style.css +583 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.106(2026-10-12)
|
|
8
|
+
|
|
9
|
+
- **发布台章节批量发布工作流升级**:待发章节自动对账(已发布/已存草稿自动排除)、按卷计划自动分卷、章节状态徽章一目了然(已过审可直发 / 发布受限 / 审稿未通过 / 已修改需重审 / 待审或评审不足)、「只选可直发」一键筛选;任务按工作区分组管理(工作区视图+区內新建任务)
|
|
10
|
+
- **Windows/macOS 桌面版预览**:新增桌面壳、桌面宿主与运行时锁(防双开),CI 产出预览安装包(npm 主分发不变)
|
|
11
|
+
- **输入区 Codex 风格重排**:目标与对话输入布局更聚焦,配套 markdown 渲染回归测试
|
|
12
|
+
- **移动端任务对话控制增强**
|
|
13
|
+
- **自由 3D 入口暂对用户隐藏**:引擎保留给内部 QA(未达发布质量),hidden/aria-hidden + CSS 双保险防旧缓存/恢复状态重新显示
|
|
14
|
+
- 蜂巢 3D 翅膀/手臂视觉打磨多分支合流收口
|
|
15
|
+
|
|
16
|
+
## v0.1.105(2026-10-12)
|
|
17
|
+
|
|
18
|
+
- **claude CLI 输出上限 16000→32000**:全局评审读 40 万字级书稿(约 28 万 token 输入)时按旧值反复续烧 23 分钟后报「response exceeded the 16000 output token maximum」三连挂(实案:单次 $3 零产出);32000=claude CLI 官方默认档,`setdefault` 语义保留——用户显式配置不覆盖
|
|
19
|
+
- **治理契约:外部人工评审请求通道**:新增 append-only 评审请求台账(requested→assigned→approved/changes_requested/dismissed/expired 幂等流转)与 `/api/reviews`、`/api/reviews/<id>/transition` 路由,外部智能体可按契约提交待人工评审的产物;配套 `/api/capabilities` 能力清单与 `/api/tasks/<id>/memory/preview` 任务记忆注入预览接口
|
|
20
|
+
- **项目记忆注入预算封顶 20000**:注入预算配置值超过 20000 时按 20000 收敛,防止过大预算挤占提示词(含回归测试锁定)
|
|
21
|
+
- **蜂巢 3D 蜜蜂翅膀精修**:翅膀更纤薄透明、减淡青雾、脉络几何强化,助手蜂手臂/键盘手部姿态与腕端高光对齐,视觉契约测试全程护航(约 40 笔打磨)
|
|
22
|
+
- 英文界面补齐「会话续跑」能力标签词条
|
|
23
|
+
|
|
7
24
|
## v0.1.104(2026-10-11)
|
|
8
25
|
|
|
9
26
|
- **连载「一直重写不达标」根因防线(马甲指纹三道防线)**:「落笔入册」「整段连排」类跨章复用此前只能靠评审判死,而重写用的是同一作者模型、长出来的还是同族指纹——循环烧钱永不达标。现把防线前移到生成侧:①**章稿落盘确定性查重闸**:整段(≥40 字)/整句(≥12 字)/完整对白(≥6 字)逐字重复检测,阈值按真实事故章校准,白描短句复沓不误伤;带重复的章稿直接打回,同作者重试与换将的提示词自动追加消重指令(同提示重跑只会长出同族事故);②**起草负样例注入**:近 4 章实际开笔/收束摘录随提示词下发(`__REUSE_GUARD__`),跨章句式同款从评审判据前移成起草约束;③**大纲轮换纪律**:开笔方式与收束形态批内硬性轮换。查重证据自动进评审面可见。生效范围:发版后的新批连载
|
package/README.md
CHANGED
|
@@ -25,9 +25,12 @@ Trae Agent、OpenClaw……),提供
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
<!-- relnotes:start -->
|
|
28
|
-
### 最新版更新内容(v0.1.
|
|
28
|
+
### 最新版更新内容(v0.1.106)
|
|
29
29
|
|
|
30
|
-
-
|
|
30
|
+
- 发布台章节批量发布升级:待发自动对账、按卷自动分卷、可直发状态徽章与一键筛选
|
|
31
|
+
- Windows/macOS 桌面版预览打包(CI 预览安装包)
|
|
32
|
+
- 输入区 Codex 风格重排 + 移动端对话控制增强
|
|
33
|
+
- 自由 3D 入口暂对用户隐藏(未达发布质量,保留内部 QA)
|
|
31
34
|
<!-- relnotes:end -->
|
|
32
35
|
|
|
33
36
|
|
|
@@ -328,8 +331,11 @@ cloudflared tunnel login
|
|
|
328
331
|
cloudflared tunnel create codebee
|
|
329
332
|
cloudflared tunnel route dns --overwrite-dns <隧道UUID> codebee.你的域名.com
|
|
330
333
|
|
|
331
|
-
::
|
|
332
|
-
start-
|
|
334
|
+
:: 日常启动服务(本机后台窗口):
|
|
335
|
+
start-service.bat
|
|
336
|
+
|
|
337
|
+
:: 启动隧道(另开一个窗口;也可 cloudflared service install 注册为开机自启):
|
|
338
|
+
cloudflared tunnel --config "%USERPROFILE%\.cloudflared\config.yml" run
|
|
333
339
|
```
|
|
334
340
|
|
|
335
341
|
- 真本机(不带转发头的 loopback)不受影响。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Small, read-only projections for the sidebar activity view."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from . import contracts
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_activity(run):
|
|
8
|
+
steps = [step for step in (run.get("steps") or []) if isinstance(step, dict)]
|
|
9
|
+
current = next((step for step in reversed(steps)
|
|
10
|
+
if step.get("status") == "running"), None)
|
|
11
|
+
if current is None and steps:
|
|
12
|
+
current = steps[-1]
|
|
13
|
+
failed = next((step for step in reversed(steps)
|
|
14
|
+
if step.get("status") in ("failed", "timeout")), None)
|
|
15
|
+
result = {}
|
|
16
|
+
for name, step in (("current_step", current), ("failed_step", failed)):
|
|
17
|
+
if step:
|
|
18
|
+
result[name] = {key: step[key] for key in
|
|
19
|
+
("n", "role", "agent", "agent_label", "status") if key in step}
|
|
20
|
+
result[name]["summary"] = " ".join(str(step.get("summary") or "").split())[:240]
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def task_attention(tasks, latest):
|
|
25
|
+
"""Approval state is authoritative; never infer approval from a read marker."""
|
|
26
|
+
result = {}
|
|
27
|
+
for task in tasks:
|
|
28
|
+
run = latest.get(task["id"]) or {}
|
|
29
|
+
contract = contracts.get(task["id"]) or {}
|
|
30
|
+
requires_approval = bool(task.get("approval_required") or
|
|
31
|
+
contract.get("approval_required"))
|
|
32
|
+
if not requires_approval or run.get("status") != "done":
|
|
33
|
+
continue
|
|
34
|
+
if (contract.get("approval") or {}).get("status") != "approved":
|
|
35
|
+
result[task["id"]] = {"approval_pending": True}
|
|
36
|
+
return result
|
|
@@ -931,7 +931,8 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
|
|
|
931
931
|
remaining if remaining is not None else CMD_MAX_TIMEOUT)
|
|
932
932
|
r = mcp_client.dispatch_full_name(
|
|
933
933
|
name, args or {}, sandbox=sandbox, workdir=workdir,
|
|
934
|
-
disabled_tools=denied, timeout_s=timeout_s
|
|
934
|
+
disabled_tools=denied, timeout_s=timeout_s,
|
|
935
|
+
cancel_event=cancel_event, deadline=deadline)
|
|
935
936
|
return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
|
|
936
937
|
except Exception as e:
|
|
937
938
|
return "MCP 工具执行失败: %s" % e
|
|
@@ -1983,6 +1984,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1983
1984
|
disabled_tools=disabled_tools, sandbox=sandbox,
|
|
1984
1985
|
checkpoint_run_id=checkpoint_run_id,
|
|
1985
1986
|
tool_output_run_id=tool_output_run_id)
|
|
1987
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1988
|
+
return _deadline_fail()
|
|
1989
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1990
|
+
return _cancel_fail()
|
|
1986
1991
|
# 运行期注入警示层:外部内容(网页/命令输出/文件)一律
|
|
1987
1992
|
# 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
|
|
1988
1993
|
try:
|
package/app/core/checkpoints.py
CHANGED
|
@@ -40,6 +40,20 @@ def _hash(value):
|
|
|
40
40
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _side_effects(value):
|
|
44
|
+
"""Normalize side-effect labels without persisting arbitrary tool output."""
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
value = [value]
|
|
47
|
+
if not isinstance(value, (list, tuple, set)):
|
|
48
|
+
return []
|
|
49
|
+
out = []
|
|
50
|
+
for item in value:
|
|
51
|
+
label = str(item or "").strip()[:80]
|
|
52
|
+
if label and label not in out:
|
|
53
|
+
out.append(label)
|
|
54
|
+
return out[:20]
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
def _append(row):
|
|
44
58
|
_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
59
|
with _file().open("a", encoding="utf-8") as handle:
|
|
@@ -48,7 +62,8 @@ def _append(row):
|
|
|
48
62
|
|
|
49
63
|
|
|
50
64
|
def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
|
|
51
|
-
idempotency_key=""
|
|
65
|
+
idempotency_key="", tool_call=None, conversation_ref="",
|
|
66
|
+
side_effects=None):
|
|
52
67
|
"""Record a step start without persisting prompt contents."""
|
|
53
68
|
now = time.time()
|
|
54
69
|
try:
|
|
@@ -61,6 +76,14 @@ def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
|
|
|
61
76
|
"attempt": attempt, "idempotency_key": str(idempotency_key or "")[:160],
|
|
62
77
|
"prompt_sha256": _hash(prompt), "input_sha256": _hash(input_data),
|
|
63
78
|
"created_at": now}
|
|
79
|
+
if tool_call is not None:
|
|
80
|
+
row["tool_call_digest"] = _hash(tool_call)
|
|
81
|
+
if conversation_ref:
|
|
82
|
+
row["conversation_ref"] = str(conversation_ref)[:200]
|
|
83
|
+
normalized_effects = _side_effects(side_effects)
|
|
84
|
+
if normalized_effects:
|
|
85
|
+
row["side_effects"] = normalized_effects
|
|
86
|
+
row["repropose_required"] = bool(tool_call is not None or normalized_effects)
|
|
64
87
|
with _LOCK:
|
|
65
88
|
try:
|
|
66
89
|
_append(row)
|
|
@@ -73,13 +96,24 @@ def finish(run_id, step, status, *, error="", output=None, metadata=None):
|
|
|
73
96
|
status = str(status or "unknown").strip().lower()
|
|
74
97
|
if status not in _TERMINAL:
|
|
75
98
|
status = "unknown"
|
|
99
|
+
safe_metadata = {}
|
|
100
|
+
for key, value in (metadata or {}).items():
|
|
101
|
+
name = str(key)[:40]
|
|
102
|
+
if name == "side_effects":
|
|
103
|
+
safe_metadata[name] = _side_effects(value)
|
|
104
|
+
elif name == "repropose_required":
|
|
105
|
+
safe_metadata[name] = bool(value)
|
|
106
|
+
elif isinstance(value, (str, int, float, bool)):
|
|
107
|
+
safe_metadata[name] = value
|
|
76
108
|
row = {"event_id": "cp-" + secrets.token_hex(10), "event": "finish",
|
|
77
109
|
"run_id": str(run_id or "")[:80], "step": int(step),
|
|
78
110
|
"status": status, "error": scrub_text(error, limit=500),
|
|
79
|
-
"output_sha256": _hash(output), "metadata":
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
"output_sha256": _hash(output), "metadata": safe_metadata,
|
|
112
|
+
"created_at": time.time()}
|
|
113
|
+
if "side_effects" in safe_metadata:
|
|
114
|
+
row["side_effects"] = safe_metadata["side_effects"]
|
|
115
|
+
if "repropose_required" in safe_metadata:
|
|
116
|
+
row["repropose_required"] = safe_metadata["repropose_required"]
|
|
83
117
|
with _LOCK:
|
|
84
118
|
try:
|
|
85
119
|
_append(row)
|
|
@@ -135,6 +169,15 @@ def replay_preview(run_id):
|
|
|
135
169
|
if key in starts:
|
|
136
170
|
item.setdefault("prompt_sha256", starts[key].get("prompt_sha256"))
|
|
137
171
|
item.setdefault("attempt", starts[key].get("attempt", 1))
|
|
172
|
+
metadata = item.get("metadata") or {}
|
|
173
|
+
for field in ("tool_call_digest", "conversation_ref", "side_effects",
|
|
174
|
+
"repropose_required"):
|
|
175
|
+
if field not in item and field in metadata:
|
|
176
|
+
item[field] = metadata[field]
|
|
177
|
+
item.setdefault("side_effects", [])
|
|
178
|
+
item["repropose_required"] = bool(
|
|
179
|
+
item.get("repropose_required") or item.get("side_effects")
|
|
180
|
+
or item.get("tool_call_digest"))
|
|
138
181
|
steps.append(item)
|
|
139
182
|
unknown = next((x for x in steps if x.get("status") in ("unknown", "running")
|
|
140
183
|
or x.get("event") != "finish"
|
package/app/core/jobs.py
CHANGED
|
@@ -314,6 +314,13 @@ def cancel_restart_drain():
|
|
|
314
314
|
_restart_drain = False
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
def begin_shutdown_drain():
|
|
318
|
+
"""Stop accepting work before a confirmed desktop shutdown cancels runs."""
|
|
319
|
+
global _restart_drain
|
|
320
|
+
with _pool_lock:
|
|
321
|
+
_restart_drain = True
|
|
322
|
+
|
|
323
|
+
|
|
317
324
|
def _close_unstarted(job, message, statuses=("queued",)):
|
|
318
325
|
"""无法启动时统一收口 run/task,供所有入口复用。"""
|
|
319
326
|
try:
|
package/app/core/manager.py
CHANGED
|
@@ -2374,6 +2374,7 @@ def catalog_view():
|
|
|
2374
2374
|
"model": read_model(e),
|
|
2375
2375
|
"orch_kind": (e.get("orch") or {}).get("kind"),
|
|
2376
2376
|
"orch_enabled": orch_enabled,
|
|
2377
|
+
"capabilities": registry.capability_manifest(e),
|
|
2377
2378
|
"update": update_info(e),
|
|
2378
2379
|
"has_install": bool(e.get("install")),
|
|
2379
2380
|
"has_upgrade": bool(e.get("upgrade")),
|
|
@@ -110,18 +110,29 @@ def _cache_file(source_id):
|
|
|
110
110
|
|
|
111
111
|
# ---------------------------------------------------------------- 网络边界
|
|
112
112
|
|
|
113
|
+
def _reject_url_userinfo(url):
|
|
114
|
+
"""拒绝 URL authority 中的凭据;错误不回显可能含令牌的原始 URL。"""
|
|
115
|
+
parsed = urlparse(url or "")
|
|
116
|
+
if parsed.username is not None or parsed.password is not None:
|
|
117
|
+
raise ValueError("URL 不允许携带用户信息")
|
|
118
|
+
return parsed
|
|
119
|
+
|
|
120
|
+
|
|
113
121
|
def assert_public_url(url):
|
|
114
122
|
"""SSRF 防护:仅 https;host 解析出的所有 IP 都不得是环回/私有/保留地址。
|
|
115
123
|
返回 (host, port),不合法直接抛 ValueError。"""
|
|
116
|
-
u =
|
|
124
|
+
u = _reject_url_userinfo(url)
|
|
117
125
|
if u.scheme != "https":
|
|
118
|
-
raise ValueError("仅允许 https
|
|
126
|
+
raise ValueError("仅允许 https 地址")
|
|
119
127
|
host = u.hostname
|
|
120
128
|
if not host:
|
|
121
|
-
raise ValueError("URL
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
raise ValueError("URL 缺少主机名")
|
|
130
|
+
try:
|
|
131
|
+
port = u.port or 443
|
|
132
|
+
except ValueError:
|
|
133
|
+
raise ValueError("端口非法") from None
|
|
134
|
+
if not (0 < port < 65536):
|
|
135
|
+
raise ValueError("端口非法")
|
|
125
136
|
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
126
137
|
if not infos:
|
|
127
138
|
raise ValueError("主机无法解析: %s" % host)
|
|
@@ -856,7 +867,7 @@ def _download_git(entry, tmp):
|
|
|
856
867
|
text=True, encoding="utf-8", errors="replace",
|
|
857
868
|
env=env)
|
|
858
869
|
except subprocess.TimeoutExpired:
|
|
859
|
-
raise ValueError("git
|
|
870
|
+
raise ValueError("git 克隆超时")
|
|
860
871
|
if proc.returncode == 0:
|
|
861
872
|
break
|
|
862
873
|
err = (proc.stderr or "").strip()[-200:]
|
|
@@ -875,6 +886,7 @@ def _download_git(entry, tmp):
|
|
|
875
886
|
def _download_git_any(entry, tmp):
|
|
876
887
|
"""git 类来源安装:codeload tarball 优先(完整、最新)→ jsdelivr 逐文件
|
|
877
888
|
兜底(codeload 不可达时)→ git 浅克隆收尾(非 github 托管 / 精确 ref)。"""
|
|
889
|
+
_reject_url_userinfo(entry["install"].get("url"))
|
|
878
890
|
try:
|
|
879
891
|
return _gh_tarball(entry, tmp)
|
|
880
892
|
except ValueError as e:
|
package/app/core/mcp_client.py
CHANGED
|
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import json
|
|
20
20
|
import hashlib
|
|
21
21
|
import os
|
|
22
|
+
import queue
|
|
22
23
|
import re
|
|
23
24
|
import shutil
|
|
24
25
|
import subprocess
|
|
@@ -28,6 +29,7 @@ import time
|
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
31
|
_LOCK = threading.RLock()
|
|
32
|
+
_REFRESH_LOCK = threading.Lock()
|
|
31
33
|
_TOOLS_CACHE = {} # server_key → (ts, tools)
|
|
32
34
|
_TOOLS_TTL = 300.0
|
|
33
35
|
_TOOLS_CONFIG_SHA = ""
|
|
@@ -35,6 +37,7 @@ _TOOLS_CONFIG_SHA = ""
|
|
|
35
37
|
MAX_SERVERS = 4
|
|
36
38
|
MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
|
|
37
39
|
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
|
|
40
|
+
_MODEL_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
38
41
|
_INIT_TIMEOUT = 15.0
|
|
39
42
|
_CALL_TIMEOUT = 60.0
|
|
40
43
|
_CALL_TIMEOUT_MAX = 300.0
|
|
@@ -64,6 +67,7 @@ def parse_servers(text):
|
|
|
64
67
|
if len(arr) > MAX_SERVERS:
|
|
65
68
|
return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
|
|
66
69
|
out = []
|
|
70
|
+
names = set()
|
|
67
71
|
for item in arr:
|
|
68
72
|
if not isinstance(item, dict):
|
|
69
73
|
return None, "MCP 服务器配置必须是对象"
|
|
@@ -71,6 +75,11 @@ def parse_servers(text):
|
|
|
71
75
|
command = str(item.get("command") or "").strip()
|
|
72
76
|
if not _NAME_RE.match(name):
|
|
73
77
|
return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
|
|
78
|
+
if "__" in name:
|
|
79
|
+
return None, "服务器名 %s 不能包含工具分隔符 __" % name
|
|
80
|
+
if name in names:
|
|
81
|
+
return None, "MCP 服务器名 %s 重名" % name
|
|
82
|
+
names.add(name)
|
|
74
83
|
if not command:
|
|
75
84
|
return None, "服务器 %s 缺 command" % name
|
|
76
85
|
args = item.get("args") if isinstance(item.get("args"), list) else []
|
|
@@ -152,12 +161,16 @@ def _bubblewrap_argv(server, sandbox, roots, cwd):
|
|
|
152
161
|
class _Session:
|
|
153
162
|
"""一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
|
|
154
163
|
|
|
155
|
-
def __init__(self, server, sandbox=None, workdir=None, timeout_s=None
|
|
164
|
+
def __init__(self, server, sandbox=None, workdir=None, timeout_s=None,
|
|
165
|
+
cancel_event=None, deadline=None):
|
|
156
166
|
self.server = server
|
|
157
167
|
self.sandbox = sandbox
|
|
158
168
|
self.workdir = workdir
|
|
159
169
|
self.timeout_s = timeout_s
|
|
160
|
-
self.deadline =
|
|
170
|
+
self.deadline = deadline
|
|
171
|
+
self.task_deadline = deadline
|
|
172
|
+
self.cancel_event = cancel_event
|
|
173
|
+
self._nest = None
|
|
161
174
|
self.proc = None
|
|
162
175
|
self.lines = None # reader 线程产出的行队列
|
|
163
176
|
self.err = None
|
|
@@ -190,7 +203,9 @@ class _Session:
|
|
|
190
203
|
|
|
191
204
|
def __enter__(self):
|
|
192
205
|
if self.timeout_s is not None:
|
|
193
|
-
|
|
206
|
+
call_deadline = time.monotonic() + max(0.0, float(self.timeout_s))
|
|
207
|
+
self.deadline = min(self.deadline, call_deadline) if self.deadline is not None else call_deadline
|
|
208
|
+
self._check_interrupted()
|
|
194
209
|
try:
|
|
195
210
|
argv, env, cwd = _sandbox_launch(self.server, self.sandbox, self.workdir)
|
|
196
211
|
if self.deadline is not None and time.monotonic() >= self.deadline:
|
|
@@ -199,18 +214,22 @@ class _Session:
|
|
|
199
214
|
argv,
|
|
200
215
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
201
216
|
stderr=subprocess.PIPE, text=False,
|
|
202
|
-
env=env, cwd=cwd, start_new_session=
|
|
217
|
+
env=env, cwd=cwd, start_new_session=(os.name != "nt"))
|
|
203
218
|
except Exception as e:
|
|
204
219
|
raise RuntimeError("MCP 服务器启动失败: %s" % e)
|
|
205
|
-
import queue as _queue
|
|
206
|
-
self.lines = _queue.Queue()
|
|
207
|
-
self._reader_threads = [
|
|
208
|
-
threading.Thread(target=self._pump_out, daemon=True),
|
|
209
|
-
threading.Thread(target=self._pump_err, daemon=True),
|
|
210
|
-
]
|
|
211
|
-
for thread in self._reader_threads:
|
|
212
|
-
thread.start()
|
|
213
220
|
try:
|
|
221
|
+
import queue as _queue
|
|
222
|
+
self.lines = _queue.Queue()
|
|
223
|
+
self._reader_threads = [
|
|
224
|
+
threading.Thread(target=self._pump_out, daemon=True),
|
|
225
|
+
threading.Thread(target=self._pump_err, daemon=True),
|
|
226
|
+
]
|
|
227
|
+
for thread in self._reader_threads:
|
|
228
|
+
thread.start()
|
|
229
|
+
if os.name == "nt":
|
|
230
|
+
from . import beekeeper
|
|
231
|
+
self._nest = beekeeper.BeeNest()
|
|
232
|
+
self._nest.adopt(self.proc)
|
|
214
233
|
init_timeout = _INIT_TIMEOUT
|
|
215
234
|
if self.deadline is not None:
|
|
216
235
|
init_timeout = min(init_timeout, self.deadline - time.monotonic())
|
|
@@ -220,57 +239,48 @@ class _Session:
|
|
|
220
239
|
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
221
240
|
"clientInfo": _CLIENT_INFO}, timeout=init_timeout)
|
|
222
241
|
self._notify("notifications/initialized")
|
|
223
|
-
except
|
|
242
|
+
except BaseException:
|
|
224
243
|
# Context manager __exit__ is not called when __enter__ raises.
|
|
225
244
|
self.__exit__(None, None, None)
|
|
226
245
|
raise
|
|
227
246
|
return self
|
|
228
247
|
|
|
229
248
|
def __exit__(self, *exc):
|
|
249
|
+
proc = self.proc
|
|
250
|
+
if proc is None:
|
|
251
|
+
return False
|
|
230
252
|
try:
|
|
231
|
-
if self.
|
|
253
|
+
if self._nest is not None:
|
|
254
|
+
self._nest.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
if os.name != "nt" or proc.poll() is None:
|
|
258
|
+
try:
|
|
259
|
+
from . import runner
|
|
260
|
+
runner._kill_tree(proc.pid)
|
|
261
|
+
except Exception:
|
|
232
262
|
try:
|
|
233
|
-
|
|
263
|
+
proc.kill()
|
|
234
264
|
except Exception:
|
|
235
265
|
pass
|
|
266
|
+
try:
|
|
267
|
+
proc.wait(timeout=2)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
if proc.stdin is not None:
|
|
236
271
|
try:
|
|
237
|
-
|
|
238
|
-
# bwrap runs in its own process group; terminate descendants
|
|
239
|
-
# as well as the launcher so timed-out MCP servers cannot
|
|
240
|
-
# leave background children holding pipes or doing work.
|
|
241
|
-
try:
|
|
242
|
-
from . import runner
|
|
243
|
-
runner._kill_tree(self.proc.pid)
|
|
244
|
-
except Exception:
|
|
245
|
-
pass
|
|
246
|
-
self.proc.kill()
|
|
247
|
-
except Exception:
|
|
248
|
-
pass
|
|
249
|
-
try:
|
|
250
|
-
if self.proc:
|
|
251
|
-
self.proc.wait(timeout=1)
|
|
272
|
+
proc.stdin.close()
|
|
252
273
|
except Exception:
|
|
253
274
|
pass
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
# on EOF and close only streams no thread currently owns.
|
|
260
|
-
for thread in self._reader_threads:
|
|
275
|
+
for thread, pipe in zip(self._reader_threads, (proc.stdout, proc.stderr)):
|
|
276
|
+
if thread is not None and thread.is_alive():
|
|
277
|
+
thread.join(timeout=0.5)
|
|
278
|
+
# 读线程持有管道锁时主线程 close 可能卡到孙进程自然退出。
|
|
279
|
+
if pipe is not None and (thread is None or not thread.is_alive()):
|
|
261
280
|
try:
|
|
262
|
-
|
|
281
|
+
pipe.close()
|
|
263
282
|
except Exception:
|
|
264
283
|
pass
|
|
265
|
-
for name in ("stdout", "stderr"):
|
|
266
|
-
pipe = getattr(self.proc, name, None) if self.proc else None
|
|
267
|
-
readers_alive = any(
|
|
268
|
-
thread.is_alive() for thread in self._reader_threads)
|
|
269
|
-
if pipe and not readers_alive:
|
|
270
|
-
try:
|
|
271
|
-
pipe.close()
|
|
272
|
-
except Exception:
|
|
273
|
-
pass
|
|
274
284
|
return False
|
|
275
285
|
|
|
276
286
|
def _send(self, obj):
|
|
@@ -281,8 +291,15 @@ class _Session:
|
|
|
281
291
|
def _notify(self, method, params=None):
|
|
282
292
|
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
283
293
|
|
|
294
|
+
def _check_interrupted(self):
|
|
295
|
+
if self.cancel_event is not None and self.cancel_event.is_set():
|
|
296
|
+
raise RuntimeError("任务已取消")
|
|
297
|
+
if self.task_deadline is not None and time.monotonic() >= self.task_deadline:
|
|
298
|
+
raise RuntimeError("任务总时限已到")
|
|
299
|
+
|
|
284
300
|
def _request(self, method, params, timeout):
|
|
285
301
|
"""单请求单响应;忽略通知与未知 id 的服务端请求。"""
|
|
302
|
+
self._check_interrupted()
|
|
286
303
|
duration = max(0.0, float(timeout))
|
|
287
304
|
if self.deadline is not None:
|
|
288
305
|
duration = min(duration, self.deadline - time.monotonic())
|
|
@@ -293,13 +310,15 @@ class _Session:
|
|
|
293
310
|
"method": method, "params": params or {}})
|
|
294
311
|
deadline = time.monotonic() + duration
|
|
295
312
|
while True:
|
|
313
|
+
self._check_interrupted()
|
|
296
314
|
remain = deadline - time.monotonic()
|
|
297
315
|
if remain <= 0:
|
|
298
316
|
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
299
317
|
try:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
318
|
+
wait = min(remain, 0.1) if self.cancel_event is not None else remain
|
|
319
|
+
line = self.lines.get(timeout=wait)
|
|
320
|
+
except queue.Empty:
|
|
321
|
+
continue
|
|
303
322
|
if line is None:
|
|
304
323
|
raise RuntimeError("MCP 服务器提前退出")
|
|
305
324
|
line = line.strip()
|
|
@@ -327,10 +346,11 @@ def list_tools(server, *, sandbox=None, workdir=None):
|
|
|
327
346
|
tools = []
|
|
328
347
|
for t in (res.get("tools") or [])[:16]:
|
|
329
348
|
if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
|
|
349
|
+
schema = t.get("inputSchema", t.get("input_schema"))
|
|
330
350
|
tools.append({"name": t["name"],
|
|
331
351
|
"description": str(t.get("description") or "")[:400],
|
|
332
|
-
"input_schema":
|
|
333
|
-
if isinstance(
|
|
352
|
+
"input_schema": schema
|
|
353
|
+
if isinstance(schema, dict) else
|
|
334
354
|
{"type": "object", "properties": {}}})
|
|
335
355
|
return {"ok": True, "tools": tools, "error": ""}
|
|
336
356
|
except Exception as e:
|
|
@@ -338,12 +358,13 @@ def list_tools(server, *, sandbox=None, workdir=None):
|
|
|
338
358
|
|
|
339
359
|
|
|
340
360
|
def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
|
|
341
|
-
*, sandbox=None, workdir=None):
|
|
361
|
+
*, sandbox=None, workdir=None, cancel_event=None, deadline=None):
|
|
342
362
|
"""调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
|
|
343
363
|
try:
|
|
344
364
|
timeout = min(_CALL_TIMEOUT_MAX, max(0.001, float(timeout_s or _CALL_TIMEOUT)))
|
|
345
365
|
with _Session(server, sandbox=sandbox, workdir=workdir,
|
|
346
|
-
timeout_s=timeout
|
|
366
|
+
timeout_s=timeout, cancel_event=cancel_event,
|
|
367
|
+
deadline=deadline) as sess:
|
|
347
368
|
res = sess._request("tools/call",
|
|
348
369
|
{"name": tool_name,
|
|
349
370
|
"arguments": arguments if isinstance(arguments, dict) else {}},
|
|
@@ -364,6 +385,12 @@ def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
|
|
|
364
385
|
# ---- 面向 builtin_agent 的合并视图(带缓存) ----
|
|
365
386
|
|
|
366
387
|
def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
388
|
+
"""Serialize discoveries so callers cannot observe incomplete refreshes."""
|
|
389
|
+
with _REFRESH_LOCK:
|
|
390
|
+
return _tool_specs_cached(force=force, sandbox=sandbox, workdir=workdir)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
367
394
|
"""全部服务器的工具合并清单:[{server, name(原名), full_name, description,
|
|
368
395
|
input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
|
|
369
396
|
global _TOOLS_CONFIG_SHA
|
|
@@ -398,8 +425,8 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
|
398
425
|
disabled = set(policy.normalize_disabled_tools(
|
|
399
426
|
(sandbox or {}).get("disabled_tools")))
|
|
400
427
|
out = []
|
|
401
|
-
|
|
402
|
-
|
|
428
|
+
discovered = {}
|
|
429
|
+
seen_names = set()
|
|
403
430
|
for srv in servers:
|
|
404
431
|
res = list_tools(srv, sandbox=sandbox, workdir=workdir)
|
|
405
432
|
if not res.get("ok"):
|
|
@@ -410,25 +437,36 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
|
410
437
|
if (t["name"] in srv.get("disabled_tools", [])
|
|
411
438
|
or t["name"] in disabled or full_name in disabled):
|
|
412
439
|
continue
|
|
440
|
+
if not _MODEL_TOOL_NAME_RE.fullmatch(full_name) or full_name in seen_names:
|
|
441
|
+
continue
|
|
442
|
+
seen_names.add(full_name)
|
|
413
443
|
specs.append({"server": srv["name"], "name": t["name"],
|
|
414
444
|
"full_name": full_name,
|
|
415
445
|
"description": t["description"],
|
|
416
446
|
"input_schema": t["input_schema"]})
|
|
417
|
-
|
|
418
|
-
_TOOLS_CACHE[(scope_sha, srv["name"])] = (time.time(), specs)
|
|
447
|
+
discovered[(scope_sha, srv["name"])] = (time.time(), specs)
|
|
419
448
|
out.extend(specs)
|
|
420
449
|
if len(out) >= MAX_MCP_TOOLS:
|
|
421
450
|
break
|
|
451
|
+
with _LOCK:
|
|
452
|
+
if config_sha != _TOOLS_CONFIG_SHA:
|
|
453
|
+
return []
|
|
454
|
+
for key in list(_TOOLS_CACHE):
|
|
455
|
+
if isinstance(key, tuple) and key[0] == scope_sha:
|
|
456
|
+
del _TOOLS_CACHE[key]
|
|
457
|
+
_TOOLS_CACHE.update(discovered)
|
|
422
458
|
return out[:MAX_MCP_TOOLS]
|
|
423
459
|
|
|
424
460
|
|
|
425
461
|
def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
|
|
426
|
-
sandbox=None, workdir=None, disabled_tools=None
|
|
462
|
+
sandbox=None, workdir=None, disabled_tools=None,
|
|
463
|
+
cancel_event=None, deadline=None):
|
|
427
464
|
"""mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
|
|
428
|
-
|
|
429
|
-
if
|
|
465
|
+
full_name = str(full_name or "")
|
|
466
|
+
sname, sep, tool = full_name[5:].partition("__") if full_name.startswith("mcp__") \
|
|
467
|
+
else ("", "", "")
|
|
468
|
+
if not sep or not _NAME_RE.fullmatch(sname) or "__" in sname or not tool:
|
|
430
469
|
return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
|
|
431
|
-
sname, tool = m.group(1), m.group(2)
|
|
432
470
|
servers, err = parse_servers(_settings_text())
|
|
433
471
|
if err:
|
|
434
472
|
return {"ok": False, "text": "", "error": err}
|
|
@@ -443,7 +481,8 @@ def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
|
|
|
443
481
|
if sandbox is None or not workdir:
|
|
444
482
|
return {"ok": False, "text": "", "error": "MCP 调用缺少任务沙箱策略或工作目录,拒绝启动"}
|
|
445
483
|
return call_tool(srv, tool, arguments, timeout_s=timeout_s,
|
|
446
|
-
sandbox=sandbox, workdir=workdir
|
|
484
|
+
sandbox=sandbox, workdir=workdir,
|
|
485
|
+
cancel_event=cancel_event, deadline=deadline)
|
|
447
486
|
|
|
448
487
|
|
|
449
488
|
_SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块
|