codebee 0.1.95 → 0.1.97

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,1680 +1,2055 @@
1
- # -*- coding: utf-8 -*-
2
- """内置执行智能体:不经外部 CLI,直连供应商 API 完成直连(direct)任务。
3
-
4
- 为什么:direct 是快档位,典型诉求是问答/轻量改写。拉起 codex/claude CLI 要付
5
- 进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
6
- 对话视图。内置智能体直接打 chat API + 工具循环:
7
- - 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
8
- - 工具:list_files / read_file / write_file(锁死工作目录内)+ run_command
9
- (本机全权执行命令,产品定位全信任,与 codex/claude 全权沙箱同档);
10
- - 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
11
- 密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
12
- """
13
- from __future__ import annotations
14
-
15
- import base64
16
- import fnmatch
17
- import io
18
- import json
19
- import mimetypes
20
- import os
21
- import re
22
- import shutil
23
- import threading
24
- import time
25
- import urllib.parse
26
- import urllib.request
27
- from pathlib import Path
28
-
29
- from . import modelhub, runner, tlsctx
30
-
31
- MAX_TOOL_ITERS = 32 # 单步工具循环上限(防模型打转;收尾另留 1 轮)
32
- READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
33
- READ_HEAD_BYTES = 44 * 1024 # TokenJuice 借鉴(openhuman):超限文件头尾保留、
34
- READ_TAIL_BYTES = 16 * 1024 # 中段省略——日志/代码的报错常在尾部,纯截头会丢关键信息
35
- LIST_MAX_ENTRIES = 200
36
- MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体防爆)
37
- IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
38
- IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
39
- IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
40
- THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
41
- STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
42
- BASE_MAX_TOKENS = 8000 # 单次生成输出预算(推理模型的思考 token 也计入此额度)
43
- MAX_TOKENS_CAP = 16000 # 思考撑爆预算后的提额上限(每轮 run 只提一次)
44
- THINK_EXHAUST_MIN_CHARS = 1500 # 零正文时思考达到此长度 ≈ 思考占满输出预算
45
- EMPTY_TEXT_MAX_STREAK = 3 # 连续零正文轮数上限(防同参数盲重试烧穿预算)
46
-
47
- CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
48
- CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
49
- CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
50
- CMD_OUT_TAIL = 8 * 1024
51
-
52
- _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
53
-
54
- ## 工作方式
55
- - 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
56
- - 改文件的优先级:小改动用 edit_file(精确查找替换,不必整份重写)>追加用 append_file>整份重建才用 write_file。删除/移动/重命名/复制用 fs_manage,不要绕 shell。
57
- - 大文件(超 64KB)默认只读头尾、中段省略;要看全用 read_file 的 offset/lines 分段读(返回头会提示下一段的 offset)。找内容用 search_content(带行号的 grep),找文件用 find_files,不要逐个文件读。
58
- - read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
59
- - 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
60
- - run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
61
- - 当工具列表中提供 create_task 时,如果用户明确要求开始另一项 CodeBee 工作(例如“新建连载小说任务”“再开一个代码任务”),调用它发起独立任务;不要只在当前回答里描述应该怎么做。先从用户话里提取任务类型和目标,缺少关键目标时再追问。
62
- - Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
63
- - 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
64
- - 产出文件一律 UTF-8 编码。
65
- - 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。
66
- - 核心产出完成后立即停止探索并总结;不要为“再确认一次”重复读取或重复写入。
67
- - 每次工具调用后都要判断是否已经达到目标;如果工具轮次即将耗尽,先完成已开始的操作,随后直接给出已完成内容、产出文件和未完成项。
68
- """
69
-
70
-
71
- def resolve(provider_id="", model="", difficulty="default"):
72
- """选内置智能体可用的 (provider, model):编排者配置优先,否则首个可用供应商。
73
-
74
- 返回 {prov, model, provider_id, provider_name} 或 None(无可用供应商 → 调用方
75
- 回退 CLI 路径)。"""
76
- provider_id = str(provider_id or "").strip()
77
- model = str(model or "").strip()
78
- if provider_id:
79
- for p in modelhub.providers():
80
- if p.get("id") != provider_id or not p.get("enabled", True) or not p.get("api_key"):
81
- continue
82
- selected = model
83
- if selected:
84
- try:
85
- enabled_names = [m.get("name") for m in modelhub._enabled_models(p)]
86
- except Exception:
87
- enabled_names = []
88
- if selected not in enabled_names:
89
- return None
90
- if not selected:
91
- selected = (p.get("model_hard") if difficulty == "hard" else
92
- p.get("model_easy") if difficulty == "easy" else
93
- p.get("model") or "").strip()
94
- if not selected:
95
- try:
96
- names = modelhub._enabled_models(p)
97
- except Exception:
98
- names = []
99
- selected = names[0]["name"] if names else ""
100
- if selected:
101
- return {"prov": p, "model": selected,
102
- "provider_id": p.get("id") or "",
103
- "provider_name": p.get("name") or p.get("id") or ""}
104
- return None
105
- orch = None
106
- try:
107
- orch = modelhub.resolve_orchestrator()
108
- except Exception:
109
- orch = None
110
- if orch:
111
- prov, model = orch
112
- return {"prov": prov, "model": model,
113
- "provider_id": prov.get("id") or "",
114
- "provider_name": prov.get("name") or prov.get("id") or ""}
115
- for p in modelhub.providers():
116
- if not p.get("enabled", True) or not p.get("api_key"):
117
- continue
118
- m = ((p.get("model_hard") if difficulty == "hard" else
119
- p.get("model_easy") if difficulty == "easy" else
120
- p.get("model")) or "").strip()
121
- if not m:
122
- try:
123
- names = modelhub._enabled_models(p)
124
- except Exception:
125
- names = []
126
- m = names[0]["name"] if names else ""
127
- if m:
128
- return {"prov": p, "model": m, "provider_id": p.get("id") or "",
129
- "provider_name": p.get("name") or p.get("id") or ""}
130
- return None
131
-
132
-
133
- # ---------------------------------------------------------------- 工作目录工具
134
- # 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
135
- # 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
136
-
137
- def _tool_list_files(workdir, args):
138
- rel = str(args.get("path") or "").replace("\\", "/").strip("/")
139
- if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
140
- return "(非法路径: %s)" % rel
141
- base = Path(workdir or ".").resolve()
142
- p = (base / rel).resolve() if rel else base
143
- if base not in p.parents and p != base:
144
- return "(路径越界: %s)" % rel
145
- if not p.is_dir():
146
- return "(目录不存在: %s)" % (rel or ".")
147
- out = []
148
- for root, dirs, files in os.walk(str(p)):
149
- dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")]
150
- for f in files:
151
- fp = os.path.join(root, f)
152
- relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
153
- try:
154
- size = os.path.getsize(fp)
155
- except OSError:
156
- size = -1
157
- out.append("%s (%d B)" % (relp, size))
158
- if len(out) >= LIST_MAX_ENTRIES:
159
- return "\n".join(out) + "\n…(截断,共 200+ 项)"
160
- return "\n".join(out) or "(空目录)"
161
-
162
-
163
- _BIN_MAGIC = (
164
- (b"\x89PNG\r\n\x1a\n", "PNG 图片"),
165
- (b"\xff\xd8\xff", "JPEG 图片"),
166
- (b"GIF8", "GIF 图片"),
167
- (b"%PDF-", "PDF 文档"),
168
- (b"PK\x03\x04", "ZIP 压缩包"),
169
- (b"\x1f\x8b", "GZIP 压缩包"),
170
- (b"7z\xbc\xaf\x27\x1c", "7z 压缩包"),
171
- (b"Rar!\x1a\x07", "RAR 压缩包"),
172
- (b"\x00\x00\x01\x00", "ICO 图标"),
173
- (b"OggS", "OGG 音频"),
174
- (b"ID3", "MP3 音频"),
175
- )
176
-
177
-
178
- def _bin_format(head):
179
- """常见二进制魔数 → 人类可读格式名(写进给模型的提示,让它立刻止损)。"""
180
- if len(head) >= 12 and head[:4] == b"RIFF":
181
- return {"WEBP": "WEBP 图片", "WAVE": "WAV 音频",
182
- "AVI ": "AVI 视频"}.get(head[8:12].decode("ascii", "replace"),
183
- "RIFF 媒体")
184
- if len(head) >= 8 and head[4:8] == b"ftyp":
185
- return "MP4/MOV 视频"
186
- for magic, name in _BIN_MAGIC:
187
- if head.startswith(magic):
188
- return name
189
- return "二进制文件"
190
-
191
-
192
- def _looks_binary(head):
193
- """文本判定从宽:NUL 字节即判二进制(git 同款启发式),控制字符占比兜底
194
- (部分二进制头 8KB 内无 NUL)。"""
195
- if not head:
196
- return False
197
- if b"\x00" in head:
198
- return True
199
- sample = head[:8192]
200
- ctrl = sum(1 for b in sample if b < 32 and b not in (9, 10, 12, 13))
201
- return ctrl * 20 > len(sample) # >5% 视为二进制
202
-
203
-
204
- def _trim_partial_utf8(chunk):
205
- """头部截断可能把多字节字符切成两半:剥掉尾部不完整序列,否则 UTF-8
206
- strict 失败会误落 GBK 解出错字(runner.tail_decoded 的头截断对偶)。"""
207
- i = len(chunk) - 1
208
- while i >= 0 and i >= len(chunk) - 3 and (chunk[i] & 0xC0) == 0x80:
209
- i -= 1
210
- if 0 <= i < len(chunk):
211
- b = chunk[i]
212
- need = 4 if (b & 0xF8) == 0xF0 else 3 if (b & 0xF0) == 0xE0 \
213
- else 2 if (b & 0xC0) == 0xC0 else 1
214
- if len(chunk) - i < need:
215
- return chunk[:i]
216
- return chunk
217
-
218
-
219
- def _guess_image_mime(path, raw=b""):
220
- m = mimetypes.guess_type(str(path))[0]
221
- if m:
222
- return m
223
- if raw.startswith(b"\x89PNG"):
224
- return "image/png"
225
- if raw.startswith(b"\xff\xd8"):
226
- return "image/jpeg"
227
- if raw.startswith(b"GIF8"):
228
- return "image/gif"
229
- if raw.startswith(b"RIFF"):
230
- return "image/webp"
231
- return "image/png"
232
-
233
-
234
- def _prep_images(paths, log=None):
235
- """图片路径 → [(mime, b64)]。Pillow 可用时:长边超 1568px 等比缩(万级像素
236
- 原图既烧 token 又常被网关拒),仍超 3.5MB 转 JPEG q85;Pillow 缺失或解码
237
- 失败时原图直传,单张超 5MB 剔除并日志。最多 MAX_IMAGES 张。"""
238
- try:
239
- from PIL import Image
240
- except Exception:
241
- Image = None
242
- out = []
243
- for p in (paths or [])[:MAX_IMAGES]:
244
- try:
245
- raw = Path(p).read_bytes()
246
- except OSError:
247
- if log:
248
- log("[图片] 跳过(读取失败): %s" % os.path.basename(str(p)))
249
- continue
250
- mime = _guess_image_mime(p, raw)
251
- data = raw
252
- if Image is not None:
253
- try:
254
- im = Image.open(io.BytesIO(raw))
255
- im.load()
256
- w, h = im.size
257
- edge = max(w, h, 1)
258
- if edge > IMAGE_MAX_EDGE:
259
- r = IMAGE_MAX_EDGE / float(edge)
260
- im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
261
- Image.LANCZOS)
262
- fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
263
- "image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
264
- buf = io.BytesIO()
265
- im.save(buf, format=fmt) # GIF 多帧仅存首帧,可接受
266
- data = buf.getvalue()
267
- except Exception:
268
- data = raw # 解码/编码失败退回原始字节
269
- if (Image is not None and mime != "image/jpeg"
270
- and len(data) > IMAGE_JPEG_BYTES):
271
- try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
272
- im = Image.open(io.BytesIO(data)).convert("RGB")
273
- buf = io.BytesIO()
274
- im.save(buf, format="JPEG", quality=85)
275
- data = buf.getvalue()
276
- mime = "image/jpeg"
277
- except Exception:
278
- pass
279
- if len(data) > IMAGE_MAX_BYTES:
280
- if log:
281
- log("[图片] 剔除(过大 %d MB): %s"
282
- % (len(data) // (1024 * 1024), os.path.basename(str(p))))
283
- continue
284
- out.append((mime, base64.b64encode(data).decode("ascii")))
285
- return out
286
-
287
-
288
- def _tool_read_file(workdir, args):
289
- rel = str(args.get("path") or "").replace("\\", "/").strip("/")
290
- if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
291
- return "(非法路径: %s)" % rel
292
- base = Path(workdir or ".").resolve()
293
- p = (base / rel).resolve()
294
- if base not in p.parents:
295
- return "(路径越界: %s)" % rel
296
- if not p.is_file():
297
- return "(文件不存在: %s)" % rel
298
- # 分段读取(P0 基础件):offset/lines 任一非空即按行号范围读——超 64KB
299
- # 被「头尾保留、中段省略」截断的大文件靠它读全(用户实测 _lessons_dump.txt
300
- # 68KB 被截案)。行号 1-based;逐行切片后不强行拼回整份。
301
- offset_raw = str(args.get("offset") or "").strip()
302
- lines_raw = str(args.get("lines") or "").strip()
303
- if offset_raw or lines_raw:
304
- try:
305
- size = p.stat().st_size
306
- except OSError as e:
307
- return "(读取失败: %s)" % e
308
- if size > READ_EDIT_MAX_BYTES:
309
- return ("(文件过大 %d MB,分段读取上限 %d MB;请用 run_command 处理)"
310
- % (size // (1024 * 1024), READ_EDIT_MAX_BYTES // (1024 * 1024)))
311
- try:
312
- offset = max(1, int(float(offset_raw or 1)))
313
- lines = max(1, min(READ_SEGMENT_MAX_LINES,
314
- int(float(lines_raw or READ_SEGMENT_DEFAULT_LINES))))
315
- except (TypeError, ValueError):
316
- return "(offset/lines 须为整数行号)"
317
- all_lines = runner.decode_output(p.read_bytes()).splitlines()
318
- total = len(all_lines)
319
- if total == 0:
320
- return "(空文件)"
321
- if offset > total:
322
- return "(offset %d 超出文件总行数 %d)" % (offset, total)
323
- seg = all_lines[offset - 1:offset - 1 + lines]
324
- head = "第 %d–%d 行(共 %d 行;用 offset=%d 续读下一段)" % (
325
- offset, offset - 1 + len(seg), total, offset + len(seg))
326
- return head + "\n" + ("\n".join(seg) or "(该范围无内容)")
327
- data = p.read_bytes()[:READ_MAX_BYTES + 1]
328
- truncated = len(data) > READ_MAX_BYTES
329
- if truncated:
330
- # 头尾保留、中段省略(TokenJuice 借鉴):头 44k 给结构/开头上下文,
331
- # 尾 16k 给报错/结论(日志与代码的关键信息常在末尾),中段标注省略量
332
- try:
333
- size = p.stat().st_size
334
- except OSError:
335
- size = len(data)
336
- with open(p, "rb") as fh:
337
- head = fh.read(READ_HEAD_BYTES)
338
- fh.seek(max(0, size - READ_TAIL_BYTES))
339
- tail = fh.read(READ_TAIL_BYTES)
340
- head = _trim_partial_utf8(head)
341
- # 尾段开头的残缺多字节序列剥头(对偶于 _trim_partial_utf8 剥尾)
342
- while tail:
343
- try:
344
- tail.decode("utf-8")
345
- break
346
- except UnicodeDecodeError:
347
- tail = tail[1:]
348
- text = runner.decode_output(head) \
349
- + "\n…(中段省略 %d 字节;头 %dKB + 尾 %dKB 保留)\n" % (
350
- max(0, size - READ_HEAD_BYTES - READ_TAIL_BYTES),
351
- READ_HEAD_BYTES // 1024, READ_TAIL_BYTES // 1024) \
352
- + runner.decode_output(tail)
353
- return "…(文件超 64KB,已头尾保留读取)\n" + text
354
- head = data
355
- if not head:
356
- return "(空文件)"
357
- if _looks_binary(head):
358
- try:
359
- size = p.stat().st_size
360
- except OSError:
361
- size = len(head)
362
- fmt = _bin_format(head)
363
- if "图片" in fmt:
364
- return ("(图片文件: %s — %s,%d B,无法按文本读取。用户以附件发来的图片会"
365
- "直接出现在对话中,无需用工具读取;若需要目录里这张图的内容,"
366
- "请提示用户把它作为附件发送。)" % (rel, fmt, size))
367
- return ("(二进制文件,无法按文本读取: %s — %s,%d B。read_file 只支持文本文件;"
368
- "请如实告知用户该文件内容无法以文本方式查看,不要反复重读。)"
369
- % (rel, fmt, size))
370
- text = runner.decode_output(head)
371
- return text
372
-
373
-
374
- def _tool_write_file(workdir, args):
375
- rel = str(args.get("path") or "").replace("\\", "/").strip("/")
376
- if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
377
- return "(非法路径: %s)" % rel
378
- content = str(args.get("content") if args.get("content") is not None else "")
379
- base = Path(workdir or ".").resolve()
380
- dest = (base / rel).resolve()
381
- if base not in dest.parents:
382
- return "(路径越界: %s)" % rel
383
- dest.parent.mkdir(parents=True, exist_ok=True)
384
- # newline="":不翻译换行——模型回写 read_file 读到的 CRLF 内容时,
385
- # 默认翻译会二次翻成 \r\r\n
386
- with dest.open("w", encoding="utf-8", newline="") as fh:
387
- fh.write(content)
388
- return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
389
-
390
-
391
- def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
392
- """本机执行一条 shell 命令,返回 (退出码, 合并输出)。
393
-
394
- 薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
395
- → cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
396
- (用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
397
- 提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
398
- 输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
399
- cmdline = str(args.get("command") or "").strip()
400
- if not cmdline:
401
- return "(命令为空,未执行)"
402
- try:
403
- timeout = int(float(args.get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
404
- except (TypeError, ValueError):
405
- timeout = CMD_DEFAULT_TIMEOUT
406
- r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
407
- timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
408
- deadline=deadline, cancel_event=cancel_event)
409
- out = runner.clean_cli_text(
410
- (r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
411
- limit = CMD_OUT_HEAD + CMD_OUT_TAIL
412
- if not out.strip():
413
- body = "(无输出)"
414
- elif len(out) <= limit:
415
- body = out
416
- else:
417
- body = (out[:CMD_OUT_HEAD]
418
- + "\n…(中段省略 %d 字符;头 %dK + 尾 %dK 保留)\n" % (
419
- len(out) - limit, CMD_OUT_HEAD // 1024, CMD_OUT_TAIL // 1024)
420
- + out[-CMD_OUT_TAIL:])
421
- if r.get("cancelled"):
422
- note = "已取消,进程树已终止"
423
- elif r.get("timed_out"):
424
- note = "超时 %d 秒,进程树已强制终止" % timeout
425
- else:
426
- note = ""
427
- prefix = ("(%s)\n" % note) if note else ""
428
- code = r.get("exit_code")
429
- return "%s退出码: %s\n%s" % (prefix, "未知" if code is None else code, body)
430
-
431
-
432
- # ---------------------------------------------------------------- P0 基础件扩展
433
- # 2026-09-25 用户实测反馈:改大文件只能整份读写、删/移/改名只能绕 shell、
434
- # 大文件 64KB 中段被截、找内容只能逐文件读——补齐四个基础件(局部编辑、
435
- # 文件管理、分段读取、全文搜索),全部锁死工作目录内。
436
-
437
- READ_EDIT_MAX_BYTES = 16 * 1024 * 1024 # edit/append 分段读全文解码上限
438
- SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024 # search_content 单文件参与搜索上限
439
- SEARCH_DEFAULT_RESULTS = 50 # 命中行默认上限
440
- READ_SEGMENT_DEFAULT_LINES = 200 # read_file 分段默认行数
441
- READ_SEGMENT_MAX_LINES = 2000 # read_file 分段行数上限
442
-
443
- _WALK_PRUNE = {".git", "__pycache__", "node_modules", ".venv", "venv",
444
- "dist", "build", "target", ".idea", ".vscode", "__MACOSX",
445
- ".pytest_cache", ".mypy_cache"}
446
-
447
-
448
- def _guard_rel(workdir, rel, allow_root=False):
449
- """工作目录相对路径守卫(list/read/write 同款惯用法抽公共)。
450
-
451
- 返回 (resolve 后的 Path, 错误串);合法时错误串为 None。空 rel 仅在
452
- allow_root=True 时代表工作目录根。"""
453
- rel = str(rel or "").replace("\\", "/").strip("/")
454
- if (not rel and not allow_root) or ".." in Path(rel).parts \
455
- or any(":" in seg for seg in Path(rel).parts):
456
- return None, "(非法路径: %s)" % rel
457
- base = Path(workdir or ".").resolve()
458
- p = (base / rel).resolve() if rel else base
459
- if base not in p.parents and p != base:
460
- return None, "(路径越界: %s)" % rel
461
- return p, None
462
-
463
-
464
- def _read_text_capped(p, limit=READ_EDIT_MAX_BYTES):
465
- """读文本并识别编码(bytes 上限防巨型文件)。返回 (text, err)。"""
466
- try:
467
- size = p.stat().st_size
468
- except OSError as e:
469
- return "", "(读取失败: %s)" % e
470
- if size > limit:
471
- return "", ("(文件过大 %d MB,超过 %d MB 上限;请用 run_command 处理)"
472
- % (size // (1024 * 1024), limit // (1024 * 1024)))
473
- data = p.read_bytes()
474
- if _looks_binary(data[:8192]):
475
- return "", "(二进制文件,%s 不适用: %s)" % ("文本编辑", _bin_format(data[:64]))
476
- return runner.decode_output(data), None
477
-
478
-
479
- def _tool_edit_file(workdir, args):
480
- """精确查找替换:old_text 逐字符匹配,默认仅允许 1 处命中。"""
481
- p, err = _guard_rel(workdir, args.get("path"))
482
- if err:
483
- return err
484
- old_text = str(args.get("old_text") or "")
485
- new_text = str(args.get("new_text") if args.get("new_text") is not None else "")
486
- if not old_text:
487
- return "(old_text 不能为空;改全文请用 write_file 整份写入)"
488
- if not p.is_file():
489
- return "(文件不存在: %s)" % p.name
490
- text, err = _read_text_capped(p)
491
- if err:
492
- return err
493
- count = text.count(old_text)
494
- crlf = False
495
- if count == 0 and "\r\n" in text and "\r\n" not in old_text:
496
- # Windows 文件 CRLF:按 LF 形态重试匹配,命中则在原文上以 CRLF 替换,
497
- # 保持文件既有换行风格(否则整个文件换行符被翻转)
498
- crlf_text = text.replace("\r\n", "\n")
499
- count = crlf_text.count(old_text)
500
- crlf = count > 0
501
- if count == 0:
502
- return ("(old_text 在文件中找不到逐字符匹配——read_file 超过 64KB 会"
503
- "中段省略,请用 offset/lines 分段读全后重试)")
504
- replace_all = str(args.get("replace_all") or "").strip().lower() in ("1", "true", "yes")
505
- if count > 1 and not replace_all:
506
- return ("(old_text 命中 %d 处;请补充更多上下文使命中唯一,"
507
- "或传 replace_all=true 全部替换)" % count)
508
- if crlf:
509
- old_text = old_text.replace("\n", "\r\n")
510
- new_text = new_text.replace("\n", "\r\n")
511
- # newline="" 禁止 Python 的 \n→os.linesep 翻译:文件既有 CRLF 时会被
512
- # 二次翻译成 \r\r\n(Windows 实测损坏案),写什么落什么
513
- with p.open("w", encoding="utf-8", newline="") as fh:
514
- fh.write(text.replace(old_text, new_text))
515
- return "已替换 %d 处(%s)" % (count if replace_all else 1, p.name)
516
-
517
-
518
- def _tool_append_file(workdir, args):
519
- """文件末尾追加(bytes 级追加,大文件不整份重写);不存在则创建。"""
520
- p, err = _guard_rel(workdir, args.get("path"))
521
- if err:
522
- return err
523
- content = str(args.get("content") if args.get("content") is not None else "")
524
- if not content:
525
- return "(content 不能为空)"
526
- if p.is_file():
527
- with open(p, "rb") as fh:
528
- head = fh.read(8192)
529
- if _looks_binary(head):
530
- return "(二进制文件,不能按文本追加: %s — %s)" % (p.name, _bin_format(head))
531
- p.parent.mkdir(parents=True, exist_ok=True)
532
- with open(p, "ab") as fh:
533
- fh.write(content.encode("utf-8"))
534
- return "已追加 %d 字符到 %s(UTF-8)" % (len(content), p.name)
535
-
536
-
537
- def _tool_fs_manage(workdir, args):
538
- """删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝防覆盖)。"""
539
- action = str(args.get("action") or "").strip().lower()
540
- if action not in ("delete", "move", "rename", "copy"):
541
- return "(action 须为 delete/move/rename/copy)"
542
- p, err = _guard_rel(workdir, args.get("path"))
543
- if err:
544
- return err
545
- if not p.exists() and not p.is_symlink():
546
- return "(不存在: %s)" % p
547
- if action == "delete":
548
- if p.is_dir() and not p.is_symlink():
549
- shutil.rmtree(str(p))
550
- return "已删除目录(含全部内容): %s" % p.name
551
- p.unlink()
552
- return "已删除: %s" % p.name
553
- dest, err = _guard_rel(workdir, args.get("dest"))
554
- if err:
555
- return err.replace("非法路径", "dest 非法路径").replace("路径越界", "dest 路径越界")
556
- if dest.exists() or dest.is_symlink():
557
- return "(目标已存在,拒绝覆盖: %s;先删除或换名)" % dest.name
558
- dest.parent.mkdir(parents=True, exist_ok=True)
559
- if action == "copy":
560
- if p.is_dir():
561
- shutil.copytree(str(p), str(dest))
562
- else:
563
- shutil.copy2(str(p), str(dest))
564
- return "已复制 %s → %s" % (p.name, dest.name)
565
- shutil.move(str(p), str(dest))
566
- return "已%s %s → %s" % ("移动" if action == "move" else "重命名",
567
- p.name, dest.name)
568
-
569
-
570
- def _tool_search_content(workdir, args):
571
- """grep 式内容搜索:正则(编译失败回退字面量),逐行命中带行号。"""
572
- pattern = str(args.get("pattern") or "").strip()
573
- if not pattern:
574
- return "(pattern 不能为空)"
575
- try:
576
- rx = re.compile(pattern)
577
- except re.error:
578
- rx = re.compile(re.escape(pattern))
579
- pattern_note = "(正则无效,已按字面量搜索)\n"
580
- else:
581
- pattern_note = ""
582
- sub, err = _guard_rel(workdir, args.get("path"), allow_root=True)
583
- if err:
584
- return err
585
- if not sub.is_dir():
586
- return "(目录不存在: %s)" % args.get("path")
587
- glob_pat = str(args.get("glob") or "").strip()
588
- try:
589
- max_results = max(1, min(200, int(float(args.get("max_results") or 0)
590
- or SEARCH_DEFAULT_RESULTS)))
591
- except (TypeError, ValueError):
592
- max_results = SEARCH_DEFAULT_RESULTS
593
- base = Path(workdir or ".").resolve()
594
- out, hits, scanned = [], 0, 0
595
- for root, dirs, files in os.walk(str(sub)):
596
- dirs[:] = [d for d in dirs if d not in _WALK_PRUNE]
597
- for f in files:
598
- if glob_pat:
599
- relp = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
600
- if not (fnmatch.fnmatch(f, glob_pat) or fnmatch.fnmatch(relp, glob_pat)):
601
- continue
602
- fp = os.path.join(root, f)
603
- try:
604
- if os.path.getsize(fp) > SEARCH_MAX_FILE_BYTES:
605
- continue
606
- with open(fp, "rb") as fh:
607
- head = fh.read(8192)
608
- if _looks_binary(head):
609
- continue
610
- with open(fp, "rb") as fh:
611
- data = fh.read()
612
- except OSError:
613
- continue
614
- scanned += 1
615
- rel = os.path.relpath(fp, str(base)).replace(os.sep, "/")
616
- for i, line in enumerate(runner.decode_output(data).splitlines(), 1):
617
- if rx.search(line):
618
- out.append("%s:%d: %s" % (rel, i, line.strip()[:200]))
619
- hits += 1
620
- if hits >= max_results:
621
- out.append("…(已达 %d 行上限,缩小 pattern/glob 或调大 max_results)"
622
- % max_results)
623
- return pattern_note + "\n".join(out)
624
- if not out:
625
- return pattern_note + ("(无命中;已扫描 %d 个文本文件)" % scanned)
626
- return pattern_note + "\n".join(out) + "\n(共 %d 行命中 / 扫描 %d 个文件)" % (hits, scanned)
627
-
628
-
629
- def _tool_find_files(workdir, args):
630
- """文件名通配查找(fnmatch;pattern 不含 / 时匹配任意深度的文件名)。"""
631
- pattern = str(args.get("pattern") or "").strip().replace("\\", "/")
632
- if not pattern or ".." in pattern or ":" in pattern:
633
- return "(非法 pattern: %s;示例 *.py 或 data/*.json)" % pattern
634
- base = Path(workdir or ".").resolve()
635
- match_name = "/" not in pattern
636
- out = []
637
- for root, dirs, files in os.walk(str(base)):
638
- dirs[:] = [d for d in dirs if d not in _WALK_PRUNE]
639
- for f in files:
640
- rel = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
641
- if not (fnmatch.fnmatch(rel, pattern)
642
- or (match_name and fnmatch.fnmatch(f, pattern))):
643
- continue
644
- try:
645
- size = os.path.getsize(os.path.join(root, f))
646
- except OSError:
647
- size = -1
648
- out.append("%s (%d B)" % (rel, size))
649
- if len(out) >= LIST_MAX_ENTRIES:
650
- return "\n".join(out) + "\n…(截断,共 %d+ 项)" % LIST_MAX_ENTRIES
651
- return "\n".join(out) or "(无匹配文件)"
652
-
653
-
654
- TOOLS_SPEC = [
655
- {"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
656
- "args": {"path": "子目录相对路径,留空=根目录"}},
657
- {"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别;默认超 64KB 时保留开头与结尾、中段省略,此时用 offset/lines 分段读全;图片等二进制文件无法读取)",
658
- "args": {"path": "文件相对路径",
659
- "?offset": "可选,起始行号(1-based),配合 lines 分段读取大文件",
660
- "?lines": "可选,读取行数(默认 200,最大 2000)"}},
661
- {"name": "edit_file", "description": "对文件做精确查找替换(局部编辑,不必整份重写):old_text 必须与文件内容逐字符一致,默认要求命中唯一,配合 read_file 使用",
662
- "args": {"path": "文件相对路径",
663
- "old_text": "要被替换的原文(逐字符精确匹配)",
664
- "new_text": "替换后的新文本(可为空串=删除)",
665
- "?replace_all": "可选,true=替换全部命中(默认仅允许 1 处命中)"}},
666
- {"name": "append_file", "description": "把文本追加到文件末尾(bytes 级追加,大文件不必整份重写);文件不存在则创建。追加日志、逐条记录时优先用它",
667
- "args": {"path": "文件相对路径", "content": "追加到末尾的文本"}},
668
- {"name": "fs_manage", "description": "文件管理:删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝覆盖,删目录会连内容一起删)",
669
- "args": {"action": "delete | move | rename | copy",
670
- "path": "源相对路径(目录或文件)",
671
- "?dest": "move/rename/copy 的目标相对路径"}},
672
- {"name": "search_content", "description": "全文内容搜索(grep 式):在工作目录内逐文件逐行匹配正则,命中行带「路径:行号:」前缀返回;自动跳过二进制与依赖目录(node_modules/.git 等)",
673
- "args": {"pattern": "搜索文本或正则表达式",
674
- "?path": "可选,限定搜索的子目录,默认根目录",
675
- "?glob": "可选,文件名过滤,如 *.py",
676
- "?max_results": "可选,命中行数上限(默认 50,最大 200)"}},
677
- {"name": "find_files", "description": "按文件名通配查找文件(如 *.py、data/*.json、report*.md);pattern 不含 / 时匹配任意深度的文件名",
678
- "args": {"pattern": "文件名通配模式"}},
679
- {"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,整份覆盖,父目录自动创建;只改一小段请用 edit_file)",
680
- "args": {"path": "文件相对路径", "content": "完整文本内容"}},
681
- {"name": "run_command",
682
- "description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
683
- "args": {"command": "完整命令行(shell 语法,本机直接执行)",
684
- "timeout_sec": "可选,超时秒数"}},
685
- ]
686
-
687
- _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
688
- "write_file": _tool_write_file, "run_command": _tool_run_command,
689
- "edit_file": _tool_edit_file, "append_file": _tool_append_file,
690
- "fs_manage": _tool_fs_manage, "search_content": _tool_search_content,
691
- "find_files": _tool_find_files}
692
-
693
-
694
- CREATE_TASK_SPEC = {
695
- "name": "create_task",
696
- "description": "发起一个独立的 CodeBee 任务。用户要求开始另一类工作(例如创建连载小说、写代码或生成文档)时使用;任务会出现在任务列表并异步运行,不要把它当作当前对话里的普通回答。",
697
- "args": {
698
- "type": "任务类型 ID,例如 serial_novel、novel、code、doc、article",
699
- "goal": "新任务的明确目标",
700
- "?title": "新任务标题",
701
- "?workdir": "绝对工作目录;留空沿用当前工作目录",
702
- "?context": "补充背景或约束",
703
- "?chapters": "serial_novel 首批章节数",
704
- "?words_per_chapter": "serial_novel 每章目标字数",
705
- "?start_chapter": "续写起始章节号",
706
- "?variants": "连载同章候选稿数量(1-3)",
707
- "?branches": "连载剧情分支数量(1-3)",
708
- "?story_bible": "连载小说初始故事圣经",
709
- },
710
- }
711
-
712
-
713
- def _tool_create_task(workdir, args, task_creator=None):
714
- """Create a separate task through the caller-owned task boundary."""
715
- if task_creator is None:
716
- return "(当前对话不支持发起新任务)"
717
- task_type = str(args.get("type") or "").strip().lower()
718
- goal = str(args.get("goal") or "").strip()
719
- if not task_type:
720
- return "(type 不能为空;可用类型请使用 serial_novel、novel、code、doc 等)"
721
- if not goal:
722
- return "(goal 不能为空)"
723
- payload = {
724
- "type": task_type,
725
- "goal": goal,
726
- "title": str(args.get("title") or "").strip(),
727
- "workdir": str(args.get("workdir") or workdir or "").strip(),
728
- "context": str(args.get("context") or "").strip(),
729
- }
730
- if task_type == "serial_novel":
731
- serial = {}
732
- for key in ("chapters", "words_per_chapter", "start_chapter", "variants", "branches"):
733
- if args.get(key) not in (None, ""):
734
- serial[key] = args.get(key)
735
- if serial:
736
- payload["serial"] = serial
737
- if args.get("story_bible") not in (None, ""):
738
- payload["story_bible"] = str(args.get("story_bible") or "").strip()
739
- try:
740
- result = task_creator(payload)
741
- except Exception as exc:
742
- return "(发起任务失败:%s)" % str(exc)[:300]
743
- if not isinstance(result, dict) or not result.get("task_id"):
744
- return "(发起任务失败:未返回任务编号)"
745
- return "已发起新任务:%s(运行编号:%s,类型:%s)" % (
746
- result["task_id"], result.get("run_id") or "", task_type)
747
-
748
-
749
- def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creator=None):
750
- if name == "create_task":
751
- return _tool_create_task(workdir, args or {}, task_creator=task_creator)
752
- if str(name or "").startswith("mcp__"):
753
- # MCP 工具:透传给配置的服务器(stdio JSON-RPC);超时给足但封顶
754
- try:
755
- from . import mcp_client
756
- r = mcp_client.dispatch_full_name(name, args or {})
757
- return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
758
- except Exception as e:
759
- return "MCP 工具执行失败: %s" % e
760
- fn = _TOOL_IMPL.get(name or "")
761
- if fn is None:
762
- return "(未知工具: %s)" % name
763
- try:
764
- if name == "run_command":
765
- return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
766
- return fn(workdir, args or {})
767
- except Exception as e:
768
- return "工具执行失败: %s" % (e)
769
-
770
-
771
- # ---------------------------------------------------------------- 协议适配
772
-
773
- def _split_tool_args(t):
774
- """args 键以 ? 开头 = 可选参数(模型侧名字去掉 ?,不进 required)。"""
775
- props, required = {}, []
776
- for k, v in t["args"].items():
777
- opt = k.startswith("?")
778
- props[k[1:] if opt else k] = {"type": "string", "description": v}
779
- if not opt:
780
- required.append(k)
781
- return props, required
782
-
783
-
784
- def _mcp_specs():
785
- """MCP 工具清单(带缓存);取不到/未配置返回空表——增强不挡主流程。"""
786
- try:
787
- from . import mcp_client
788
- return mcp_client.tool_specs_cached()
789
- except Exception:
790
- return []
791
-
792
-
793
- def _openai_tools(extra_specs=None):
794
- out = []
795
- for t in list(TOOLS_SPEC) + list(extra_specs or []):
796
- props, required = _split_tool_args(t)
797
- out.append({"type": "function", "function": {
798
- "name": t["name"], "description": t["description"],
799
- "parameters": {"type": "object", "properties": props,
800
- "required": required}}})
801
- for m in _mcp_specs():
802
- # MCP 工具自带 JSON Schema(透传,不套字符串化的 _split_tool_args)
803
- out.append({"type": "function", "function": {
804
- "name": m["full_name"],
805
- "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
806
- "parameters": m["input_schema"]}})
807
- return out
808
-
809
-
810
- def _anthropic_tools(extra_specs=None):
811
- out = []
812
- for t in list(TOOLS_SPEC) + list(extra_specs or []):
813
- props, required = _split_tool_args(t)
814
- out.append({"name": t["name"], "description": t["description"],
815
- "input_schema": {"type": "object", "properties": props,
816
- "required": required}})
817
- for m in _mcp_specs():
818
- out.append({"name": m["full_name"],
819
- "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
820
- "input_schema": m["input_schema"]})
821
- return out
822
-
823
-
824
- def _post_json(url, headers, body, allow_private, timeout):
825
- """传输层单点(测试在这里打桩)。"""
826
- return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
827
-
828
-
829
- def _call_with_optional_deadline(fn, args, kwargs, deadline):
830
- """Keep older test/integration transport shims source-compatible."""
831
- if deadline is None:
832
- return fn(*args, **kwargs)
833
- try:
834
- return fn(*args, **kwargs, deadline=deadline)
835
- except TypeError as exc:
836
- if "unexpected keyword argument 'deadline'" not in str(exc):
837
- raise
838
- return fn(*args, **kwargs)
839
-
840
-
841
- def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
842
- deadline=None):
843
- """可打断的模型调用:cancel_event 置位即刻放弃等待返回。
844
-
845
- 单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
846
- 线程自行收尾(响应被丢弃,socket 随线程结束释放),主流程立即返回。
847
- 服务端仍会把这次生成跑完(token 已花),但任务本身即刻终止。"""
848
- box = {}
849
-
850
- def _go():
851
- try:
852
- box["r"] = _post_json(url, headers, body, allow_private, timeout)
853
- except Exception as e:
854
- box["r"] = (0, None, repr(e))
855
-
856
- th = threading.Thread(target=_go, daemon=True)
857
- th.start()
858
- wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
859
- while th.is_alive():
860
- if cancel_event is not None and cancel_event.is_set():
861
- break
862
- if time.monotonic() >= wait_until:
863
- return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
864
- th.join(0.5)
865
- if cancel_event is not None and cancel_event.is_set():
866
- return 0, None, "已取消"
867
- return box.get("r") or (0, None, "无响应")
868
-
869
-
870
- # ---------------------------------------------------------------- 思考过程(流式)
871
- # 用户诉求(2026-09-22):「把思考过程打印出来」——直连对话此前整轮只有三点
872
- # 打字动画,模型的思维链(DeepSeek/vLLM 系 reasoning_content、Anthropic
873
- # thinking 块)被 _sse_parse 明确丢弃。这里自带一份「认思考」的增量解析:
874
- # 边收边把思考与正文分别攒起来,交给调用方实时落盘 + 结束后存进步骤记录。
875
-
876
- def _reason_from_delta(proto, obj):
877
- """从一条流式事件里取「思考增量」。三协议字段各异,取不到返回空串。
878
-
879
- openai 兼容面最杂:reasoning_content(DeepSeek/vLLM/云知声实测)、
880
- reasoning(部分网关)、reasoning_details[].text(OpenRouter 系)。
881
- anthropic 是 thinking_delta(另有 redacted_thinking 不文本化,跳过)。"""
882
- if proto == "openai":
883
- total = ""
884
- for ch in obj.get("choices") or []:
885
- d = ch.get("delta") or {}
886
- for k in ("reasoning_content", "reasoning"):
887
- v = d.get(k)
888
- if isinstance(v, str):
889
- total += v
890
- for det in (d.get("reasoning_details") or []):
891
- if isinstance(det, dict) and isinstance(det.get("text"), str):
892
- total += det["text"]
893
- return total
894
- if proto == "anthropic" and obj.get("type") == "content_block_delta":
895
- d = obj.get("delta") or {}
896
- if d.get("type") == "thinking_delta":
897
- return d.get("thinking") or ""
898
- return ""
899
- if proto == "google":
900
- total = ""
901
- for cand in obj.get("candidates") or []:
902
- for p in ((cand.get("content") or {}).get("parts") or []):
903
- if isinstance(p, dict) and p.get("thought"):
904
- total += p.get("text") or ""
905
- return total
906
- return ""
907
-
908
-
909
- def _reason_from_message(proto, data):
910
- """非流式响应里的思考字段(网关不支持流式时的兜底)。"""
911
- if not isinstance(data, dict):
912
- return ""
913
- if proto == "anthropic":
914
- return "\n".join(b.get("thinking", "") for b in (data.get("content") or [])
915
- if isinstance(b, dict) and b.get("type") == "thinking")
916
- if proto == "google":
917
- total = ""
918
- for cand in data.get("candidates") or []:
919
- for p in ((cand.get("content") or {}).get("parts") or []):
920
- if isinstance(p, dict) and p.get("thought"):
921
- total += p.get("text") or ""
922
- return total
923
- msg = ((data.get("choices") or [{}])[0].get("message") or {})
924
- total = ""
925
- for k in ("reasoning_content", "reasoning"):
926
- v = msg.get(k)
927
- if isinstance(v, str):
928
- total += v
929
- for det in (msg.get("reasoning_details") or []):
930
- if isinstance(det, dict) and isinstance(det.get("text"), str):
931
- total += det["text"]
932
- return total
933
-
934
-
935
- def _stream_accumulate(proto, obj, acc):
936
- """把一条 SSE 事件并入累加器(就地改写 acc)。返回是否有「有意义的进展」。
937
-
938
- acc = {text, reasoning, tools, usage};工具参数按 index 归并——openai 的
939
- function.arguments 与 anthropic 的 input_json_delta 都是分片到达的。"""
940
- progress = False
941
- if proto == "anthropic":
942
- t = obj.get("type")
943
- if t == "content_block_start":
944
- blk = obj.get("content_block") or {}
945
- if blk.get("type") == "tool_use":
946
- idx = int(obj.get("index") or 0)
947
- acc["tools"][idx] = {"id": blk.get("id") or "",
948
- "name": blk.get("name") or "", "raw": ""}
949
- progress = True
950
- elif t == "content_block_delta":
951
- d = obj.get("delta") or {}
952
- if d.get("type") == "text_delta" and d.get("text"):
953
- acc["text"] += d["text"]
954
- progress = True
955
- elif d.get("type") == "input_json_delta":
956
- idx = int(obj.get("index") or 0)
957
- slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
958
- slot["raw"] += d.get("partial_json") or ""
959
- progress = True
960
- elif t == "message_start":
961
- u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
962
- acc["usage"].update({"input": int(u.get("input_tokens") or 0),
963
- "output": int(u.get("output_tokens") or 0),
964
- "cached": (int(u.get("cache_read_input_tokens") or 0)
965
- + int(u.get("cache_creation_input_tokens") or 0))})
966
- elif t == "message_delta":
967
- u = obj.get("usage") or {}
968
- if u.get("output_tokens"):
969
- acc["usage"]["output"] = int(u["output_tokens"])
970
- elif proto == "google":
971
- for cand in obj.get("candidates") or []:
972
- for p in ((cand.get("content") or {}).get("parts") or []):
973
- if not isinstance(p, dict):
974
- continue
975
- if p.get("thought"):
976
- acc["reasoning"] += p.get("text") or ""
977
- progress = True
978
- elif p.get("functionCall"):
979
- fc = p["functionCall"]
980
- acc["tools"][len(acc["tools"])] = {
981
- "id": "call_%d" % (len(acc["tools"]) + 1),
982
- "name": fc.get("name") or "",
983
- "raw": json.dumps(fc.get("args") or {}, ensure_ascii=False)}
984
- progress = True
985
- elif p.get("text"):
986
- acc["text"] += p["text"]
987
- progress = True
988
- u = obj.get("usageMetadata")
989
- if isinstance(u, dict):
990
- acc["usage"].update({"input": int(u.get("promptTokenCount") or 0),
991
- "output": int(u.get("candidatesTokenCount") or 0),
992
- "total": int(u.get("totalTokenCount") or 0)})
993
- else:
994
- for ch in obj.get("choices") or []:
995
- d = ch.get("delta") or {}
996
- if isinstance(d.get("content"), str) and d["content"]:
997
- acc["text"] += d["content"]
998
- progress = True
999
- for i, tc in enumerate(d.get("tool_calls") or []):
1000
- if not isinstance(tc, dict):
1001
- continue
1002
- idx = int(tc.get("index") if tc.get("index") is not None else i)
1003
- slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
1004
- if tc.get("id"):
1005
- slot["id"] = tc["id"]
1006
- fn = tc.get("function") or {}
1007
- if fn.get("name"):
1008
- slot["name"] = fn["name"]
1009
- if fn.get("arguments"):
1010
- slot["raw"] += fn["arguments"]
1011
- progress = True
1012
- u = obj.get("usage")
1013
- if isinstance(u, dict):
1014
- acc["usage"].update({"input": int(u.get("prompt_tokens") or 0),
1015
- "output": int(u.get("completion_tokens") or 0),
1016
- "total": int(u.get("total_tokens") or 0),
1017
- "cached": int(((u.get("prompt_tokens_details") or {})
1018
- .get("cached_tokens")) or 0)})
1019
- return progress
1020
-
1021
-
1022
- def _calls_from_acc(acc):
1023
- """累加器里的工具分片 → [{id, name, args}](参数 JSON 解析失败给空对象)。"""
1024
- out = []
1025
- for idx in sorted(acc["tools"].keys()):
1026
- slot = acc["tools"][idx]
1027
- name = slot.get("name") or ""
1028
- if not name:
1029
- continue
1030
- try:
1031
- args = json.loads(slot.get("raw") or "{}")
1032
- except Exception:
1033
- args = {}
1034
- if not isinstance(args, dict):
1035
- args = {}
1036
- out.append({"id": slot.get("id") or ("call_%d" % (idx + 1)),
1037
- "name": name, "args": args})
1038
- return out
1039
-
1040
-
1041
- def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
1042
- on_reason=None, on_text=None, on_tick=None, cancel_event=None,
1043
- deadline=None):
1044
- """流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
1045
-
1046
- 与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
1047
- 即刻放弃等待(socket 随线程结束释放)。三个回调:on_reason 每收到一段思考
1048
- 增量(调用方据此实时打印「思考过程」),on_text 收到**累计**正文(只在真的
1049
- 变长时触发,运行中直接当正文预览),on_tick 是无文本进展时的心跳。
1050
- events=0 表示网关根本没按流回(多半忽略了 stream 字段)——调用方据此回落
1051
- 非流式;events>0 而正文为空则是模型真没说话,不该重发(白烧一次生成)。
1052
-
1053
- 首字延迟与吞吐(§07 体验面):`first_token_ms` = 发出请求到第一个**带内容**
1054
- 的增量(思考 / 正文 / 工具参数)之间的毫秒数;`tokens_per_sec` 用 output
1055
- token 除以「首个到末个内容增量」的窗口,窗口过短或拿不到 output 时记 0
1056
- (0 = 不可测,不是"一秒 0 个 token")。两者只有走流式才有值,非流式回落
1057
- 路径与 CLI 子进程路径都给 0。"""
1058
- acc = {"text": "", "reasoning": "", "tools": {}, "usage": {}}
1059
- box = {"status": 0, "err": "", "events": 0, "sent_text": None,
1060
- "first_ms": None, "last_ms": None}
1061
- lock = threading.Lock()
1062
-
1063
- def _emit(cb, payload):
1064
- if cb is None:
1065
- return
1066
- try:
1067
- cb(payload)
1068
- except Exception:
1069
- pass
1070
-
1071
- def _go():
1072
- try:
1073
- parsed = urllib.parse.urlsplit(url)
1074
- if parsed.scheme not in ("http", "https"):
1075
- box["err"] = "协议必须是 http/https"
1076
- return
1077
- host, herr = modelhub._validate_host(url, allow_private)
1078
- if host is None:
1079
- box["err"] = herr
1080
- return
1081
- t_send = time.monotonic() # 首字延迟基线:请求发出,不含 DNS/连接前准备
1082
- req = urllib.request.Request(
1083
- url, method="POST",
1084
- headers=dict(headers, **{"Content-Type": "application/json",
1085
- "Accept": "text/event-stream"}),
1086
- data=json.dumps(body).encode("utf-8"))
1087
- with modelhub._opener().open(req, timeout=timeout) as resp:
1088
- box["status"] = resp.status
1089
- if not 200 <= resp.status < 300:
1090
- raw = resp.read(65536).decode("utf-8", "replace")
1091
- box["err"] = "HTTP %s %s" % (resp.status, raw[:200])
1092
- return
1093
- for raw in resp:
1094
- line = raw.decode("utf-8", "replace").strip()
1095
- if not line.startswith("data:"):
1096
- continue
1097
- payload = line[5:].strip()
1098
- if not payload or payload == "[DONE]":
1099
- continue
1100
- try:
1101
- obj = json.loads(payload)
1102
- except Exception:
1103
- continue # 半行/坏行静默跳过,不中断整流
1104
- if not isinstance(obj, dict):
1105
- continue
1106
- box["events"] += 1
1107
- reason = _reason_from_delta(proto, obj)
1108
- with lock:
1109
- if reason:
1110
- acc["reasoning"] += reason
1111
- moved = _stream_accumulate(proto, obj, acc)
1112
- text_now = acc["text"]
1113
- if reason or moved:
1114
- now_ms = (time.monotonic() - t_send) * 1000.0
1115
- if box["first_ms"] is None:
1116
- box["first_ms"] = now_ms
1117
- box["last_ms"] = now_ms
1118
- if reason:
1119
- _emit(on_reason, reason)
1120
- if text_now != box["sent_text"]:
1121
- box["sent_text"] = text_now
1122
- _emit(on_text, text_now) # 累计正文(运行中实时打印)
1123
- elif moved and not reason:
1124
- _emit(on_tick, None) # 只有工具/用量在推进的心跳
1125
- if sum(len(x) for x in (acc["text"], acc["reasoning"])) > 4 * 1024 * 1024:
1126
- break # 防失控
1127
- except Exception as e:
1128
- box["err"] = box["err"] or tlsctx.humanize(repr(e)[:300])
1129
-
1130
- th = threading.Thread(target=_go, daemon=True)
1131
- th.start()
1132
- wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
1133
- while th.is_alive():
1134
- if cancel_event is not None and cancel_event.is_set():
1135
- break
1136
- if time.monotonic() >= wait_until:
1137
- break
1138
- th.join(0.5)
1139
- with lock:
1140
- out = {"status": box["status"], "text": acc["text"],
1141
- "reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
1142
- "calls": _calls_from_acc(acc), "events": box["events"],
1143
- "first_token_ms": round(box["first_ms"], 1) if box["first_ms"] is not None else 0.0,
1144
- "tokens_per_sec": 0.0, "error": box["err"]}
1145
- gen_s = 0.0
1146
- if box["first_ms"] is not None and box["last_ms"] is not None:
1147
- gen_s = (box["last_ms"] - box["first_ms"]) / 1000.0
1148
- out_tok = int(acc["usage"].get("output") or 0)
1149
- if gen_s > 0.2 and out_tok > 0:
1150
- out["tokens_per_sec"] = round(out_tok / gen_s, 2)
1151
- if cancel_event is not None and cancel_event.is_set():
1152
- out["error"], out["status"] = "已取消", 0
1153
- return out
1154
- if th.is_alive():
1155
- out["error"], out["status"] = ("任务总时限已到" if deadline is not None
1156
- else "响应超时"), 0
1157
- out["deadline_exceeded"] = deadline is not None
1158
- return out
1159
-
1160
-
1161
- def _m_openai(m):
1162
- """内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
1163
- if m["role"] == "assistant":
1164
- out = {"role": "assistant", "content": m.get("content") or ""}
1165
- if m.get("tool_calls"):
1166
- out["tool_calls"] = [{"id": tc["id"], "type": "function",
1167
- "function": {"name": tc["name"],
1168
- "arguments": json.dumps(tc["args"], ensure_ascii=False)}}
1169
- for tc in m["tool_calls"]]
1170
- return out
1171
- if m["role"] == "tool_results":
1172
- return [{"role": "tool", "tool_call_id": cid, "content": text}
1173
- for cid, text in m["tool_results"]]
1174
- imgs = m.get("images") or []
1175
- if imgs: # 多模态:content 变数组,图跟在文本后(role=tool 只收文本,故图只走 user)
1176
- content = [{"type": "text", "text": m.get("content") or ""}]
1177
- content += [{"type": "image_url",
1178
- "image_url": {"url": "data:%s;base64,%s" % (mime, b64)}}
1179
- for mime, b64 in imgs]
1180
- return {"role": "user", "content": content}
1181
- return {"role": "user", "content": m.get("content") or ""}
1182
-
1183
-
1184
- def _m_anthropic(m):
1185
- if m["role"] == "assistant":
1186
- blocks = []
1187
- if m.get("content"):
1188
- blocks.append({"type": "text", "text": m["content"]})
1189
- for tc in (m.get("tool_calls") or []):
1190
- blocks.append({"type": "tool_use", "id": tc["id"], "name": tc["name"],
1191
- "input": tc["args"]})
1192
- return {"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]}
1193
- if m["role"] == "tool_results":
1194
- return {"role": "user", "content": [
1195
- {"type": "tool_result", "tool_use_id": cid, "content": text}
1196
- for cid, text in m["tool_results"]]}
1197
- imgs = m.get("images") or []
1198
- if imgs:
1199
- blocks = [{"type": "text", "text": m.get("content") or ""}]
1200
- blocks += [{"type": "image",
1201
- "source": {"type": "base64", "media_type": mime, "data": b64}}
1202
- for mime, b64 in imgs]
1203
- return {"role": "user", "content": blocks}
1204
- return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
1205
-
1206
-
1207
- def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
1208
- max_tokens=BASE_MAX_TOKENS, extra_specs=None):
1209
- """按协议构造 (url, headers, body)。msgs 为内部统一形状。
1210
-
1211
- stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
1212
- openai/anthropic 置 stream 字段(openai 另要 include_usage,否则收尾
1213
- chunk 不带 usage,这一步的 token 记账会凭空少一截)。"""
1214
- base = (base or "").rstrip("/")
1215
- if proto == "google":
1216
- if base.endswith("/v1beta"):
1217
- prefix = base + "/models/%s"
1218
- else:
1219
- prefix = base + "/v1beta/models/%s"
1220
- url = (prefix + (":streamGenerateContent?alt=sse" if stream
1221
- else ":generateContent")) % model
1222
- headers = {"x-goog-api-key": use_key}
1223
- contents = []
1224
- for m in msgs:
1225
- if m["role"] not in ("user", "assistant"):
1226
- continue
1227
- parts = [{"text": m.get("content") or ""}]
1228
- parts += [{"inline_data": {"mime_type": mime, "data": b64}}
1229
- for mime, b64 in (m.get("images") or [])]
1230
- contents.append({"role": ("user" if m["role"] == "user" else "model"),
1231
- "parts": parts})
1232
- body = {"contents": contents,
1233
- "systemInstruction": {"parts": [{"text": system}]},
1234
- "generationConfig": {"maxOutputTokens": max_tokens}}
1235
- return url, headers, body
1236
- path = "/messages" if proto == "anthropic" else "/chat/completions"
1237
- url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
1238
- if proto == "anthropic":
1239
- headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
1240
- body = {"model": model, "max_tokens": max_tokens, "system": system,
1241
- "messages": [_m_anthropic(m) for m in msgs]}
1242
- if with_tools:
1243
- body["tools"] = _anthropic_tools(extra_specs)
1244
- if stream:
1245
- body["stream"] = True
1246
- return url, headers, body
1247
- headers = {"Authorization": "Bearer " + use_key}
1248
- # tool_results 消息展开成多条 role=tool(_m_openai 对它返回列表,不能嵌套)
1249
- msgs_wire = []
1250
- for m in msgs:
1251
- w = _m_openai(m)
1252
- if isinstance(w, list):
1253
- msgs_wire.extend(w)
1254
- else:
1255
- msgs_wire.append(w)
1256
- body = {"model": model, "max_tokens": max_tokens,
1257
- "messages": [{"role": "system", "content": system}] + msgs_wire}
1258
- if with_tools:
1259
- body["tools"] = _openai_tools(extra_specs)
1260
- if stream:
1261
- body["stream"] = True
1262
- body["stream_options"] = {"include_usage": True}
1263
- return url, headers, body
1264
-
1265
-
1266
- def _parse_reply(proto, data):
1267
- """从响应解析 → (文本, [tool_call], usage)。
1268
- tool_call = {id, name, args};usage = {input, output, cached, total}。"""
1269
- usage = {"input": 0, "output": 0, "cached": 0, "total": 0}
1270
- if proto == "google":
1271
- cand = (data.get("candidates") or [{}])[0]
1272
- parts = (cand.get("content") or {}).get("parts") or []
1273
- text = "\n".join(p.get("text", "") for p in parts if isinstance(p, dict))
1274
- um = data.get("usageMetadata") or {}
1275
- usage["input"] = int(um.get("promptTokenCount") or 0)
1276
- usage["output"] = int(um.get("candidatesTokenCount") or 0)
1277
- usage["total"] = int(um.get("totalTokenCount") or 0)
1278
- return text.strip(), [], usage
1279
- if proto == "anthropic":
1280
- blocks = data.get("content") or []
1281
- text = "\n".join(b.get("text", "") for b in blocks
1282
- if isinstance(b, dict) and b.get("type") == "text")
1283
- calls = [{"id": b.get("id") or "", "name": b.get("name") or "",
1284
- "args": b.get("input") or {}}
1285
- for b in blocks if isinstance(b, dict) and b.get("type") == "tool_use"]
1286
- u = data.get("usage") or {}
1287
- usage["input"] = int(u.get("input_tokens") or 0)
1288
- usage["output"] = int(u.get("output_tokens") or 0)
1289
- usage["cached"] = (int(u.get("cache_read_input_tokens") or 0)
1290
- + int(u.get("cache_creation_input_tokens") or 0))
1291
- usage["total"] = usage["input"] + usage["output"] + usage["cached"]
1292
- return text.strip(), calls, usage
1293
- choice = (data.get("choices") or [{}])[0]
1294
- msg = choice.get("message") or {}
1295
- calls = []
1296
- for tc in (msg.get("tool_calls") or []):
1297
- fn = tc.get("function") or {}
1298
- try:
1299
- args = json.loads(fn.get("arguments") or "{}")
1300
- except Exception:
1301
- args = {}
1302
- if not isinstance(args, dict):
1303
- args = {}
1304
- calls.append({"id": tc.get("id") or ("call_%d" % (len(calls) + 1)),
1305
- "name": fn.get("name") or "", "args": args})
1306
- u = data.get("usage") or {}
1307
- usage["input"] = int(u.get("prompt_tokens") or 0)
1308
- usage["output"] = int(u.get("completion_tokens") or 0)
1309
- usage["total"] = int(u.get("total_tokens") or 0) or (usage["input"] + usage["output"])
1310
- return (msg.get("content") or "").strip(), calls, usage
1311
-
1312
-
1313
- # ---------------------------------------------------------------- 主循环
1314
-
1315
- def _norm_usage(usage):
1316
- """三协议 usage → {input, output, cached, total}(缺 total 用三项相加补齐)。"""
1317
- out = {"input": 0, "output": 0, "cached": 0, "total": 0}
1318
- for k in out:
1319
- try:
1320
- out[k] = int((usage or {}).get(k) or 0)
1321
- except Exception:
1322
- out[k] = 0
1323
- if not out["total"]:
1324
- out["total"] = out["input"] + out["output"] + out["cached"]
1325
- return out
1326
-
1327
-
1328
- def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
1329
- on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None,
1330
- task_creator=None):
1331
- """跑一次内置智能体(内部自带工具循环直到给出最终回答)。
1332
-
1333
- bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
1334
- images: 用户随本轮消息提供的图片绝对路径列表(任务附件/对话追话);
1335
- log: 追加一行日志的回调(步骤日志)。返回 runner 风格统一结果:
1336
- {ok, text, usage, error, model, provider_name, provider_id, iterations,
1337
- cost_usd, reasoning}。
1338
-
1339
- 思考过程可视化(2026-09-22 用户诉求「把思考过程打印出来」):stream=True 走
1340
- SSE,模型的思维链(reasoning_content / thinking 块)边收边经 on_reason 回调,
1341
- 正文增量经 on_stream(累计值),工具活动经 on_activity——调用方据此在对话
1342
- 时间线上实时打印,不再让用户干看三点打字动画。网关忽略 stream(零事件)时
1343
- 自动回落非流式,思考字段仍从响应体兜底抽取(on_reason 补发一次)。"""
1344
- prov = bi["prov"]
1345
- model = bi["model"]
1346
- allow_private = bool(prov.get("allow_private"))
1347
- system = _SYSTEM_PROMPT + "\n\n## 工作目录\n%s" % os.path.abspath(workdir or ".")
1348
- imgs = _prep_images(images, log) if images else []
1349
- if imgs and not modelhub._model_image_in(prov, model):
1350
- # 能力闸门:模型未声明图片输入就不塞图(网关多半会拒),改为显式告知
1351
- if log:
1352
- log("[图片] 模型 %s 未开启图片输入,%d 张图不随消息发送" % (model, len(imgs)))
1353
- prompt = ("(用户提供了 %d 张图片,但当前模型未开启图片输入;请在回答开头提示用户"
1354
- "到绑定页为该模型开启「图」开关,或改绑多模态模型。本条回答请基于"
1355
- "文字部分完成。)\n\n%s" % (len(imgs), prompt))
1356
- imgs = []
1357
- msgs = [{"role": "user", "content": prompt, "images": imgs}]
1358
- candidates = list(modelhub._protocol_candidates(prov))
1359
- tools_ok = all(proto != "google" for proto, _ in candidates) # google wire 无工具协议
1360
- total_usage = {"input": 0, "cached": 0, "output": 0, "total": 0}
1361
- text = ""
1362
- iters = 0
1363
- last_err = ""
1364
- ok = False
1365
- reasons = [] # 各迭代的思考正文(按迭代顺序拼接,供步骤记录展示)
1366
- max_tokens = BASE_MAX_TOKENS # 思考占满预算时提额重试(跨迭代保持,见空正文分支)
1367
- escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
1368
- empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
1369
- last_action_was_tools = False
1370
- repeat_tool_signature = ""
1371
- repeat_tool_streak = 0
1372
- repeat_stop = False
1373
- finalize_attempted = False
1374
- perf = {"ttft": [], "tps": []} # 各次流式调用的首字延迟(ms)与吞吐(tok/s)
1375
-
1376
- def _perf():
1377
- """本次 run 的体验指标:跨迭代取均值;无流式样本时返回空 dict。"""
1378
- if not perf["ttft"] and not perf["tps"]:
1379
- return {}
1380
- return {"first_token_ms": (round(sum(perf["ttft"]) / len(perf["ttft"]), 1)
1381
- if perf["ttft"] else 0.0),
1382
- "tokens_per_sec": (round(sum(perf["tps"]) / len(perf["tps"]), 2)
1383
- if perf["tps"] else 0.0)}
1384
-
1385
- def _deadline_fail():
1386
- return {"ok": False, "text": "", "usage": dict(total_usage),
1387
- "error": "任务总时限已到", "model": model,
1388
- "provider_name": bi["provider_name"],
1389
- "provider_id": bi["provider_id"], "iterations": iters,
1390
- "cost_usd": 0.0, "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS],
1391
- "raw": {"timed_out": True, "deadline_exceeded": True}}
1392
-
1393
- def _fire(cb, *cb_args):
1394
- if cb is None:
1395
- return
1396
- try:
1397
- cb(*cb_args)
1398
- except Exception:
1399
- pass # 可视化是增强:回调炸了不能拖垮生成
1400
-
1401
- def _take_reason(reason):
1402
- """收一段思考:攒进本轮正文并实时回调(限长,防超长思维链撑爆 run.json)。"""
1403
- if not reason:
1404
- return
1405
- if not reasons:
1406
- reasons.append("")
1407
- if len(reasons[-1]) < THINK_MAX_CHARS:
1408
- reasons[-1] += reason
1409
- _fire(on_reason, reason)
1410
-
1411
- def _fail(err):
1412
- return {"ok": False, "text": "", "usage": dict(total_usage), "error": err,
1413
- "model": model, "provider_name": bi["provider_name"],
1414
- "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
1415
- "raw": _perf(),
1416
- "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
1417
-
1418
- def _cancel_fail():
1419
- """取消终态带结构化标记:下游惩罚账(KEY 冷却/供应商健康/评测作废)靠标记
1420
- 认出取消,不靠「已取消」字样匹配。"""
1421
- out = _fail("已取消")
1422
- out["cancelled"] = True
1423
- return out
1424
-
1425
- # 工具上限后最多再给一次“只输出总结”的机会。这样最后一轮已经
1426
- # 成功写文件/抓数据但尚未总结时,不会把真实成果误报成失败。
1427
- for it in range(1, MAX_TOOL_ITERS + 2):
1428
- force_finalize = it > MAX_TOOL_ITERS or repeat_stop
1429
- if force_finalize and (finalize_attempted or not last_action_was_tools):
1430
- break
1431
- if force_finalize and not finalize_attempted:
1432
- finalize_attempted = True
1433
- msgs.append({"role": "user", "content":
1434
- "请停止调用工具,只根据已经完成的操作直接给出最终答复。"
1435
- "列出已完成内容、产出文件和仍未完成的事项;不要继续探索。"})
1436
- if log:
1437
- log("[收尾] 工具循环已到边界,发起一次无工具最终总结")
1438
- _fire(on_activity, "工具循环到边界,正在生成最终总结")
1439
- if deadline is not None and time.monotonic() >= deadline:
1440
- return _deadline_fail()
1441
- if cancel_event is not None and cancel_event.is_set():
1442
- return _cancel_fail()
1443
- reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
1444
- done = False
1445
- reBump = False # 本轮要提额重试(同一迭代重跑,不烧迭代数)
1446
- stopit = False # 本轮判死(连续零正文),直接终局不再空转
1447
- for _bump in range(2): # 第 2 圈 = 提额后的同轮重试
1448
- for proto, pbase in candidates:
1449
- keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
1450
- for kk in keys:
1451
- got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
1452
- stripped = False # thinking 对象被网关 400 拒后已摘除重发
1453
- while True:
1454
- if deadline is not None and time.monotonic() >= deadline:
1455
- return _deadline_fail()
1456
- req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
1457
- if deadline is not None else timeout
1458
- allow_tools = tools_ok and not force_finalize
1459
- url, headers, body = _build_request(
1460
- proto, pbase, kk["key"], model, system, msgs,
1461
- allow_tools, max_tokens=max_tokens,
1462
- extra_specs=[CREATE_TASK_SPEC] if task_creator else None)
1463
- sbody = None
1464
- if stream:
1465
- # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
1466
- _, _, sbody = _build_request(
1467
- proto, pbase, kk["key"], model, system, msgs,
1468
- allow_tools, stream=True, max_tokens=max_tokens,
1469
- extra_specs=[CREATE_TASK_SPEC] if task_creator else None)
1470
- effort = str(bi.get("reasoning_effort") or "").strip().lower()
1471
- # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
1472
- # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
1473
- if effort in ("low", "medium", "high") and proto == "openai":
1474
- body["reasoning_effort"] = effort
1475
- if sbody is not None:
1476
- sbody["reasoning_effort"] = effort
1477
- if effort in ("low", "medium") and proto == "anthropic" \
1478
- and not stripped:
1479
- # 「思考程度」此前只对 openai 面生效;GLM 这类推理模型
1480
- # 在 Anthropic 兼容面上默认全功率思考,能把 max_tokens
1481
- # 整个烧光(2026-09-22 glm-5.3-flash 47 分钟零正文案)。
1482
- # low/medium 显式给思考预算,high 留默认(模型自决=放开想)。
1483
- body["thinking"] = {
1484
- "type": "enabled",
1485
- "budget_tokens": 1024 if effort == "low" else 4096}
1486
- if sbody is not None:
1487
- sbody["thinking"] = dict(body["thinking"])
1488
- text, calls, usage = "", [], {}
1489
- streamed = False
1490
- if sbody is not None:
1491
- part = _call_with_optional_deadline(
1492
- _post_sse_stream,
1493
- (url, headers, sbody, allow_private, req_timeout, proto),
1494
- {"on_reason": _take_reason, "on_text": on_stream,
1495
- "cancel_event": cancel_event}, deadline)
1496
- if part.get("deadline_exceeded"):
1497
- return _deadline_fail()
1498
- if part["error"] == "已取消":
1499
- return _cancel_fail()
1500
- if not part["error"] and part["events"]:
1501
- # 网关确实按流回了:正文/工具取增量累加结果
1502
- text, calls = part["text"], part["calls"]
1503
- usage = part["usage"]
1504
- streamed = True
1505
- if part.get("first_token_ms"):
1506
- perf["ttft"].append(float(part["first_token_ms"]))
1507
- if part.get("tokens_per_sec"):
1508
- perf["tps"].append(float(part["tokens_per_sec"]))
1509
- if not reasons or not reasons[-1]:
1510
- # 调用方没挂实时回调(无可视化诉求)时思考只在
1511
- # 累加器里:这里兜底收进结果,落库口径一致
1512
- _take_reason(part["reasoning"])
1513
- else:
1514
- # 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
1515
- # 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
1516
- # 供应商稳妥;非流式也失败才走下面的记账/换链。
1517
- last_err = part["error"]
1518
- if not streamed:
1519
- status, data, err = _call_with_optional_deadline(
1520
- _post_interruptible,
1521
- (url, headers, body, allow_private, req_timeout, cancel_event),
1522
- {}, deadline)
1523
- if err == "任务总时限已到":
1524
- return _deadline_fail()
1525
- if cancel_event is not None and cancel_event.is_set():
1526
- # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
1527
- return _cancel_fail()
1528
- if status == 0 or not (200 <= status < 300):
1529
- msg = ""
1530
- if isinstance(data, dict):
1531
- e = data.get("error")
1532
- msg = e.get("message", "") if isinstance(e, dict) else str(e)
1533
- if status == 400 and isinstance(body.get("thinking"), dict) \
1534
- and "thinking" in str(msg).lower() and not stripped:
1535
- # 网关不认 thinking 对象:摘掉同 KEY 立即重发,
1536
- # 别让「思考程度」偏好把健康 KEY 送进冷却
1537
- body.pop("thinking", None)
1538
- if sbody is not None:
1539
- sbody.pop("thinking", None)
1540
- stripped = True
1541
- continue
1542
- last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
1543
- try:
1544
- modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
1545
- except Exception:
1546
- pass
1547
- break # 换下一个 KEY
1548
- text, calls, usage = _parse_reply(proto, data)
1549
- # 非流式响应里的思考字段:补发一次,可视化与流式路径同形
1550
- _take_reason(_reason_from_message(proto, data))
1551
- try:
1552
- modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
1553
- except Exception:
1554
- pass
1555
- usage = _norm_usage(usage)
1556
- for k in total_usage:
1557
- total_usage[k] += int(usage.get(k) or 0)
1558
- iters = it
1559
- got = True
1560
- break
1561
- if not got:
1562
- continue # 本 KEY 失败,换下一个
1563
- if calls and force_finalize:
1564
- # 收尾请求明确禁用工具;模型若仍返回 tool call,不能再
1565
- # 开启新一轮,否则会绕过止损边界。
1566
- last_err = "收尾时模型仍请求工具"
1567
- last_action_was_tools = False
1568
- done = True
1569
- break
1570
- if calls:
1571
- sig = json.dumps(
1572
- [(c.get("name"), c.get("args") or {}) for c in calls],
1573
- ensure_ascii=False, sort_keys=True, separators=(",", ":"))
1574
- if sig == repeat_tool_signature:
1575
- repeat_tool_streak += 1
1576
- else:
1577
- repeat_tool_signature = sig
1578
- repeat_tool_streak = 1
1579
- if repeat_tool_streak >= 3:
1580
- repeat_stop = True
1581
- if log:
1582
- log("[迭代 %d] 检测到同一工具调用连续重复,转入收尾" % it)
1583
- _fire(on_activity, "检测到重复工具调用,正在收尾")
1584
- if log:
1585
- log("[迭代 %d] %s 请求工具: %s" % (
1586
- it, model, ", ".join(c["name"] for c in calls)))
1587
- _fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
1588
- results = []
1589
- for c in calls:
1590
- out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
1591
- deadline=deadline, task_creator=task_creator)
1592
- # 运行期注入警示层:外部内容(网页/命令输出/文件)一律
1593
- # 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
1594
- try:
1595
- from . import prompt_guard
1596
- out = prompt_guard.wrap_external(out, c["name"])
1597
- except Exception:
1598
- pass
1599
- if log:
1600
- brief = out if len(out) <= 120 else out[:120] + "…"
1601
- log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
1602
- _fire(on_activity, "%s %s" % (
1603
- c["name"], str(c["args"].get("path")
1604
- or c["args"].get("command") or "")[:80]))
1605
- results.append((c["id"], out))
1606
- msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
1607
- msgs.append({"role": "tool_results", "tool_results": results})
1608
- empty_streak = 0
1609
- last_action_was_tools = True
1610
- done = True
1611
- break
1612
- if (text or "").strip():
1613
- if log:
1614
- log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
1615
- ok = True
1616
- empty_streak = 0
1617
- last_action_was_tools = False
1618
- done = True
1619
- break
1620
- # —— 零正文零工具:分辨「思考占满输出预算」还是「模型真没说话」。
1621
- # 推理模型的思考计入 max_tokens:想满了流会正常收,正文却是空的。
1622
- last_action_was_tools = False
1623
- rlen = len(reasons[-1] or "")
1624
- out_tok = int(usage.get("output") or 0)
1625
- exhausted = (rlen >= THINK_EXHAUST_MIN_CHARS
1626
- or out_tok >= int(max_tokens * 0.9))
1627
- empty_streak += 1
1628
- if exhausted and not escalated:
1629
- # 同轮提额重试一次:8K 想不完的 16K 大多就想完了
1630
- escalated = True
1631
- old = max_tokens
1632
- max_tokens = min(max_tokens * 2, MAX_TOKENS_CAP)
1633
- last_err = ("模型思考占满输出预算(max_tokens=%d,思考 %d 字零正文)"
1634
- % (old, rlen))
1635
- if log:
1636
- log("[第 %d 轮] 思考 %d 字仍无正文/工具 → max_tokens %d→%d 同轮重试"
1637
- % (it, rlen, old, max_tokens))
1638
- _fire(on_activity, "思考占满输出预算,提高输出预算重试")
1639
- reBump = True
1640
- break
1641
- if empty_streak >= EMPTY_TEXT_MAX_STREAK:
1642
- last_err = ("模型思考连续占满输出预算(max_tokens 已提到 %d 仍无正文)"
1643
- % max_tokens) if exhausted else \
1644
- ("模型连续 %d 轮零正文零工具" % empty_streak)
1645
- done = True
1646
- stopit = True
1647
- break
1648
- last_err = "模型未返回文本"
1649
- if log:
1650
- log("[第 %d 轮] 模型零正文零工具(思考 %d 字),重试" % (it, rlen))
1651
- _fire(on_activity, "模型零正文,重试")
1652
- done = True
1653
- break
1654
- if done or reBump:
1655
- break
1656
- if reBump:
1657
- continue # 同一迭代用新预算重来
1658
- break # 非提额路径一锤定音
1659
- if not done:
1660
- break # 所有 wire/KEY 都失败
1661
- if ok or stopit:
1662
- break
1663
- if not ok and not text and finalize_attempted:
1664
- # 工具已经执行过且收尾调用也未能给正文:把真实执行结果作为“部分完成”
1665
- # 返回。pipeline 会按成功步骤落盘,用户可以继续追问而不是被迫重跑。
1666
- partial = _fail(last_err or "已完成工具操作,但未生成最终总结")
1667
- partial["ok"] = True
1668
- partial["partial"] = True
1669
- partial["text"] = ("部分完成:已执行部分操作,但未能生成最终总结。"
1670
- "请检查工作目录中的产出文件,或继续追问以完成剩余事项。")
1671
- partial["error"] = last_err or "已完成工具操作,但未生成最终总结"
1672
- partial.setdefault("raw", {})["partial"] = True
1673
- return partial
1674
- if not ok and not text:
1675
- return _fail(last_err or "工具循环达上限仍无最终回答")
1676
- return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),
1677
- "error": "", "model": model, "provider_name": bi["provider_name"],
1678
- "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
1679
- "raw": _perf(),
1680
- "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
1
+ # -*- coding: utf-8 -*-
2
+ """内置执行智能体:不经外部 CLI,直连供应商 API 完成直连(direct)任务。
3
+
4
+ 为什么:direct 是快档位,典型诉求是问答/轻量改写。拉起 codex/claude CLI 要付
5
+ 进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
6
+ 对话视图。内置智能体直接打 chat API + 工具循环:
7
+ - 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
8
+ - 工具:list_files / read_file / write_file(锁死工作目录内)+ run_command
9
+ (本机全权执行命令,产品定位全信任,与 codex/claude 全权沙箱同档);
10
+ - 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
11
+ 密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import fnmatch
17
+ import io
18
+ import json
19
+ import mimetypes
20
+ import os
21
+ import re
22
+ import secrets
23
+ import shutil
24
+ import stat
25
+ import sys
26
+ import threading
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+ from pathlib import Path
31
+
32
+ from . import modelhub, runner, tlsctx
33
+
34
+ MAX_TOOL_ITERS = 32 # 单步工具循环上限(防模型打转;收尾另留 1 轮)
35
+ READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
36
+ READ_HEAD_BYTES = 44 * 1024 # TokenJuice 借鉴(openhuman):超限文件头尾保留、
37
+ READ_TAIL_BYTES = 16 * 1024 # 中段省略——日志/代码的报错常在尾部,纯截头会丢关键信息
38
+ LIST_MAX_ENTRIES = 200
39
+ MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体防爆)
40
+ IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
41
+ IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
42
+ IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
43
+ THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
44
+ STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
45
+ BASE_MAX_TOKENS = 8000 # 单次生成输出预算(推理模型的思考 token 也计入此额度)
46
+ MAX_TOKENS_CAP = 16000 # 思考撑爆预算后的提额上限(每轮 run 只提一次)
47
+ THINK_EXHAUST_MIN_CHARS = 1500 # 零正文时思考达到此长度 ≈ 思考占满输出预算
48
+ EMPTY_TEXT_MAX_STREAK = 3 # 连续零正文轮数上限(防同参数盲重试烧穿预算)
49
+
50
+ CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
51
+ CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
52
+ CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
53
+ CMD_OUT_TAIL = 8 * 1024
54
+ TOOL_HISTORY_KEEP_ROUNDS = 4
55
+ TOOL_HISTORY_OLD_RESULT_CHARS = 1200
56
+ MAX_DIRECT_CONTEXT_CHARS = 48000 # ~12K tokens; keep direct chat within small model windows
57
+ _TOOL_OUTPUT_MARKER_RE = re.compile(
58
+ r"\n…\[较早结果已卸载到本机;需查看全文请调用 read_tool_output,"
59
+ r"ref=([a-f0-9]{32}) \]…\n")
60
+
61
+ _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
62
+
63
+ ## 工作方式
64
+ - 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
65
+ - 改文件的优先级:小改动用 edit_file(精确查找替换,不必整份重写)>追加用 append_file>整份重建才用 write_file。删除/移动/重命名/复制用 fs_manage,不要绕 shell。
66
+ - 大文件(超 64KB)默认只读头尾、中段省略;要看全用 read_file 的 offset/lines 分段读(返回头会提示下一段的 offset)。找内容用 search_content(带行号的 grep),找文件用 find_files,不要逐个文件读。
67
+ - read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
68
+ - 较早工具结果若提示已卸载并给出 ref,使用 read_tool_output 按需读取(可传 offset 分段);不要假设压缩标记就是完整结果。
69
+ - 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
70
+ - run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
71
+ - 当工具列表中提供 create_task 时,如果用户明确要求开始另一项 CodeBee 工作(例如“新建连载小说任务”“再开一个代码任务”),调用它发起独立任务;不要只在当前回答里描述应该怎么做。先从用户话里提取任务类型和目标,缺少关键目标时再追问。
72
+ - Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
73
+ - 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
74
+ - 产出文件一律 UTF-8 编码。
75
+ - 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。
76
+ - 核心产出完成后立即停止探索并总结;不要为“再确认一次”重复读取或重复写入。
77
+ - 每次工具调用后都要判断是否已经达到目标;如果工具轮次即将耗尽,先完成已开始的操作,随后直接给出已完成内容、产出文件和未完成项。
78
+ """
79
+
80
+
81
+ def resolve(provider_id="", model="", difficulty="default"):
82
+ """选内置智能体可用的 (provider, model):编排者配置优先,否则首个可用供应商。
83
+
84
+ 返回 {prov, model, provider_id, provider_name} 或 None(无可用供应商 → 调用方
85
+ 回退 CLI 路径)。"""
86
+ provider_id = str(provider_id or "").strip()
87
+ model = str(model or "").strip()
88
+ if provider_id:
89
+ for p in modelhub.providers():
90
+ if p.get("id") != provider_id or not p.get("enabled", True) or not p.get("api_key"):
91
+ continue
92
+ selected = model
93
+ if selected:
94
+ try:
95
+ enabled_names = [m.get("name") for m in modelhub._enabled_models(p)]
96
+ except Exception:
97
+ enabled_names = []
98
+ if selected not in enabled_names:
99
+ return None
100
+ if not selected:
101
+ selected = (p.get("model_hard") if difficulty == "hard" else
102
+ p.get("model_easy") if difficulty == "easy" else
103
+ p.get("model") or "").strip()
104
+ if not selected:
105
+ try:
106
+ names = modelhub._enabled_models(p)
107
+ except Exception:
108
+ names = []
109
+ selected = names[0]["name"] if names else ""
110
+ if selected:
111
+ return {"prov": p, "model": selected,
112
+ "provider_id": p.get("id") or "",
113
+ "provider_name": p.get("name") or p.get("id") or ""}
114
+ return None
115
+ orch = None
116
+ try:
117
+ orch = modelhub.resolve_orchestrator()
118
+ except Exception:
119
+ orch = None
120
+ if orch:
121
+ prov, model = orch
122
+ return {"prov": prov, "model": model,
123
+ "provider_id": prov.get("id") or "",
124
+ "provider_name": prov.get("name") or prov.get("id") or ""}
125
+ for p in modelhub.providers():
126
+ if not p.get("enabled", True) or not p.get("api_key"):
127
+ continue
128
+ m = ((p.get("model_hard") if difficulty == "hard" else
129
+ p.get("model_easy") if difficulty == "easy" else
130
+ p.get("model")) or "").strip()
131
+ if not m:
132
+ try:
133
+ names = modelhub._enabled_models(p)
134
+ except Exception:
135
+ names = []
136
+ m = names[0]["name"] if names else ""
137
+ if m:
138
+ return {"prov": p, "model": m, "provider_id": p.get("id") or "",
139
+ "provider_name": p.get("name") or p.get("id") or ""}
140
+ return None
141
+
142
+
143
+ # ---------------------------------------------------------------- 工作目录工具
144
+ # 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
145
+ # 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
146
+
147
+ def _is_linklike(path):
148
+ """Treat symlinks and Windows junctions alike when enumerating a sandbox root."""
149
+ try:
150
+ if path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction()):
151
+ return True
152
+ # Path.is_junction() was added in Python 3.12. On supported older
153
+ # Windows versions, detect all reparse points directly from lstat so
154
+ # junctions cannot bypass a narrowed allowed_roots policy.
155
+ attributes = getattr(path.lstat(), "st_file_attributes", 0)
156
+ reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
157
+ return bool(attributes & reparse_point)
158
+ except OSError:
159
+ return True
160
+
161
+ def _tool_list_files(workdir, args):
162
+ rel = str(args.get("path") or "").replace("\\", "/").strip("/")
163
+ if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
164
+ return "(非法路径: %s)" % rel
165
+ base = Path(workdir or ".").resolve()
166
+ p = (base / rel).resolve() if rel else base
167
+ if base not in p.parents and p != base:
168
+ return "(路径越界: %s)" % rel
169
+ if not p.is_dir():
170
+ return "(目录不存在: %s)" % (rel or ".")
171
+ out = []
172
+ for root, dirs, files in os.walk(str(p)):
173
+ dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")
174
+ and not _is_linklike(Path(root) / d)]
175
+ for f in files:
176
+ fp = os.path.join(root, f)
177
+ if _is_linklike(Path(fp)):
178
+ continue
179
+ relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
180
+ try:
181
+ size = os.path.getsize(fp)
182
+ except OSError:
183
+ size = -1
184
+ out.append("%s (%d B)" % (relp, size))
185
+ if len(out) >= LIST_MAX_ENTRIES:
186
+ return "\n".join(out) + "\n…(截断,共 200+ 项)"
187
+ return "\n".join(out) or "(空目录)"
188
+
189
+
190
+ _BIN_MAGIC = (
191
+ (b"\x89PNG\r\n\x1a\n", "PNG 图片"),
192
+ (b"\xff\xd8\xff", "JPEG 图片"),
193
+ (b"GIF8", "GIF 图片"),
194
+ (b"%PDF-", "PDF 文档"),
195
+ (b"PK\x03\x04", "ZIP 压缩包"),
196
+ (b"\x1f\x8b", "GZIP 压缩包"),
197
+ (b"7z\xbc\xaf\x27\x1c", "7z 压缩包"),
198
+ (b"Rar!\x1a\x07", "RAR 压缩包"),
199
+ (b"\x00\x00\x01\x00", "ICO 图标"),
200
+ (b"OggS", "OGG 音频"),
201
+ (b"ID3", "MP3 音频"),
202
+ )
203
+
204
+
205
+ def _bin_format(head):
206
+ """常见二进制魔数 → 人类可读格式名(写进给模型的提示,让它立刻止损)。"""
207
+ if len(head) >= 12 and head[:4] == b"RIFF":
208
+ return {"WEBP": "WEBP 图片", "WAVE": "WAV 音频",
209
+ "AVI ": "AVI 视频"}.get(head[8:12].decode("ascii", "replace"),
210
+ "RIFF 媒体")
211
+ if len(head) >= 8 and head[4:8] == b"ftyp":
212
+ return "MP4/MOV 视频"
213
+ for magic, name in _BIN_MAGIC:
214
+ if head.startswith(magic):
215
+ return name
216
+ return "二进制文件"
217
+
218
+
219
+ def _looks_binary(head):
220
+ """文本判定从宽:NUL 字节即判二进制(git 同款启发式),控制字符占比兜底
221
+ (部分二进制头 8KB 内无 NUL)。"""
222
+ if not head:
223
+ return False
224
+ if b"\x00" in head:
225
+ return True
226
+ sample = head[:8192]
227
+ ctrl = sum(1 for b in sample if b < 32 and b not in (9, 10, 12, 13))
228
+ return ctrl * 20 > len(sample) # >5% 视为二进制
229
+
230
+
231
+ def _trim_partial_utf8(chunk):
232
+ """头部截断可能把多字节字符切成两半:剥掉尾部不完整序列,否则 UTF-8
233
+ strict 失败会误落 GBK 解出错字(runner.tail_decoded 的头截断对偶)。"""
234
+ i = len(chunk) - 1
235
+ while i >= 0 and i >= len(chunk) - 3 and (chunk[i] & 0xC0) == 0x80:
236
+ i -= 1
237
+ if 0 <= i < len(chunk):
238
+ b = chunk[i]
239
+ need = 4 if (b & 0xF8) == 0xF0 else 3 if (b & 0xF0) == 0xE0 \
240
+ else 2 if (b & 0xC0) == 0xC0 else 1
241
+ if len(chunk) - i < need:
242
+ return chunk[:i]
243
+ return chunk
244
+
245
+
246
+ def _guess_image_mime(path, raw=b""):
247
+ m = mimetypes.guess_type(str(path))[0]
248
+ if m:
249
+ return m
250
+ if raw.startswith(b"\x89PNG"):
251
+ return "image/png"
252
+ if raw.startswith(b"\xff\xd8"):
253
+ return "image/jpeg"
254
+ if raw.startswith(b"GIF8"):
255
+ return "image/gif"
256
+ if raw.startswith(b"RIFF"):
257
+ return "image/webp"
258
+ return "image/png"
259
+
260
+
261
+ def _prep_images(paths, log=None):
262
+ """图片路径 → [(mime, b64)]。Pillow 可用时:长边超 1568px 等比缩(万级像素
263
+ 原图既烧 token 又常被网关拒),仍超 3.5MB 转 JPEG q85;Pillow 缺失或解码
264
+ 失败时原图直传,单张超 5MB 剔除并日志。最多 MAX_IMAGES 张。"""
265
+ try:
266
+ from PIL import Image
267
+ except Exception:
268
+ Image = None
269
+ out = []
270
+ for p in (paths or [])[:MAX_IMAGES]:
271
+ try:
272
+ raw = Path(p).read_bytes()
273
+ except OSError:
274
+ if log:
275
+ log("[图片] 跳过(读取失败): %s" % os.path.basename(str(p)))
276
+ continue
277
+ mime = _guess_image_mime(p, raw)
278
+ data = raw
279
+ if Image is not None:
280
+ try:
281
+ im = Image.open(io.BytesIO(raw))
282
+ im.load()
283
+ w, h = im.size
284
+ edge = max(w, h, 1)
285
+ if edge > IMAGE_MAX_EDGE:
286
+ r = IMAGE_MAX_EDGE / float(edge)
287
+ im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
288
+ Image.LANCZOS)
289
+ fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
290
+ "image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
291
+ buf = io.BytesIO()
292
+ im.save(buf, format=fmt) # GIF 多帧仅存首帧,可接受
293
+ data = buf.getvalue()
294
+ except Exception:
295
+ data = raw # 解码/编码失败退回原始字节
296
+ if (Image is not None and mime != "image/jpeg"
297
+ and len(data) > IMAGE_JPEG_BYTES):
298
+ try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
299
+ im = Image.open(io.BytesIO(data)).convert("RGB")
300
+ buf = io.BytesIO()
301
+ im.save(buf, format="JPEG", quality=85)
302
+ data = buf.getvalue()
303
+ mime = "image/jpeg"
304
+ except Exception:
305
+ pass
306
+ if len(data) > IMAGE_MAX_BYTES:
307
+ if log:
308
+ log("[图片] 剔除(过大 %d MB): %s"
309
+ % (len(data) // (1024 * 1024), os.path.basename(str(p))))
310
+ continue
311
+ out.append((mime, base64.b64encode(data).decode("ascii")))
312
+ return out
313
+
314
+
315
+ def _tool_read_file(workdir, args):
316
+ rel = str(args.get("path") or "").replace("\\", "/").strip("/")
317
+ if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
318
+ return "(非法路径: %s)" % rel
319
+ base = Path(workdir or ".").resolve()
320
+ p = (base / rel).resolve()
321
+ if base not in p.parents:
322
+ return "(路径越界: %s)" % rel
323
+ if not p.is_file():
324
+ return "(文件不存在: %s)" % rel
325
+ # 分段读取(P0 基础件):offset/lines 任一非空即按行号范围读——超 64KB
326
+ # 被「头尾保留、中段省略」截断的大文件靠它读全(用户实测 _lessons_dump.txt
327
+ # 68KB 被截案)。行号 1-based;逐行切片后不强行拼回整份。
328
+ offset_raw = str(args.get("offset") or "").strip()
329
+ lines_raw = str(args.get("lines") or "").strip()
330
+ if offset_raw or lines_raw:
331
+ try:
332
+ size = p.stat().st_size
333
+ except OSError as e:
334
+ return "(读取失败: %s)" % e
335
+ if size > READ_EDIT_MAX_BYTES:
336
+ return ("(文件过大 %d MB,分段读取上限 %d MB;请用 run_command 处理)"
337
+ % (size // (1024 * 1024), READ_EDIT_MAX_BYTES // (1024 * 1024)))
338
+ try:
339
+ offset = max(1, int(float(offset_raw or 1)))
340
+ lines = max(1, min(READ_SEGMENT_MAX_LINES,
341
+ int(float(lines_raw or READ_SEGMENT_DEFAULT_LINES))))
342
+ except (TypeError, ValueError):
343
+ return "(offset/lines 须为整数行号)"
344
+ all_lines = runner.decode_output(p.read_bytes()).splitlines()
345
+ total = len(all_lines)
346
+ if total == 0:
347
+ return "(空文件)"
348
+ if offset > total:
349
+ return "(offset %d 超出文件总行数 %d)" % (offset, total)
350
+ seg = all_lines[offset - 1:offset - 1 + lines]
351
+ head = "第 %d–%d 行(共 %d 行;用 offset=%d 续读下一段)" % (
352
+ offset, offset - 1 + len(seg), total, offset + len(seg))
353
+ return head + "\n" + ("\n".join(seg) or "(该范围无内容)")
354
+ data = p.read_bytes()[:READ_MAX_BYTES + 1]
355
+ truncated = len(data) > READ_MAX_BYTES
356
+ if truncated:
357
+ # 头尾保留、中段省略(TokenJuice 借鉴):头 44k 给结构/开头上下文,
358
+ # 尾 16k 给报错/结论(日志与代码的关键信息常在末尾),中段标注省略量
359
+ try:
360
+ size = p.stat().st_size
361
+ except OSError:
362
+ size = len(data)
363
+ with open(p, "rb") as fh:
364
+ head = fh.read(READ_HEAD_BYTES)
365
+ fh.seek(max(0, size - READ_TAIL_BYTES))
366
+ tail = fh.read(READ_TAIL_BYTES)
367
+ head = _trim_partial_utf8(head)
368
+ # 尾段开头的残缺多字节序列剥头(对偶于 _trim_partial_utf8 剥尾)
369
+ while tail:
370
+ try:
371
+ tail.decode("utf-8")
372
+ break
373
+ except UnicodeDecodeError:
374
+ tail = tail[1:]
375
+ text = runner.decode_output(head) \
376
+ + "\n…(中段省略 %d 字节;头 %dKB + 尾 %dKB 保留)\n" % (
377
+ max(0, size - READ_HEAD_BYTES - READ_TAIL_BYTES),
378
+ READ_HEAD_BYTES // 1024, READ_TAIL_BYTES // 1024) \
379
+ + runner.decode_output(tail)
380
+ return "…(文件超 64KB,已头尾保留读取)\n" + text
381
+ head = data
382
+ if not head:
383
+ return "(空文件)"
384
+ if _looks_binary(head):
385
+ try:
386
+ size = p.stat().st_size
387
+ except OSError:
388
+ size = len(head)
389
+ fmt = _bin_format(head)
390
+ if "图片" in fmt:
391
+ return ("(图片文件: %s — %s,%d B,无法按文本读取。用户以附件发来的图片会"
392
+ "直接出现在对话中,无需用工具读取;若需要目录里这张图的内容,"
393
+ "请提示用户把它作为附件发送。)" % (rel, fmt, size))
394
+ return ("(二进制文件,无法按文本读取: %s — %s,%d B。read_file 只支持文本文件;"
395
+ "请如实告知用户该文件内容无法以文本方式查看,不要反复重读。)"
396
+ % (rel, fmt, size))
397
+ text = runner.decode_output(head)
398
+ return text
399
+
400
+
401
+ def _tool_write_file(workdir, args):
402
+ rel = str(args.get("path") or "").replace("\\", "/").strip("/")
403
+ if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
404
+ return "(非法路径: %s)" % rel
405
+ content = str(args.get("content") if args.get("content") is not None else "")
406
+ base = Path(workdir or ".").resolve()
407
+ dest = (base / rel).resolve()
408
+ if base not in dest.parents:
409
+ return "(路径越界: %s)" % rel
410
+ dest.parent.mkdir(parents=True, exist_ok=True)
411
+ # newline="":不翻译换行——模型回写 read_file 读到的 CRLF 内容时,
412
+ # 默认翻译会二次翻成 \r\r\n
413
+ with dest.open("w", encoding="utf-8", newline="") as fh:
414
+ fh.write(content)
415
+ return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
416
+
417
+
418
+ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=None):
419
+ """本机执行一条 shell 命令,返回 (退出码, 合并输出)。
420
+
421
+ 薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
422
+ → cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
423
+ (用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
424
+ 提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
425
+ 输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
426
+ cmdline = str(args.get("command") or "").strip()
427
+ if not cmdline:
428
+ return "(命令为空,未执行)"
429
+ from . import policy
430
+ sandbox = sandbox or policy.normalize_sandbox({}, workdir)
431
+ timeout = _command_timeout(args, sandbox)
432
+ cwd = os.path.abspath(workdir or ".")
433
+ if not policy.path_allowed(cwd, sandbox):
434
+ return "(沙箱拒绝:工作目录不在允许范围内,命令未执行)"
435
+ # This process runner has no OS-level network/filesystem namespace. Do not
436
+ # imply network isolation when policy requests it; fail closed instead.
437
+ if sandbox.get("network") is False:
438
+ if os.name != "posix" or not shutil.which("bwrap"):
439
+ return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
440
+ # Enforce the declared filesystem boundary at the process boundary too.
441
+ # bubblewrap is the supported Linux backend. Windows/macOS without an
442
+ # equivalent backend fail closed rather than pretending cwd is a sandbox.
443
+ if os.name == "posix" and shutil.which("bwrap"):
444
+ argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
445
+ # Mount only the runtime needed by ordinary commands. Do not expose the
446
+ # entire host root read-only: read access can still leak user secrets.
447
+ for system_path in ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"):
448
+ if os.path.exists(system_path):
449
+ argv.extend(["--dir", system_path, "--ro-bind", system_path, system_path])
450
+ for volatile_path in ("/proc", "/dev"):
451
+ argv.extend(["--dir", volatile_path])
452
+ argv.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"])
453
+ if sandbox.get("network") is False:
454
+ argv.append("--unshare-net")
455
+ for allowed_root in sandbox.get("allowed_roots") or []:
456
+ root_path = str(Path(allowed_root).resolve())
457
+ parents = list(reversed(Path(root_path).parents))
458
+ for parent in parents:
459
+ if str(parent) != "/":
460
+ argv.extend(["--dir", str(parent)])
461
+ argv.extend(["--dir", root_path])
462
+ argv.extend(["--bind", root_path, root_path])
463
+ argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
464
+ elif os.name == "posix" and sys.platform == "darwin":
465
+ return "(沙箱拒绝:当前 macOS 执行器尚未接入文件系统隔离后端,命令未执行)"
466
+ elif os.name != "posix":
467
+ return "(沙箱拒绝:当前 Windows 执行器尚未接入文件系统隔离后端,命令未执行)"
468
+ else:
469
+ return "(沙箱拒绝:未安装 bubblewrap 隔离后端,命令未执行)"
470
+ env_policy = dict(sandbox)
471
+ env_policy["env_allowlist"] = list(dict.fromkeys(
472
+ list(sandbox.get("env_allowlist") or [])
473
+ + ["PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC", "HOME", "USERPROFILE"]))
474
+ r = runner.run_process(argv=argv, cwd=cwd,
475
+ env=policy.filter_env(os.environ, env_policy),
476
+ timeout=min(timeout, CMD_MAX_TIMEOUT),
477
+ deadline=deadline, cancel_event=cancel_event)
478
+ out = runner.clean_cli_text(
479
+ (r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
480
+ limit = min(CMD_OUT_HEAD + CMD_OUT_TAIL,
481
+ max(1024, int(sandbox.get("max_output_bytes") or CMD_OUT_HEAD + CMD_OUT_TAIL)))
482
+ if not out.strip():
483
+ body = "(无输出)"
484
+ elif len(out) <= limit:
485
+ body = out
486
+ else:
487
+ body = (out[:CMD_OUT_HEAD]
488
+ + "\n…(中段省略 %d 字符;头 %dK + 尾 %dK 保留)\n" % (
489
+ len(out) - limit, CMD_OUT_HEAD // 1024, CMD_OUT_TAIL // 1024)
490
+ + out[-CMD_OUT_TAIL:])
491
+ if r.get("cancelled"):
492
+ note = "已取消,进程树已终止"
493
+ elif r.get("timed_out"):
494
+ note = "超时 %d 秒,进程树已强制终止" % timeout
495
+ else:
496
+ note = ""
497
+ prefix = ("(%s)\n" % note) if note else ""
498
+ code = r.get("exit_code")
499
+ return ("%s退出码: %s\n%s\n(注意:shell 命令副作用不纳入文件检查点,"
500
+ "不能保证可回滚)") % (prefix, "未知" if code is None else code, body)
501
+
502
+
503
+ def _command_timeout(args, sandbox):
504
+ """Resolve the shell timeout without exceeding the task's sandbox budget."""
505
+ try:
506
+ requested = int(float((args or {}).get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
507
+ except (TypeError, ValueError):
508
+ requested = CMD_DEFAULT_TIMEOUT
509
+ try:
510
+ policy_limit = int(float((sandbox or {}).get("timeout_s") or CMD_MAX_TIMEOUT))
511
+ except (TypeError, ValueError):
512
+ policy_limit = CMD_MAX_TIMEOUT
513
+ return max(1, min(requested, policy_limit, CMD_MAX_TIMEOUT))
514
+
515
+
516
+ # ---------------------------------------------------------------- P0 基础件扩展
517
+ # 2026-09-25 用户实测反馈:改大文件只能整份读写、删/移/改名只能绕 shell、
518
+ # 大文件 64KB 中段被截、找内容只能逐文件读——补齐四个基础件(局部编辑、
519
+ # 文件管理、分段读取、全文搜索),全部锁死工作目录内。
520
+
521
+ READ_EDIT_MAX_BYTES = 16 * 1024 * 1024 # edit/append 分段读全文解码上限
522
+ SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024 # search_content 单文件参与搜索上限
523
+ SEARCH_DEFAULT_RESULTS = 50 # 命中行默认上限
524
+ READ_SEGMENT_DEFAULT_LINES = 200 # read_file 分段默认行数
525
+ READ_SEGMENT_MAX_LINES = 2000 # read_file 分段行数上限
526
+
527
+ _WALK_PRUNE = {".git", "__pycache__", "node_modules", ".venv", "venv",
528
+ "dist", "build", "target", ".idea", ".vscode", "__MACOSX",
529
+ ".pytest_cache", ".mypy_cache"}
530
+
531
+
532
+ def _guard_rel(workdir, rel, allow_root=False):
533
+ """工作目录相对路径守卫(list/read/write 同款惯用法抽公共)。
534
+
535
+ 返回 (resolve 后的 Path, 错误串);合法时错误串为 None。空 rel 仅在
536
+ allow_root=True 时代表工作目录根。"""
537
+ rel = str(rel or "").replace("\\", "/").strip("/")
538
+ if (not rel and not allow_root) or ".." in Path(rel).parts \
539
+ or any(":" in seg for seg in Path(rel).parts):
540
+ return None, "(非法路径: %s)" % rel
541
+ base = Path(workdir or ".").resolve()
542
+ p = (base / rel).resolve() if rel else base
543
+ if base not in p.parents and p != base:
544
+ return None, "(路径越界: %s)" % rel
545
+ return p, None
546
+
547
+
548
+ def _read_text_capped(p, limit=READ_EDIT_MAX_BYTES):
549
+ """读文本并识别编码(bytes 上限防巨型文件)。返回 (text, err)。"""
550
+ try:
551
+ size = p.stat().st_size
552
+ except OSError as e:
553
+ return "", "(读取失败: %s)" % e
554
+ if size > limit:
555
+ return "", ("(文件过大 %d MB,超过 %d MB 上限;请用 run_command 处理)"
556
+ % (size // (1024 * 1024), limit // (1024 * 1024)))
557
+ data = p.read_bytes()
558
+ if _looks_binary(data[:8192]):
559
+ return "", "(二进制文件,%s 不适用: %s)" % ("文本编辑", _bin_format(data[:64]))
560
+ return runner.decode_output(data), None
561
+
562
+
563
+ def _tool_edit_file(workdir, args):
564
+ """精确查找替换:old_text 逐字符匹配,默认仅允许 1 处命中。"""
565
+ p, err = _guard_rel(workdir, args.get("path"))
566
+ if err:
567
+ return err
568
+ old_text = str(args.get("old_text") or "")
569
+ new_text = str(args.get("new_text") if args.get("new_text") is not None else "")
570
+ if not old_text:
571
+ return "(old_text 不能为空;改全文请用 write_file 整份写入)"
572
+ if not p.is_file():
573
+ return "(文件不存在: %s)" % p.name
574
+ text, err = _read_text_capped(p)
575
+ if err:
576
+ return err
577
+ count = text.count(old_text)
578
+ crlf = False
579
+ if count == 0 and "\r\n" in text and "\r\n" not in old_text:
580
+ # Windows 文件 CRLF:按 LF 形态重试匹配,命中则在原文上以 CRLF 替换,
581
+ # 保持文件既有换行风格(否则整个文件换行符被翻转)
582
+ crlf_text = text.replace("\r\n", "\n")
583
+ count = crlf_text.count(old_text)
584
+ crlf = count > 0
585
+ if count == 0:
586
+ return ("(old_text 在文件中找不到逐字符匹配——read_file 超过 64KB 会"
587
+ "中段省略,请用 offset/lines 分段读全后重试)")
588
+ replace_all = str(args.get("replace_all") or "").strip().lower() in ("1", "true", "yes")
589
+ if count > 1 and not replace_all:
590
+ return ("(old_text 命中 %d 处;请补充更多上下文使命中唯一,"
591
+ "或传 replace_all=true 全部替换)" % count)
592
+ if crlf:
593
+ old_text = old_text.replace("\n", "\r\n")
594
+ new_text = new_text.replace("\n", "\r\n")
595
+ # newline="" 禁止 Python 的 \n→os.linesep 翻译:文件既有 CRLF 时会被
596
+ # 二次翻译成 \r\r\n(Windows 实测损坏案),写什么落什么
597
+ with p.open("w", encoding="utf-8", newline="") as fh:
598
+ fh.write(text.replace(old_text, new_text))
599
+ return "已替换 %d 处(%s)" % (count if replace_all else 1, p.name)
600
+
601
+
602
+ def _tool_append_file(workdir, args):
603
+ """文件末尾追加(bytes 级追加,大文件不整份重写);不存在则创建。"""
604
+ p, err = _guard_rel(workdir, args.get("path"))
605
+ if err:
606
+ return err
607
+ content = str(args.get("content") if args.get("content") is not None else "")
608
+ if not content:
609
+ return "(content 不能为空)"
610
+ if p.is_file():
611
+ with open(p, "rb") as fh:
612
+ head = fh.read(8192)
613
+ if _looks_binary(head):
614
+ return "(二进制文件,不能按文本追加: %s — %s)" % (p.name, _bin_format(head))
615
+ p.parent.mkdir(parents=True, exist_ok=True)
616
+ with open(p, "ab") as fh:
617
+ fh.write(content.encode("utf-8"))
618
+ return "已追加 %d 字符到 %s(UTF-8)" % (len(content), p.name)
619
+
620
+
621
+ def _tool_fs_manage(workdir, args):
622
+ """删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝防覆盖)。"""
623
+ action = str(args.get("action") or "").strip().lower()
624
+ if action not in ("delete", "move", "rename", "copy"):
625
+ return "(action 须为 delete/move/rename/copy)"
626
+ p, err = _guard_rel(workdir, args.get("path"))
627
+ if err:
628
+ return err
629
+ if not p.exists() and not p.is_symlink():
630
+ return "(不存在: %s)" % p
631
+ if action == "delete":
632
+ if p.is_dir() and not p.is_symlink():
633
+ shutil.rmtree(str(p))
634
+ return "已删除目录(含全部内容): %s" % p.name
635
+ p.unlink()
636
+ return "已删除: %s" % p.name
637
+ dest, err = _guard_rel(workdir, args.get("dest"))
638
+ if err:
639
+ return err.replace("非法路径", "dest 非法路径").replace("路径越界", "dest 路径越界")
640
+ if dest.exists() or dest.is_symlink():
641
+ return "(目标已存在,拒绝覆盖: %s;先删除或换名)" % dest.name
642
+ dest.parent.mkdir(parents=True, exist_ok=True)
643
+ if action == "copy":
644
+ if p.is_dir():
645
+ shutil.copytree(str(p), str(dest))
646
+ else:
647
+ shutil.copy2(str(p), str(dest))
648
+ return "已复制 %s → %s" % (p.name, dest.name)
649
+ shutil.move(str(p), str(dest))
650
+ return "已%s %s → %s" % ("移动" if action == "move" else "重命名",
651
+ p.name, dest.name)
652
+
653
+
654
+ def _tool_search_content(workdir, args):
655
+ """grep 式内容搜索:正则(编译失败回退字面量),逐行命中带行号。"""
656
+ pattern = str(args.get("pattern") or "").strip()
657
+ if not pattern:
658
+ return "(pattern 不能为空)"
659
+ try:
660
+ rx = re.compile(pattern)
661
+ except re.error:
662
+ rx = re.compile(re.escape(pattern))
663
+ pattern_note = "(正则无效,已按字面量搜索)\n"
664
+ else:
665
+ pattern_note = ""
666
+ sub, err = _guard_rel(workdir, args.get("path"), allow_root=True)
667
+ if err:
668
+ return err
669
+ if not sub.is_dir():
670
+ return "(目录不存在: %s)" % args.get("path")
671
+ glob_pat = str(args.get("glob") or "").strip()
672
+ try:
673
+ max_results = max(1, min(200, int(float(args.get("max_results") or 0)
674
+ or SEARCH_DEFAULT_RESULTS)))
675
+ except (TypeError, ValueError):
676
+ max_results = SEARCH_DEFAULT_RESULTS
677
+ base = Path(workdir or ".").resolve()
678
+ out, hits, scanned = [], 0, 0
679
+ for root, dirs, files in os.walk(str(sub)):
680
+ dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
681
+ and not _is_linklike(Path(root) / d)]
682
+ for f in files:
683
+ if glob_pat:
684
+ relp = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
685
+ if not (fnmatch.fnmatch(f, glob_pat) or fnmatch.fnmatch(relp, glob_pat)):
686
+ continue
687
+ fp = os.path.join(root, f)
688
+ try:
689
+ if _is_linklike(Path(fp)):
690
+ continue
691
+ if os.path.getsize(fp) > SEARCH_MAX_FILE_BYTES:
692
+ continue
693
+ with open(fp, "rb") as fh:
694
+ head = fh.read(8192)
695
+ if _looks_binary(head):
696
+ continue
697
+ with open(fp, "rb") as fh:
698
+ data = fh.read()
699
+ except OSError:
700
+ continue
701
+ scanned += 1
702
+ rel = os.path.relpath(fp, str(base)).replace(os.sep, "/")
703
+ for i, line in enumerate(runner.decode_output(data).splitlines(), 1):
704
+ if rx.search(line):
705
+ out.append("%s:%d: %s" % (rel, i, line.strip()[:200]))
706
+ hits += 1
707
+ if hits >= max_results:
708
+ out.append("…(已达 %d 行上限,缩小 pattern/glob 或调大 max_results)"
709
+ % max_results)
710
+ return pattern_note + "\n".join(out)
711
+ if not out:
712
+ return pattern_note + ("(无命中;已扫描 %d 个文本文件)" % scanned)
713
+ return pattern_note + "\n".join(out) + "\n(共 %d 行命中 / 扫描 %d 个文件)" % (hits, scanned)
714
+
715
+
716
+ def _tool_find_files(workdir, args):
717
+ """文件名通配查找(fnmatch;pattern 不含 / 时匹配任意深度的文件名)。"""
718
+ pattern = str(args.get("pattern") or "").strip().replace("\\", "/")
719
+ if not pattern or ".." in pattern or ":" in pattern:
720
+ return "(非法 pattern: %s;示例 *.py 或 data/*.json)" % pattern
721
+ base = Path(workdir or ".").resolve()
722
+ sub, err = _guard_rel(workdir, args.get("path"), allow_root=True)
723
+ if err:
724
+ return err
725
+ if not sub.is_dir():
726
+ return "(目录不存在: %s)" % (args.get("path") or ".")
727
+ match_name = "/" not in pattern
728
+ out = []
729
+ for root, dirs, files in os.walk(str(sub)):
730
+ dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
731
+ and not _is_linklike(Path(root) / d)]
732
+ for f in files:
733
+ rel = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
734
+ if not (fnmatch.fnmatch(rel, pattern)
735
+ or (match_name and fnmatch.fnmatch(f, pattern))):
736
+ continue
737
+ if _is_linklike(Path(root) / f):
738
+ continue
739
+ try:
740
+ size = os.path.getsize(os.path.join(root, f))
741
+ except OSError:
742
+ size = -1
743
+ out.append("%s (%d B)" % (rel, size))
744
+ if len(out) >= LIST_MAX_ENTRIES:
745
+ return "\n".join(out) + "\n…(截断,共 %d+ 项)" % LIST_MAX_ENTRIES
746
+ return "\n".join(out) or "(无匹配文件)"
747
+
748
+
749
+ TOOLS_SPEC = [
750
+ {"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
751
+ "args": {"path": "子目录相对路径,留空=根目录"}},
752
+ {"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别;默认超 64KB 时保留开头与结尾、中段省略,此时用 offset/lines 分段读全;图片等二进制文件无法读取)",
753
+ "args": {"path": "文件相对路径",
754
+ "?offset": "可选,起始行号(1-based),配合 lines 分段读取大文件",
755
+ "?lines": "可选,读取行数(默认 200,最大 2000)"}},
756
+ {"name": "edit_file", "description": "对文件做精确查找替换(局部编辑,不必整份重写):old_text 必须与文件内容逐字符一致,默认要求命中唯一,配合 read_file 使用",
757
+ "args": {"path": "文件相对路径",
758
+ "old_text": "要被替换的原文(逐字符精确匹配)",
759
+ "new_text": "替换后的新文本(可为空串=删除)",
760
+ "?replace_all": "可选,true=替换全部命中(默认仅允许 1 处命中)"}},
761
+ {"name": "append_file", "description": "把文本追加到文件末尾(bytes 级追加,大文件不必整份重写);文件不存在则创建。追加日志、逐条记录时优先用它",
762
+ "args": {"path": "文件相对路径", "content": "追加到末尾的文本"}},
763
+ {"name": "fs_manage", "description": "文件管理:删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝覆盖,删目录会连内容一起删)",
764
+ "args": {"action": "delete | move | rename | copy",
765
+ "path": "源相对路径(目录或文件)",
766
+ "?dest": "move/rename/copy 的目标相对路径"}},
767
+ {"name": "search_content", "description": "全文内容搜索(grep 式):在工作目录内逐文件逐行匹配正则,命中行带「路径:行号:」前缀返回;自动跳过二进制与依赖目录(node_modules/.git 等)",
768
+ "args": {"pattern": "搜索文本或正则表达式",
769
+ "?path": "可选,限定搜索的子目录,默认根目录",
770
+ "?glob": "可选,文件名过滤,如 *.py",
771
+ "?max_results": "可选,命中行数上限(默认 50,最大 200)"}},
772
+ {"name": "find_files", "description": "按文件名通配查找文件(如 *.py、data/*.json、report*.md);pattern 不含 / 时匹配任意深度的文件名",
773
+ "args": {"pattern": "文件名通配模式",
774
+ "?path": "可选,限定搜索的子目录,默认根目录"}},
775
+ {"name": "read_tool_output", "description": "按 ref 重新读取本次运行中卸载到本机的较早工具输出;大结果可用 offset 分段读取",
776
+ "args": {"ref": "压缩提示提供的工具结果引用",
777
+ "?offset": "可选,字节偏移,默认 0",
778
+ "?limit": "可选,返回字符数,最大 20000"}},
779
+ {"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,整份覆盖,父目录自动创建;只改一小段请用 edit_file)",
780
+ "args": {"path": "文件相对路径", "content": "完整文本内容"}},
781
+ {"name": "run_command",
782
+ "description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
783
+ "args": {"command": "完整命令行(shell 语法,本机直接执行)",
784
+ "timeout_sec": "可选,超时秒数"}},
785
+ ]
786
+
787
+ _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
788
+ "write_file": _tool_write_file, "run_command": _tool_run_command,
789
+ "edit_file": _tool_edit_file, "append_file": _tool_append_file,
790
+ "fs_manage": _tool_fs_manage, "search_content": _tool_search_content,
791
+ "find_files": _tool_find_files}
792
+
793
+
794
+ CREATE_TASK_SPEC = {
795
+ "name": "create_task",
796
+ "description": "发起一个独立的 CodeBee 任务。用户要求开始另一类工作(例如创建连载小说、写代码或生成文档)时使用;任务会出现在任务列表并异步运行,不要把它当作当前对话里的普通回答。",
797
+ "args": {
798
+ "type": "任务类型 ID,例如 serial_novel、novel、code、doc、article",
799
+ "goal": "新任务的明确目标",
800
+ "?title": "新任务标题",
801
+ "?workdir": "绝对工作目录;留空沿用当前工作目录",
802
+ "?context": "补充背景或约束",
803
+ "?chapters": "serial_novel 首批章节数",
804
+ "?words_per_chapter": "serial_novel 每章目标字数",
805
+ "?start_chapter": "续写起始章节号",
806
+ "?variants": "连载同章候选稿数量(1-3)",
807
+ "?branches": "连载剧情分支数量(1-3)",
808
+ "?story_bible": "连载小说初始故事圣经",
809
+ },
810
+ }
811
+
812
+
813
+ def _tool_create_task(workdir, args, task_creator=None, sandbox=None):
814
+ """Create a separate task through the caller-owned task boundary."""
815
+ if task_creator is None:
816
+ return "(当前对话不支持发起新任务)"
817
+ task_type = str(args.get("type") or "").strip().lower()
818
+ goal = str(args.get("goal") or "").strip()
819
+ if not task_type:
820
+ return "(type 不能为空;可用类型请使用 serial_novel、novel、code、doc 等)"
821
+ if not goal:
822
+ return "(goal 不能为空)"
823
+ requested_workdir = str(args.get("workdir") or workdir or "").strip()
824
+ if sandbox and requested_workdir:
825
+ from . import policy
826
+ if not policy.path_allowed(requested_workdir, sandbox):
827
+ return "(沙箱拒绝:子任务工作目录超出允许范围,未创建)"
828
+ child_sandbox = dict(sandbox) if isinstance(sandbox, dict) else None
829
+ if child_sandbox and requested_workdir:
830
+ from . import policy
831
+ parent_root = Path(workdir or ".").expanduser().resolve()
832
+ child_root = Path(requested_workdir).expanduser().resolve()
833
+ if child_root != parent_root:
834
+ # The requested child directory is already checked against the
835
+ # parent's roots above. Rebase the inherited allowlist to that
836
+ # directory so task normalization cannot widen it back to a parent.
837
+ child_sandbox["allowed_roots"] = [str(child_root)]
838
+ payload = {
839
+ "type": task_type,
840
+ "goal": goal,
841
+ "title": str(args.get("title") or "").strip(),
842
+ "workdir": requested_workdir,
843
+ "context": str(args.get("context") or "").strip(),
844
+ }
845
+ if child_sandbox is not None:
846
+ payload["sandbox"] = child_sandbox
847
+ if task_type == "serial_novel":
848
+ serial = {}
849
+ for key in ("chapters", "words_per_chapter", "start_chapter", "variants", "branches"):
850
+ if args.get(key) not in (None, ""):
851
+ serial[key] = args.get(key)
852
+ if serial:
853
+ payload["serial"] = serial
854
+ if args.get("story_bible") not in (None, ""):
855
+ payload["story_bible"] = str(args.get("story_bible") or "").strip()
856
+ try:
857
+ result = task_creator(payload)
858
+ except Exception as exc:
859
+ return "(发起任务失败:%s)" % str(exc)[:300]
860
+ if not isinstance(result, dict) or not result.get("task_id"):
861
+ return "(发起任务失败:未返回任务编号)"
862
+ return "已发起新任务:%s(运行编号:%s,类型:%s)" % (
863
+ result["task_id"], result.get("run_id") or "", task_type)
864
+
865
+
866
+ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creator=None,
867
+ disabled_tools=None, sandbox=None, checkpoint_run_id="",
868
+ tool_output_run_id=""):
869
+ from . import policy
870
+ denied = set(policy.normalize_disabled_tools(disabled_tools))
871
+ if sandbox is None:
872
+ sandbox = policy.normalize_sandbox({}, workdir)
873
+ denied.update(sandbox.get("disabled_tools") or [])
874
+ if name in denied:
875
+ return "(工具已禁用,拒绝执行: %s)" % str(name or "")[:120]
876
+ # All built-in file tools are constrained to the task's allowed roots, not
877
+ # merely hidden from the model. Shell/MCP effects are handled separately.
878
+ if name in {"list_files", "read_file", "write_file", "edit_file", "append_file",
879
+ "fs_manage", "search_content", "find_files"}:
880
+ keys = ["path", "dest"]
881
+ # Enumeration tools default to the whole workspace. If policy grants
882
+ # only a narrower root, require an explicit in-scope path instead of
883
+ # silently exposing names/content outside that root.
884
+ if name in {"list_files", "search_content", "find_files"} \
885
+ and not (args or {}).get("path") \
886
+ and not policy.path_allowed(workdir or ".", sandbox):
887
+ return "(沙箱拒绝:此工具必须指定允许范围内的 path,未执行)"
888
+ for key in keys:
889
+ value = (args or {}).get(key)
890
+ if not value:
891
+ continue
892
+ candidate = Path(str(value)).expanduser()
893
+ if not candidate.is_absolute():
894
+ candidate = Path(workdir or ".") / candidate
895
+ if not policy.path_allowed(candidate, sandbox):
896
+ return "(沙箱拒绝:%s 超出允许工作目录,未执行)" % key
897
+ if name == "create_task":
898
+ return _tool_create_task(workdir, args or {}, task_creator=task_creator,
899
+ sandbox=sandbox)
900
+ if name == "read_tool_output":
901
+ from . import tool_outputs
902
+ result = tool_outputs.read(tool_output_run_id, (args or {}).get("ref"),
903
+ offset=(args or {}).get("offset", 0),
904
+ limit=(args or {}).get("limit", 20000))
905
+ if not result.get("ok"):
906
+ return "(%s)" % result.get("error", "工具结果不可用")
907
+ text = result.get("text") or ""
908
+ if result.get("next_offset") is not None:
909
+ text += "\n…[后续内容可继续 read_tool_output,offset=%s]…" % result["next_offset"]
910
+ return text
911
+ if str(name or "").startswith("mcp__"):
912
+ # MCP process launch enforces this policy through bubblewrap; platforms
913
+ # without the isolation backend fail closed in mcp_client._sandbox_launch.
914
+ remaining = None
915
+ if deadline is not None:
916
+ remaining = float(deadline) - time.monotonic()
917
+ if remaining <= 0:
918
+ return "(任务总时限已到,MCP 工具未执行)"
919
+ # MCP 工具:透传给配置的服务器(stdio JSON-RPC);超时给足但封顶
920
+ try:
921
+ from . import mcp_client
922
+ timeout_s = min(float(sandbox.get("timeout_s") or CMD_MAX_TIMEOUT),
923
+ remaining if remaining is not None else CMD_MAX_TIMEOUT)
924
+ r = mcp_client.dispatch_full_name(
925
+ name, args or {}, sandbox=sandbox, workdir=workdir,
926
+ disabled_tools=denied, timeout_s=timeout_s)
927
+ return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
928
+ except Exception as e:
929
+ return "MCP 工具执行失败: %s" % e
930
+ fn = _TOOL_IMPL.get(name or "")
931
+ if fn is None:
932
+ return "(未知工具: %s)" % name
933
+ try:
934
+ snapshot_paths = []
935
+ if checkpoint_run_id and name in {"write_file", "edit_file", "append_file", "fs_manage"}:
936
+ action = str((args or {}).get("action") or "").lower()
937
+ src_value = (args or {}).get("path")
938
+ src = Path(str(src_value)).expanduser() if src_value else None
939
+ if src is not None and not src.is_absolute():
940
+ src = Path(workdir or ".") / src
941
+ if src is not None:
942
+ if name == "fs_manage" and src.is_dir() and not src.is_symlink():
943
+ return "(检查点拒绝:目录级操作尚不支持完整快照,未执行;请明确改为逐文件操作)"
944
+ snapshot_paths.append(src)
945
+ if action in ("move", "rename", "copy") and (args or {}).get("dest"):
946
+ dest = Path(str(args["dest"])).expanduser()
947
+ if not dest.is_absolute():
948
+ dest = Path(workdir or ".") / dest
949
+ snapshot_paths.append(dest)
950
+ # Keep first-touch order while avoiding duplicate writes.
951
+ snapshot_paths = list(dict.fromkeys(snapshot_paths))
952
+ try:
953
+ from . import checkpoints
954
+ for path in snapshot_paths:
955
+ saved = checkpoints.capture_file(checkpoint_run_id, workdir, path)
956
+ if not saved.get("ok"):
957
+ return "(检查点拒绝:无法安全保存 %s 的原始版本(%s),未执行文件操作)" % (
958
+ path.name, saved.get("reason") or "unknown")
959
+ except Exception:
960
+ return "(检查点拒绝:文件原始版本保存失败,未执行文件操作)"
961
+ if name == "run_command":
962
+ result = fn(workdir, args or {}, cancel_event=cancel_event,
963
+ deadline=deadline, sandbox=sandbox)
964
+ else:
965
+ result = fn(workdir, args or {})
966
+ if snapshot_paths:
967
+ checkpoint_warning = ""
968
+ try:
969
+ from . import checkpoints
970
+ for path in snapshot_paths:
971
+ if not checkpoints.mark_file_after(checkpoint_run_id, workdir, path):
972
+ checkpoint_warning = "(检查点警告:操作后状态未能记账,此文件可能无法自动恢复)"
973
+ except Exception:
974
+ checkpoint_warning = "(检查点警告:操作后状态未能记账,此文件可能无法自动恢复)"
975
+ if checkpoint_warning:
976
+ result += "\n" + checkpoint_warning
977
+ return result
978
+ except Exception as e:
979
+ return "工具执行失败: %s" % (e)
980
+
981
+
982
+ # ---------------------------------------------------------------- 协议适配
983
+
984
+ def _split_tool_args(t):
985
+ """args 键以 ? 开头 = 可选参数(模型侧名字去掉 ?,不进 required)。"""
986
+ props, required = {}, []
987
+ for k, v in t["args"].items():
988
+ opt = k.startswith("?")
989
+ props[k[1:] if opt else k] = {"type": "string", "description": v}
990
+ if not opt:
991
+ required.append(k)
992
+ return props, required
993
+
994
+
995
+ def _mcp_specs(sandbox=None, workdir=None):
996
+ """MCP 工具清单(带缓存);取不到/未配置返回空表——增强不挡主流程。"""
997
+ try:
998
+ from . import mcp_client
999
+ return mcp_client.tool_specs_cached(sandbox=sandbox, workdir=workdir)
1000
+ except Exception:
1001
+ return []
1002
+
1003
+
1004
+ def _openai_tools(extra_specs=None, disabled_tools=None, sandbox=None, workdir=None):
1005
+ disabled = set(disabled_tools or [])
1006
+ out = []
1007
+ for t in list(TOOLS_SPEC) + list(extra_specs or []):
1008
+ if t["name"] in disabled:
1009
+ continue
1010
+ props, required = _split_tool_args(t)
1011
+ out.append({"type": "function", "function": {
1012
+ "name": t["name"], "description": t["description"],
1013
+ "parameters": {"type": "object", "properties": props,
1014
+ "required": required}}})
1015
+ for m in _mcp_specs(sandbox=sandbox, workdir=workdir):
1016
+ if m["full_name"] in disabled:
1017
+ continue
1018
+ # MCP 工具自带 JSON Schema(透传,不套字符串化的 _split_tool_args)
1019
+ out.append({"type": "function", "function": {
1020
+ "name": m["full_name"],
1021
+ "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
1022
+ "parameters": m["input_schema"]}})
1023
+ return out
1024
+
1025
+
1026
+ def _anthropic_tools(extra_specs=None, disabled_tools=None, sandbox=None, workdir=None):
1027
+ disabled = set(disabled_tools or [])
1028
+ out = []
1029
+ for t in list(TOOLS_SPEC) + list(extra_specs or []):
1030
+ if t["name"] in disabled:
1031
+ continue
1032
+ props, required = _split_tool_args(t)
1033
+ out.append({"name": t["name"], "description": t["description"],
1034
+ "input_schema": {"type": "object", "properties": props,
1035
+ "required": required}})
1036
+ for m in _mcp_specs(sandbox=sandbox, workdir=workdir):
1037
+ if m["full_name"] in disabled:
1038
+ continue
1039
+ out.append({"name": m["full_name"],
1040
+ "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
1041
+ "input_schema": m["input_schema"]})
1042
+ return out
1043
+
1044
+
1045
+ def _post_json(url, headers, body, allow_private, timeout):
1046
+ """传输层单点(测试在这里打桩)。"""
1047
+ return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
1048
+
1049
+
1050
+ def _call_with_optional_deadline(fn, args, kwargs, deadline):
1051
+ """Keep older test/integration transport shims source-compatible."""
1052
+ if deadline is None:
1053
+ return fn(*args, **kwargs)
1054
+ try:
1055
+ return fn(*args, **kwargs, deadline=deadline)
1056
+ except TypeError as exc:
1057
+ if "unexpected keyword argument 'deadline'" not in str(exc):
1058
+ raise
1059
+ return fn(*args, **kwargs)
1060
+
1061
+
1062
+ def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
1063
+ deadline=None):
1064
+ """可打断的模型调用:cancel_event 置位即刻放弃等待返回。
1065
+
1066
+ 单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
1067
+ 线程自行收尾(响应被丢弃,socket 随线程结束释放),主流程立即返回。
1068
+ 服务端仍会把这次生成跑完(token 已花),但任务本身即刻终止。"""
1069
+ box = {}
1070
+
1071
+ def _go():
1072
+ try:
1073
+ box["r"] = _post_json(url, headers, body, allow_private, timeout)
1074
+ except Exception as e:
1075
+ box["r"] = (0, None, repr(e))
1076
+
1077
+ th = threading.Thread(target=_go, daemon=True)
1078
+ th.start()
1079
+ wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
1080
+ while th.is_alive():
1081
+ if cancel_event is not None and cancel_event.is_set():
1082
+ break
1083
+ if time.monotonic() >= wait_until:
1084
+ return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
1085
+ th.join(0.5)
1086
+ if cancel_event is not None and cancel_event.is_set():
1087
+ return 0, None, "已取消"
1088
+ return box.get("r") or (0, None, "无响应")
1089
+
1090
+
1091
+ # ---------------------------------------------------------------- 思考过程(流式)
1092
+ # 用户诉求(2026-09-22):「把思考过程打印出来」——直连对话此前整轮只有三点
1093
+ # 打字动画,模型的思维链(DeepSeek/vLLM 系 reasoning_content、Anthropic
1094
+ # thinking 块)被 _sse_parse 明确丢弃。这里自带一份「认思考」的增量解析:
1095
+ # 边收边把思考与正文分别攒起来,交给调用方实时落盘 + 结束后存进步骤记录。
1096
+
1097
+ def _reason_from_delta(proto, obj):
1098
+ """从一条流式事件里取「思考增量」。三协议字段各异,取不到返回空串。
1099
+
1100
+ openai 兼容面最杂:reasoning_content(DeepSeek/vLLM/云知声实测)、
1101
+ reasoning(部分网关)、reasoning_details[].text(OpenRouter 系)。
1102
+ anthropic 是 thinking_delta(另有 redacted_thinking 不文本化,跳过)。"""
1103
+ if proto == "openai":
1104
+ total = ""
1105
+ for ch in obj.get("choices") or []:
1106
+ d = ch.get("delta") or {}
1107
+ for k in ("reasoning_content", "reasoning"):
1108
+ v = d.get(k)
1109
+ if isinstance(v, str):
1110
+ total += v
1111
+ for det in (d.get("reasoning_details") or []):
1112
+ if isinstance(det, dict) and isinstance(det.get("text"), str):
1113
+ total += det["text"]
1114
+ return total
1115
+ if proto == "anthropic" and obj.get("type") == "content_block_delta":
1116
+ d = obj.get("delta") or {}
1117
+ if d.get("type") == "thinking_delta":
1118
+ return d.get("thinking") or ""
1119
+ return ""
1120
+ if proto == "google":
1121
+ total = ""
1122
+ for cand in obj.get("candidates") or []:
1123
+ for p in ((cand.get("content") or {}).get("parts") or []):
1124
+ if isinstance(p, dict) and p.get("thought"):
1125
+ total += p.get("text") or ""
1126
+ return total
1127
+ return ""
1128
+
1129
+
1130
+ def _reason_from_message(proto, data):
1131
+ """非流式响应里的思考字段(网关不支持流式时的兜底)。"""
1132
+ if not isinstance(data, dict):
1133
+ return ""
1134
+ if proto == "anthropic":
1135
+ return "\n".join(b.get("thinking", "") for b in (data.get("content") or [])
1136
+ if isinstance(b, dict) and b.get("type") == "thinking")
1137
+ if proto == "google":
1138
+ total = ""
1139
+ for cand in data.get("candidates") or []:
1140
+ for p in ((cand.get("content") or {}).get("parts") or []):
1141
+ if isinstance(p, dict) and p.get("thought"):
1142
+ total += p.get("text") or ""
1143
+ return total
1144
+ msg = ((data.get("choices") or [{}])[0].get("message") or {})
1145
+ total = ""
1146
+ for k in ("reasoning_content", "reasoning"):
1147
+ v = msg.get(k)
1148
+ if isinstance(v, str):
1149
+ total += v
1150
+ for det in (msg.get("reasoning_details") or []):
1151
+ if isinstance(det, dict) and isinstance(det.get("text"), str):
1152
+ total += det["text"]
1153
+ return total
1154
+
1155
+
1156
+ def _stream_accumulate(proto, obj, acc):
1157
+ """把一条 SSE 事件并入累加器(就地改写 acc)。返回是否有「有意义的进展」。
1158
+
1159
+ acc = {text, reasoning, tools, usage};工具参数按 index 归并——openai 的
1160
+ function.arguments 与 anthropic 的 input_json_delta 都是分片到达的。"""
1161
+ progress = False
1162
+ if proto == "anthropic":
1163
+ t = obj.get("type")
1164
+ if t == "content_block_start":
1165
+ blk = obj.get("content_block") or {}
1166
+ if blk.get("type") == "tool_use":
1167
+ idx = int(obj.get("index") or 0)
1168
+ acc["tools"][idx] = {"id": blk.get("id") or "",
1169
+ "name": blk.get("name") or "", "raw": ""}
1170
+ progress = True
1171
+ elif t == "content_block_delta":
1172
+ d = obj.get("delta") or {}
1173
+ if d.get("type") == "text_delta" and d.get("text"):
1174
+ acc["text"] += d["text"]
1175
+ progress = True
1176
+ elif d.get("type") == "input_json_delta":
1177
+ idx = int(obj.get("index") or 0)
1178
+ slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
1179
+ slot["raw"] += d.get("partial_json") or ""
1180
+ progress = True
1181
+ elif t == "message_start":
1182
+ u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
1183
+ acc["usage"].update({"input": int(u.get("input_tokens") or 0),
1184
+ "output": int(u.get("output_tokens") or 0),
1185
+ "cached": (int(u.get("cache_read_input_tokens") or 0)
1186
+ + int(u.get("cache_creation_input_tokens") or 0))})
1187
+ elif t == "message_delta":
1188
+ u = obj.get("usage") or {}
1189
+ if u.get("output_tokens"):
1190
+ acc["usage"]["output"] = int(u["output_tokens"])
1191
+ elif proto == "google":
1192
+ for cand in obj.get("candidates") or []:
1193
+ for p in ((cand.get("content") or {}).get("parts") or []):
1194
+ if not isinstance(p, dict):
1195
+ continue
1196
+ if p.get("thought"):
1197
+ acc["reasoning"] += p.get("text") or ""
1198
+ progress = True
1199
+ elif p.get("functionCall"):
1200
+ fc = p["functionCall"]
1201
+ acc["tools"][len(acc["tools"])] = {
1202
+ "id": "call_%d" % (len(acc["tools"]) + 1),
1203
+ "name": fc.get("name") or "",
1204
+ "raw": json.dumps(fc.get("args") or {}, ensure_ascii=False)}
1205
+ progress = True
1206
+ elif p.get("text"):
1207
+ acc["text"] += p["text"]
1208
+ progress = True
1209
+ u = obj.get("usageMetadata")
1210
+ if isinstance(u, dict):
1211
+ acc["usage"].update({"input": int(u.get("promptTokenCount") or 0),
1212
+ "output": int(u.get("candidatesTokenCount") or 0),
1213
+ "total": int(u.get("totalTokenCount") or 0)})
1214
+ else:
1215
+ for ch in obj.get("choices") or []:
1216
+ d = ch.get("delta") or {}
1217
+ if isinstance(d.get("content"), str) and d["content"]:
1218
+ acc["text"] += d["content"]
1219
+ progress = True
1220
+ for i, tc in enumerate(d.get("tool_calls") or []):
1221
+ if not isinstance(tc, dict):
1222
+ continue
1223
+ idx = int(tc.get("index") if tc.get("index") is not None else i)
1224
+ slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
1225
+ if tc.get("id"):
1226
+ slot["id"] = tc["id"]
1227
+ fn = tc.get("function") or {}
1228
+ if fn.get("name"):
1229
+ slot["name"] = fn["name"]
1230
+ if fn.get("arguments"):
1231
+ slot["raw"] += fn["arguments"]
1232
+ progress = True
1233
+ u = obj.get("usage")
1234
+ if isinstance(u, dict):
1235
+ acc["usage"].update({"input": int(u.get("prompt_tokens") or 0),
1236
+ "output": int(u.get("completion_tokens") or 0),
1237
+ "total": int(u.get("total_tokens") or 0),
1238
+ "cached": int(((u.get("prompt_tokens_details") or {})
1239
+ .get("cached_tokens")) or 0)})
1240
+ return progress
1241
+
1242
+
1243
+ def _calls_from_acc(acc):
1244
+ """累加器里的工具分片 → [{id, name, args}](参数 JSON 解析失败给空对象)。"""
1245
+ out = []
1246
+ for idx in sorted(acc["tools"].keys()):
1247
+ slot = acc["tools"][idx]
1248
+ name = slot.get("name") or ""
1249
+ if not name:
1250
+ continue
1251
+ try:
1252
+ args = json.loads(slot.get("raw") or "{}")
1253
+ except Exception:
1254
+ args = {}
1255
+ if not isinstance(args, dict):
1256
+ args = {}
1257
+ out.append({"id": slot.get("id") or ("call_%d" % (idx + 1)),
1258
+ "name": name, "args": args})
1259
+ return out
1260
+
1261
+
1262
+ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
1263
+ on_reason=None, on_text=None, on_tick=None, cancel_event=None,
1264
+ deadline=None):
1265
+ """流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
1266
+
1267
+ 与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
1268
+ 即刻放弃等待(socket 随线程结束释放)。三个回调:on_reason 每收到一段思考
1269
+ 增量(调用方据此实时打印「思考过程」),on_text 收到**累计**正文(只在真的
1270
+ 变长时触发,运行中直接当正文预览),on_tick 是无文本进展时的心跳。
1271
+ events=0 表示网关根本没按流回(多半忽略了 stream 字段)——调用方据此回落
1272
+ 非流式;events>0 而正文为空则是模型真没说话,不该重发(白烧一次生成)。
1273
+
1274
+ 首字延迟与吞吐(§07 体验面):`first_token_ms` = 发出请求到第一个**带内容**
1275
+ 的增量(思考 / 正文 / 工具参数)之间的毫秒数;`tokens_per_sec` 用 output
1276
+ token 除以「首个到末个内容增量」的窗口,窗口过短或拿不到 output 时记 0
1277
+ (0 = 不可测,不是"一秒 0 个 token")。两者只有走流式才有值,非流式回落
1278
+ 路径与 CLI 子进程路径都给 0。"""
1279
+ acc = {"text": "", "reasoning": "", "tools": {}, "usage": {}}
1280
+ box = {"status": 0, "err": "", "events": 0, "sent_text": None,
1281
+ "first_ms": None, "last_ms": None}
1282
+ lock = threading.Lock()
1283
+
1284
+ def _emit(cb, payload):
1285
+ if cb is None:
1286
+ return
1287
+ try:
1288
+ cb(payload)
1289
+ except Exception:
1290
+ pass
1291
+
1292
+ def _go():
1293
+ try:
1294
+ parsed = urllib.parse.urlsplit(url)
1295
+ if parsed.scheme not in ("http", "https"):
1296
+ box["err"] = "协议必须是 http/https"
1297
+ return
1298
+ host, herr = modelhub._validate_host(url, allow_private)
1299
+ if host is None:
1300
+ box["err"] = herr
1301
+ return
1302
+ t_send = time.monotonic() # 首字延迟基线:请求发出,不含 DNS/连接前准备
1303
+ req = urllib.request.Request(
1304
+ url, method="POST",
1305
+ headers=dict(headers, **{"Content-Type": "application/json",
1306
+ "Accept": "text/event-stream"}),
1307
+ data=json.dumps(body).encode("utf-8"))
1308
+ with modelhub._opener().open(req, timeout=timeout) as resp:
1309
+ box["status"] = resp.status
1310
+ if not 200 <= resp.status < 300:
1311
+ raw = resp.read(65536).decode("utf-8", "replace")
1312
+ box["err"] = "HTTP %s %s" % (resp.status, raw[:200])
1313
+ return
1314
+ for raw in resp:
1315
+ line = raw.decode("utf-8", "replace").strip()
1316
+ if not line.startswith("data:"):
1317
+ continue
1318
+ payload = line[5:].strip()
1319
+ if not payload or payload == "[DONE]":
1320
+ continue
1321
+ try:
1322
+ obj = json.loads(payload)
1323
+ except Exception:
1324
+ continue # 半行/坏行静默跳过,不中断整流
1325
+ if not isinstance(obj, dict):
1326
+ continue
1327
+ box["events"] += 1
1328
+ reason = _reason_from_delta(proto, obj)
1329
+ with lock:
1330
+ if reason:
1331
+ acc["reasoning"] += reason
1332
+ moved = _stream_accumulate(proto, obj, acc)
1333
+ text_now = acc["text"]
1334
+ if reason or moved:
1335
+ now_ms = (time.monotonic() - t_send) * 1000.0
1336
+ if box["first_ms"] is None:
1337
+ box["first_ms"] = now_ms
1338
+ box["last_ms"] = now_ms
1339
+ if reason:
1340
+ _emit(on_reason, reason)
1341
+ if text_now != box["sent_text"]:
1342
+ box["sent_text"] = text_now
1343
+ _emit(on_text, text_now) # 累计正文(运行中实时打印)
1344
+ elif moved and not reason:
1345
+ _emit(on_tick, None) # 只有工具/用量在推进的心跳
1346
+ if sum(len(x) for x in (acc["text"], acc["reasoning"])) > 4 * 1024 * 1024:
1347
+ break # 防失控
1348
+ except Exception as e:
1349
+ box["err"] = box["err"] or tlsctx.humanize(repr(e)[:300])
1350
+
1351
+ th = threading.Thread(target=_go, daemon=True)
1352
+ th.start()
1353
+ wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
1354
+ while th.is_alive():
1355
+ if cancel_event is not None and cancel_event.is_set():
1356
+ break
1357
+ if time.monotonic() >= wait_until:
1358
+ break
1359
+ th.join(0.5)
1360
+ with lock:
1361
+ out = {"status": box["status"], "text": acc["text"],
1362
+ "reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
1363
+ "calls": _calls_from_acc(acc), "events": box["events"],
1364
+ "first_token_ms": round(box["first_ms"], 1) if box["first_ms"] is not None else 0.0,
1365
+ "tokens_per_sec": 0.0, "error": box["err"]}
1366
+ gen_s = 0.0
1367
+ if box["first_ms"] is not None and box["last_ms"] is not None:
1368
+ gen_s = (box["last_ms"] - box["first_ms"]) / 1000.0
1369
+ out_tok = int(acc["usage"].get("output") or 0)
1370
+ if gen_s > 0.2 and out_tok > 0:
1371
+ out["tokens_per_sec"] = round(out_tok / gen_s, 2)
1372
+ if cancel_event is not None and cancel_event.is_set():
1373
+ out["error"], out["status"] = "已取消", 0
1374
+ return out
1375
+ if th.is_alive():
1376
+ out["error"], out["status"] = ("任务总时限已到" if deadline is not None
1377
+ else "响应超时"), 0
1378
+ out["deadline_exceeded"] = deadline is not None
1379
+ return out
1380
+
1381
+
1382
+ def _m_openai(m):
1383
+ """内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
1384
+ if m["role"] == "assistant":
1385
+ out = {"role": "assistant", "content": m.get("content") or ""}
1386
+ if m.get("tool_calls"):
1387
+ out["tool_calls"] = [{"id": tc["id"], "type": "function",
1388
+ "function": {"name": tc["name"],
1389
+ "arguments": json.dumps(tc["args"], ensure_ascii=False)}}
1390
+ for tc in m["tool_calls"]]
1391
+ return out
1392
+ if m["role"] == "tool_results":
1393
+ return [{"role": "tool", "tool_call_id": cid, "content": text}
1394
+ for cid, text in m["tool_results"]]
1395
+ imgs = m.get("images") or []
1396
+ if imgs: # 多模态:content 变数组,图跟在文本后(role=tool 只收文本,故图只走 user)
1397
+ content = [{"type": "text", "text": m.get("content") or ""}]
1398
+ content += [{"type": "image_url",
1399
+ "image_url": {"url": "data:%s;base64,%s" % (mime, b64)}}
1400
+ for mime, b64 in imgs]
1401
+ return {"role": "user", "content": content}
1402
+ return {"role": "user", "content": m.get("content") or ""}
1403
+
1404
+
1405
+ def _m_anthropic(m):
1406
+ if m["role"] == "assistant":
1407
+ blocks = []
1408
+ if m.get("content"):
1409
+ blocks.append({"type": "text", "text": m["content"]})
1410
+ for tc in (m.get("tool_calls") or []):
1411
+ blocks.append({"type": "tool_use", "id": tc["id"], "name": tc["name"],
1412
+ "input": tc["args"]})
1413
+ return {"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]}
1414
+ if m["role"] == "tool_results":
1415
+ return {"role": "user", "content": [
1416
+ {"type": "tool_result", "tool_use_id": cid, "content": text}
1417
+ for cid, text in m["tool_results"]]}
1418
+ imgs = m.get("images") or []
1419
+ if imgs:
1420
+ blocks = [{"type": "text", "text": m.get("content") or ""}]
1421
+ blocks += [{"type": "image",
1422
+ "source": {"type": "base64", "media_type": mime, "data": b64}}
1423
+ for mime, b64 in imgs]
1424
+ return {"role": "user", "content": blocks}
1425
+ return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
1426
+
1427
+
1428
+ def _compact_old_tool_results(messages, keep_rounds=TOOL_HISTORY_KEEP_ROUNDS,
1429
+ old_result_chars=TOOL_HISTORY_OLD_RESULT_CHARS,
1430
+ run_id=""):
1431
+ """Bound old tool output while preserving recent call/result pairs."""
1432
+ result_indexes = [i for i, msg in enumerate(messages)
1433
+ if msg.get("role") == "tool_results"]
1434
+ keep_rounds = max(0, int(keep_rounds))
1435
+ compact_indexes = result_indexes[:max(0, len(result_indexes) - keep_rounds)]
1436
+ for idx in compact_indexes:
1437
+ msg = messages[idx]
1438
+ compacted = []
1439
+ for call_id, value in msg.get("tool_results") or []:
1440
+ value = str(value or "")
1441
+ if len(value) > old_result_chars:
1442
+ existing_ref = _existing_tool_output_ref(value, run_id, call_id)
1443
+ if existing_ref:
1444
+ # Idempotent across repeated context passes: never save the
1445
+ # already-truncated marker as a new "full" tool output.
1446
+ compacted.append((call_id, value))
1447
+ continue
1448
+ head = old_result_chars * 2 // 3
1449
+ tail = old_result_chars - head
1450
+ try:
1451
+ from . import tool_outputs
1452
+ ref = tool_outputs.save(run_id, call_id, value) if run_id else ""
1453
+ except Exception:
1454
+ ref = ""
1455
+ if ref:
1456
+ marker = ("\n…[较早结果已卸载到本机;需查看全文请调用 read_tool_output,"
1457
+ "ref=%s ]…\n" % ref)
1458
+ else:
1459
+ marker = "\n…[较早工具结果已压缩;需要时请重新读取对应文件/调用工具]…\n"
1460
+ value = value[:head] + marker + value[-tail:]
1461
+ compacted.append((call_id, value))
1462
+ msg["tool_results"] = compacted
1463
+
1464
+
1465
+ def _existing_tool_output_ref(value, run_id, call_id):
1466
+ """Return a ref only when a marker previews this call's stored full output.
1467
+
1468
+ Tool output is untrusted. A bare ``ref=<hex>`` substring is not evidence of a
1469
+ CodeBee marker: require the complete framing and verify the opaque reference
1470
+ against this call's stored full output before treating it as already unloaded.
1471
+ """
1472
+ match = _TOOL_OUTPUT_MARKER_RE.search(str(value or ""))
1473
+ if not match or not str(run_id or "").strip():
1474
+ return ""
1475
+ try:
1476
+ from . import tool_outputs
1477
+ is_match = tool_outputs.matches_compacted_output(
1478
+ run_id, call_id, match.group(1), value[:match.start()], value[match.end():])
1479
+ return match.group(1) if is_match else ""
1480
+ except Exception:
1481
+ return ""
1482
+
1483
+
1484
+ def _direct_context_chars(messages):
1485
+ return sum(len(json.dumps(msg, ensure_ascii=False, default=str)) for msg in messages)
1486
+
1487
+
1488
+ def _bound_direct_context(messages, *, max_chars=MAX_DIRECT_CONTEXT_CHARS,
1489
+ keep_rounds=TOOL_HISTORY_KEEP_ROUNDS, run_id=""):
1490
+ """Fold complete old tool rounds when a direct-agent conversation grows large.
1491
+
1492
+ Unlike pipeline steps, direct chat has no session-log compaction surface. Keep the
1493
+ original request and the newest tool interactions, but cap older outputs and replace
1494
+ discarded call/result pairs with a bounded factual note. Full large outputs remain
1495
+ available through read_tool_output references.
1496
+ """
1497
+ try:
1498
+ max_chars = max(1000, int(max_chars))
1499
+ keep_rounds = max(1, int(keep_rounds))
1500
+ except (TypeError, ValueError):
1501
+ max_chars, keep_rounds = MAX_DIRECT_CONTEXT_CHARS, TOOL_HISTORY_KEEP_ROUNDS
1502
+ rounds = []
1503
+ for index, msg in enumerate(messages[:-1]):
1504
+ if msg.get("role") == "assistant" and msg.get("tool_calls") \
1505
+ and messages[index + 1].get("role") == "tool_results":
1506
+ rounds.append((index, index + 1))
1507
+ if not rounds:
1508
+ return
1509
+ if _direct_context_chars(messages) <= max_chars:
1510
+ return
1511
+
1512
+ # First compact all tool results; recent full output is useful only while the
1513
+ # conversation has room. Oversized output is recoverable by its scoped ref.
1514
+ result_budget = max(160, min(TOOL_HISTORY_OLD_RESULT_CHARS,
1515
+ max_chars // max(1, keep_rounds * 8)))
1516
+ _compact_old_tool_results(messages, keep_rounds=0,
1517
+ old_result_chars=result_budget, run_id=run_id)
1518
+ note_index = 1 if messages and messages[0].get("role") == "user" else 0
1519
+ previous_note = (messages[note_index].get("content") or "") \
1520
+ if note_index < len(messages) \
1521
+ and messages[note_index].get("_direct_compaction_note") else ""
1522
+ folded = ([previous_note.split("\n", 1)[1]]
1523
+ if previous_note.startswith("## 更早的工具轮次摘要\n") else [])
1524
+ while len(rounds) > keep_rounds and _direct_context_chars(messages) > max_chars:
1525
+ start, end = rounds.pop(0)
1526
+ assistant, result_msg = messages[start], messages[end]
1527
+ calls = assistant.get("tool_calls") or []
1528
+ tools = ", ".join(str(call.get("name") or "tool")[:80] for call in calls[:8])
1529
+ detail = str(assistant.get("content") or "").strip().replace("\n", " ")[:240]
1530
+ refs = []
1531
+ for call_id, output in (result_msg.get("tool_results") or [])[:5]:
1532
+ value = str(output or "")
1533
+ existing_ref = _existing_tool_output_ref(value, run_id, call_id)
1534
+ if existing_ref:
1535
+ refs.append(existing_ref)
1536
+ elif run_id:
1537
+ try:
1538
+ from . import tool_outputs
1539
+ ref = tool_outputs.save(run_id, call_id, value)
1540
+ if ref:
1541
+ refs.append(ref)
1542
+ except Exception:
1543
+ pass
1544
+ piece = "- 工具 %s;模型说明:%s%s" % (
1545
+ tools or "unknown", detail or "(无)",
1546
+ (";全文 ref=" + ",".join(refs)) if refs else "")
1547
+ folded.append(piece[:500])
1548
+ del messages[start:end + 1]
1549
+ # Tool payloads are untrusted external data: summarize call intent and refs,
1550
+ # never copy output excerpts outside their prompt-guard fences.
1551
+ note = "## 更早的工具轮次摘要\n" + "\n".join(folded)
1552
+ note_limit = min(8000, max(1400, max_chars // 4))
1553
+ if len(note) > note_limit:
1554
+ note = note[-note_limit:]
1555
+ insert_at = 1 if messages and messages[0].get("role") == "user" else 0
1556
+ if insert_at < len(messages) and messages[insert_at].get("_direct_compaction_note"):
1557
+ messages[insert_at]["content"] = note
1558
+ else:
1559
+ messages.insert(insert_at, {"role": "user", "content": note,
1560
+ "_direct_compaction_note": True})
1561
+ # Earlier deletions shift indices; rebuild complete pairs.
1562
+ rounds = [(i, i + 1) for i, msg in enumerate(messages[:-1])
1563
+ if msg.get("role") == "assistant" and msg.get("tool_calls")
1564
+ and messages[i + 1].get("role") == "tool_results"]
1565
+
1566
+
1567
+ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
1568
+ max_tokens=BASE_MAX_TOKENS, extra_specs=None, disabled_tools=None,
1569
+ sandbox=None, workdir=None):
1570
+ """按协议构造 (url, headers, body)。msgs 为内部统一形状。
1571
+
1572
+ stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
1573
+ openai/anthropic 置 stream 字段(openai 另要 include_usage,否则收尾
1574
+ chunk 不带 usage,这一步的 token 记账会凭空少一截)。"""
1575
+ base = (base or "").rstrip("/")
1576
+ if proto == "google":
1577
+ if base.endswith("/v1beta"):
1578
+ prefix = base + "/models/%s"
1579
+ else:
1580
+ prefix = base + "/v1beta/models/%s"
1581
+ url = (prefix + (":streamGenerateContent?alt=sse" if stream
1582
+ else ":generateContent")) % model
1583
+ headers = {"x-goog-api-key": use_key}
1584
+ contents = []
1585
+ for m in msgs:
1586
+ if m["role"] not in ("user", "assistant"):
1587
+ continue
1588
+ parts = [{"text": m.get("content") or ""}]
1589
+ parts += [{"inline_data": {"mime_type": mime, "data": b64}}
1590
+ for mime, b64 in (m.get("images") or [])]
1591
+ contents.append({"role": ("user" if m["role"] == "user" else "model"),
1592
+ "parts": parts})
1593
+ body = {"contents": contents,
1594
+ "systemInstruction": {"parts": [{"text": system}]},
1595
+ "generationConfig": {"maxOutputTokens": max_tokens}}
1596
+ return url, headers, body
1597
+ path = "/messages" if proto == "anthropic" else "/chat/completions"
1598
+ url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
1599
+ if proto == "anthropic":
1600
+ headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
1601
+ body = {"model": model, "max_tokens": max_tokens, "system": system,
1602
+ "messages": [_m_anthropic(m) for m in msgs]}
1603
+ if with_tools:
1604
+ body["tools"] = _anthropic_tools(extra_specs, disabled_tools,
1605
+ sandbox=sandbox, workdir=workdir)
1606
+ if stream:
1607
+ body["stream"] = True
1608
+ return url, headers, body
1609
+ headers = {"Authorization": "Bearer " + use_key}
1610
+ # tool_results 消息展开成多条 role=tool(_m_openai 对它返回列表,不能嵌套)
1611
+ msgs_wire = []
1612
+ for m in msgs:
1613
+ w = _m_openai(m)
1614
+ if isinstance(w, list):
1615
+ msgs_wire.extend(w)
1616
+ else:
1617
+ msgs_wire.append(w)
1618
+ body = {"model": model, "max_tokens": max_tokens,
1619
+ "messages": [{"role": "system", "content": system}] + msgs_wire}
1620
+ if with_tools:
1621
+ body["tools"] = _openai_tools(extra_specs, disabled_tools,
1622
+ sandbox=sandbox, workdir=workdir)
1623
+ if stream:
1624
+ body["stream"] = True
1625
+ body["stream_options"] = {"include_usage": True}
1626
+ return url, headers, body
1627
+
1628
+
1629
+ def _parse_reply(proto, data):
1630
+ """从响应解析 → (文本, [tool_call], usage)。
1631
+ tool_call = {id, name, args};usage = {input, output, cached, total}。"""
1632
+ usage = {"input": 0, "output": 0, "cached": 0, "total": 0}
1633
+ if proto == "google":
1634
+ cand = (data.get("candidates") or [{}])[0]
1635
+ parts = (cand.get("content") or {}).get("parts") or []
1636
+ text = "\n".join(p.get("text", "") for p in parts if isinstance(p, dict))
1637
+ um = data.get("usageMetadata") or {}
1638
+ usage["input"] = int(um.get("promptTokenCount") or 0)
1639
+ usage["output"] = int(um.get("candidatesTokenCount") or 0)
1640
+ usage["total"] = int(um.get("totalTokenCount") or 0)
1641
+ return text.strip(), [], usage
1642
+ if proto == "anthropic":
1643
+ blocks = data.get("content") or []
1644
+ text = "\n".join(b.get("text", "") for b in blocks
1645
+ if isinstance(b, dict) and b.get("type") == "text")
1646
+ calls = [{"id": b.get("id") or "", "name": b.get("name") or "",
1647
+ "args": b.get("input") or {}}
1648
+ for b in blocks if isinstance(b, dict) and b.get("type") == "tool_use"]
1649
+ u = data.get("usage") or {}
1650
+ usage["input"] = int(u.get("input_tokens") or 0)
1651
+ usage["output"] = int(u.get("output_tokens") or 0)
1652
+ usage["cached"] = (int(u.get("cache_read_input_tokens") or 0)
1653
+ + int(u.get("cache_creation_input_tokens") or 0))
1654
+ usage["total"] = usage["input"] + usage["output"] + usage["cached"]
1655
+ return text.strip(), calls, usage
1656
+ choice = (data.get("choices") or [{}])[0]
1657
+ msg = choice.get("message") or {}
1658
+ calls = []
1659
+ for tc in (msg.get("tool_calls") or []):
1660
+ fn = tc.get("function") or {}
1661
+ try:
1662
+ args = json.loads(fn.get("arguments") or "{}")
1663
+ except Exception:
1664
+ args = {}
1665
+ if not isinstance(args, dict):
1666
+ args = {}
1667
+ calls.append({"id": tc.get("id") or ("call_%d" % (len(calls) + 1)),
1668
+ "name": fn.get("name") or "", "args": args})
1669
+ u = data.get("usage") or {}
1670
+ usage["input"] = int(u.get("prompt_tokens") or 0)
1671
+ usage["output"] = int(u.get("completion_tokens") or 0)
1672
+ usage["total"] = int(u.get("total_tokens") or 0) or (usage["input"] + usage["output"])
1673
+ return (msg.get("content") or "").strip(), calls, usage
1674
+
1675
+
1676
+ # ---------------------------------------------------------------- 主循环
1677
+
1678
+ def _norm_usage(usage):
1679
+ """三协议 usage → {input, output, cached, total}(缺 total 用三项相加补齐)。"""
1680
+ out = {"input": 0, "output": 0, "cached": 0, "total": 0}
1681
+ for k in out:
1682
+ try:
1683
+ out[k] = int((usage or {}).get(k) or 0)
1684
+ except Exception:
1685
+ out[k] = 0
1686
+ if not out["total"]:
1687
+ out["total"] = out["input"] + out["output"] + out["cached"]
1688
+ return out
1689
+
1690
+
1691
+ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
1692
+ on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None,
1693
+ task_creator=None, sandbox=None, checkpoint_run_id=""):
1694
+ """跑一次内置智能体(内部自带工具循环直到给出最终回答)。
1695
+
1696
+ bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
1697
+ images: 用户随本轮消息提供的图片绝对路径列表(任务附件/对话追话);
1698
+ log: 追加一行日志的回调(步骤日志)。返回 runner 风格统一结果:
1699
+ {ok, text, usage, error, model, provider_name, provider_id, iterations,
1700
+ cost_usd, reasoning}。
1701
+
1702
+ 思考过程可视化(2026-09-22 用户诉求「把思考过程打印出来」):stream=True 走
1703
+ SSE,模型的思维链(reasoning_content / thinking 块)边收边经 on_reason 回调,
1704
+ 正文增量经 on_stream(累计值),工具活动经 on_activity——调用方据此在对话
1705
+ 时间线上实时打印,不再让用户干看三点打字动画。网关忽略 stream(零事件)时
1706
+ 自动回落非流式,思考字段仍从响应体兜底抽取(on_reason 补发一次)。"""
1707
+ prov = bi["prov"]
1708
+ model = bi["model"]
1709
+ from . import policy
1710
+ sandbox = policy.normalize_sandbox(sandbox, workdir)
1711
+ disabled_tools = sandbox.get("disabled_tools") or []
1712
+ allow_private = bool(prov.get("allow_private"))
1713
+ system = _SYSTEM_PROMPT + "\n\n## 工作目录\n%s" % os.path.abspath(workdir or ".")
1714
+ imgs = _prep_images(images, log) if images else []
1715
+ if imgs and not modelhub._model_image_in(prov, model):
1716
+ # 能力闸门:模型未声明图片输入就不塞图(网关多半会拒),改为显式告知
1717
+ if log:
1718
+ log("[图片] 模型 %s 未开启图片输入,%d 张图不随消息发送" % (model, len(imgs)))
1719
+ prompt = ("(用户提供了 %d 张图片,但当前模型未开启图片输入;请在回答开头提示用户"
1720
+ "到绑定页为该模型开启「图」开关,或改绑多模态模型。本条回答请基于"
1721
+ "文字部分完成。)\n\n%s" % (len(imgs), prompt))
1722
+ imgs = []
1723
+ msgs = [{"role": "user", "content": prompt, "images": imgs}]
1724
+ candidates = list(modelhub._protocol_candidates(prov))
1725
+ tools_ok = all(proto != "google" for proto, _ in candidates) # google wire 无工具协议
1726
+ total_usage = {"input": 0, "cached": 0, "output": 0, "total": 0}
1727
+ tool_output_run_id = checkpoint_run_id or secrets.token_hex(16)
1728
+ text = ""
1729
+ iters = 0
1730
+ last_err = ""
1731
+ ok = False
1732
+ reasons = [] # 各迭代的思考正文(按迭代顺序拼接,供步骤记录展示)
1733
+ max_tokens = BASE_MAX_TOKENS # 思考占满预算时提额重试(跨迭代保持,见空正文分支)
1734
+ escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
1735
+ empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
1736
+ last_action_was_tools = False
1737
+ repeat_tool_signature = ""
1738
+ repeat_tool_streak = 0
1739
+ repeat_stop = False
1740
+ finalize_attempted = False
1741
+ perf = {"ttft": [], "tps": []} # 各次流式调用的首字延迟(ms)与吞吐(tok/s)
1742
+
1743
+ def _perf():
1744
+ """本次 run 的体验指标:跨迭代取均值;无流式样本时返回空 dict。"""
1745
+ if not perf["ttft"] and not perf["tps"]:
1746
+ return {}
1747
+ return {"first_token_ms": (round(sum(perf["ttft"]) / len(perf["ttft"]), 1)
1748
+ if perf["ttft"] else 0.0),
1749
+ "tokens_per_sec": (round(sum(perf["tps"]) / len(perf["tps"]), 2)
1750
+ if perf["tps"] else 0.0)}
1751
+
1752
+ def _deadline_fail():
1753
+ return {"ok": False, "text": "", "usage": dict(total_usage),
1754
+ "error": "任务总时限已到", "model": model,
1755
+ "provider_name": bi["provider_name"],
1756
+ "provider_id": bi["provider_id"], "iterations": iters,
1757
+ "cost_usd": 0.0, "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS],
1758
+ "raw": {"timed_out": True, "deadline_exceeded": True}}
1759
+
1760
+ def _fire(cb, *cb_args):
1761
+ if cb is None:
1762
+ return
1763
+ try:
1764
+ cb(*cb_args)
1765
+ except Exception:
1766
+ pass # 可视化是增强:回调炸了不能拖垮生成
1767
+
1768
+ def _take_reason(reason):
1769
+ """收一段思考:攒进本轮正文并实时回调(限长,防超长思维链撑爆 run.json)。"""
1770
+ if not reason:
1771
+ return
1772
+ if not reasons:
1773
+ reasons.append("")
1774
+ if len(reasons[-1]) < THINK_MAX_CHARS:
1775
+ reasons[-1] += reason
1776
+ _fire(on_reason, reason)
1777
+
1778
+ def _fail(err):
1779
+ return {"ok": False, "text": "", "usage": dict(total_usage), "error": err,
1780
+ "model": model, "provider_name": bi["provider_name"],
1781
+ "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
1782
+ "raw": _perf(),
1783
+ "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
1784
+
1785
+ def _cancel_fail():
1786
+ """取消终态带结构化标记:下游惩罚账(KEY 冷却/供应商健康/评测作废)靠标记
1787
+ 认出取消,不靠「已取消」字样匹配。"""
1788
+ out = _fail("已取消")
1789
+ out["cancelled"] = True
1790
+ return out
1791
+
1792
+ # 工具上限后最多再给一次“只输出总结”的机会。这样最后一轮已经
1793
+ # 成功写文件/抓数据但尚未总结时,不会把真实成果误报成失败。
1794
+ for it in range(1, MAX_TOOL_ITERS + 2):
1795
+ force_finalize = it > MAX_TOOL_ITERS or repeat_stop
1796
+ if force_finalize and (finalize_attempted or not last_action_was_tools):
1797
+ break
1798
+ if force_finalize and not finalize_attempted:
1799
+ finalize_attempted = True
1800
+ msgs.append({"role": "user", "content":
1801
+ "请停止调用工具,只根据已经完成的操作直接给出最终答复。"
1802
+ "列出已完成内容、产出文件和仍未完成的事项;不要继续探索。"})
1803
+ if log:
1804
+ log("[收尾] 工具循环已到边界,发起一次无工具最终总结")
1805
+ _fire(on_activity, "工具循环到边界,正在生成最终总结")
1806
+ if deadline is not None and time.monotonic() >= deadline:
1807
+ return _deadline_fail()
1808
+ if cancel_event is not None and cancel_event.is_set():
1809
+ return _cancel_fail()
1810
+ reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
1811
+ done = False
1812
+ reBump = False # 本轮要提额重试(同一迭代重跑,不烧迭代数)
1813
+ stopit = False # 本轮判死(连续零正文),直接终局不再空转
1814
+ for _bump in range(2): # 第 2 圈 = 提额后的同轮重试
1815
+ for proto, pbase in candidates:
1816
+ keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
1817
+ for kk in keys:
1818
+ got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
1819
+ stripped = False # thinking 对象被网关 400 拒后已摘除重发
1820
+ while True:
1821
+ if deadline is not None and time.monotonic() >= deadline:
1822
+ return _deadline_fail()
1823
+ req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
1824
+ if deadline is not None else timeout
1825
+ allow_tools = tools_ok and not force_finalize
1826
+ url, headers, body = _build_request(
1827
+ proto, pbase, kk["key"], model, system, msgs,
1828
+ allow_tools, max_tokens=max_tokens,
1829
+ extra_specs=[CREATE_TASK_SPEC] if task_creator else None,
1830
+ disabled_tools=disabled_tools, sandbox=sandbox,
1831
+ workdir=workdir)
1832
+ sbody = None
1833
+ if stream:
1834
+ # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
1835
+ _, _, sbody = _build_request(
1836
+ proto, pbase, kk["key"], model, system, msgs,
1837
+ allow_tools, stream=True, max_tokens=max_tokens,
1838
+ extra_specs=[CREATE_TASK_SPEC] if task_creator else None,
1839
+ disabled_tools=disabled_tools, sandbox=sandbox,
1840
+ workdir=workdir)
1841
+ effort = str(bi.get("reasoning_effort") or "").strip().lower()
1842
+ # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
1843
+ # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
1844
+ if effort in ("low", "medium", "high") and proto == "openai":
1845
+ body["reasoning_effort"] = effort
1846
+ if sbody is not None:
1847
+ sbody["reasoning_effort"] = effort
1848
+ if effort in ("low", "medium") and proto == "anthropic" \
1849
+ and not stripped:
1850
+ # 「思考程度」此前只对 openai 面生效;GLM 这类推理模型
1851
+ # 在 Anthropic 兼容面上默认全功率思考,能把 max_tokens
1852
+ # 整个烧光(2026-09-22 glm-5.3-flash 47 分钟零正文案)。
1853
+ # low/medium 显式给思考预算,high 留默认(模型自决=放开想)。
1854
+ body["thinking"] = {
1855
+ "type": "enabled",
1856
+ "budget_tokens": 1024 if effort == "low" else 4096}
1857
+ if sbody is not None:
1858
+ sbody["thinking"] = dict(body["thinking"])
1859
+ text, calls, usage = "", [], {}
1860
+ streamed = False
1861
+ if sbody is not None:
1862
+ part = _call_with_optional_deadline(
1863
+ _post_sse_stream,
1864
+ (url, headers, sbody, allow_private, req_timeout, proto),
1865
+ {"on_reason": _take_reason, "on_text": on_stream,
1866
+ "cancel_event": cancel_event}, deadline)
1867
+ if part.get("deadline_exceeded"):
1868
+ return _deadline_fail()
1869
+ if part["error"] == "已取消":
1870
+ return _cancel_fail()
1871
+ if not part["error"] and part["events"]:
1872
+ # 网关确实按流回了:正文/工具取增量累加结果
1873
+ text, calls = part["text"], part["calls"]
1874
+ usage = part["usage"]
1875
+ streamed = True
1876
+ if part.get("first_token_ms"):
1877
+ perf["ttft"].append(float(part["first_token_ms"]))
1878
+ if part.get("tokens_per_sec"):
1879
+ perf["tps"].append(float(part["tokens_per_sec"]))
1880
+ if not reasons or not reasons[-1]:
1881
+ # 调用方没挂实时回调(无可视化诉求)时思考只在
1882
+ # 累加器里:这里兜底收进结果,落库口径一致
1883
+ _take_reason(part["reasoning"])
1884
+ else:
1885
+ # 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
1886
+ # 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
1887
+ # 供应商稳妥;非流式也失败才走下面的记账/换链。
1888
+ last_err = part["error"]
1889
+ if not streamed:
1890
+ status, data, err = _call_with_optional_deadline(
1891
+ _post_interruptible,
1892
+ (url, headers, body, allow_private, req_timeout, cancel_event),
1893
+ {}, deadline)
1894
+ if err == "任务总时限已到":
1895
+ return _deadline_fail()
1896
+ if cancel_event is not None and cancel_event.is_set():
1897
+ # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
1898
+ return _cancel_fail()
1899
+ if status == 0 or not (200 <= status < 300):
1900
+ msg = ""
1901
+ if isinstance(data, dict):
1902
+ e = data.get("error")
1903
+ msg = e.get("message", "") if isinstance(e, dict) else str(e)
1904
+ if status == 400 and isinstance(body.get("thinking"), dict) \
1905
+ and "thinking" in str(msg).lower() and not stripped:
1906
+ # 网关不认 thinking 对象:摘掉同 KEY 立即重发,
1907
+ # 别让「思考程度」偏好把健康 KEY 送进冷却
1908
+ body.pop("thinking", None)
1909
+ if sbody is not None:
1910
+ sbody.pop("thinking", None)
1911
+ stripped = True
1912
+ continue
1913
+ last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
1914
+ try:
1915
+ modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
1916
+ except Exception:
1917
+ pass
1918
+ break # 换下一个 KEY
1919
+ text, calls, usage = _parse_reply(proto, data)
1920
+ # 非流式响应里的思考字段:补发一次,可视化与流式路径同形
1921
+ _take_reason(_reason_from_message(proto, data))
1922
+ try:
1923
+ modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
1924
+ except Exception:
1925
+ pass
1926
+ usage = _norm_usage(usage)
1927
+ for k in total_usage:
1928
+ total_usage[k] += int(usage.get(k) or 0)
1929
+ iters = it
1930
+ got = True
1931
+ break
1932
+ if not got:
1933
+ continue # 本 KEY 失败,换下一个
1934
+ if calls and force_finalize:
1935
+ # 收尾请求明确禁用工具;模型若仍返回 tool call,不能再
1936
+ # 开启新一轮,否则会绕过止损边界。
1937
+ last_err = "收尾时模型仍请求工具"
1938
+ last_action_was_tools = False
1939
+ done = True
1940
+ break
1941
+ if calls:
1942
+ sig = json.dumps(
1943
+ [(c.get("name"), c.get("args") or {}) for c in calls],
1944
+ ensure_ascii=False, sort_keys=True, separators=(",", ":"))
1945
+ if sig == repeat_tool_signature:
1946
+ repeat_tool_streak += 1
1947
+ else:
1948
+ repeat_tool_signature = sig
1949
+ repeat_tool_streak = 1
1950
+ if repeat_tool_streak >= 3:
1951
+ repeat_stop = True
1952
+ if log:
1953
+ log("[迭代 %d] 检测到同一工具调用连续重复,转入收尾" % it)
1954
+ _fire(on_activity, "检测到重复工具调用,正在收尾")
1955
+ if log:
1956
+ log("[迭代 %d] %s 请求工具: %s" % (
1957
+ it, model, ", ".join(c["name"] for c in calls)))
1958
+ _fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
1959
+ results = []
1960
+ for c in calls:
1961
+ out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
1962
+ deadline=deadline, task_creator=task_creator,
1963
+ disabled_tools=disabled_tools, sandbox=sandbox,
1964
+ checkpoint_run_id=checkpoint_run_id,
1965
+ tool_output_run_id=tool_output_run_id)
1966
+ # 运行期注入警示层:外部内容(网页/命令输出/文件)一律
1967
+ # 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
1968
+ try:
1969
+ from . import prompt_guard
1970
+ out = prompt_guard.wrap_external(out, c["name"])
1971
+ except Exception:
1972
+ pass
1973
+ if log:
1974
+ brief = out if len(out) <= 120 else out[:120] + "…"
1975
+ log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
1976
+ _fire(on_activity, "%s %s" % (
1977
+ c["name"], str(c["args"].get("path")
1978
+ or c["args"].get("command") or "")[:80]))
1979
+ results.append((c["id"], out))
1980
+ msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
1981
+ msgs.append({"role": "tool_results", "tool_results": results})
1982
+ _bound_direct_context(msgs, run_id=tool_output_run_id)
1983
+ empty_streak = 0
1984
+ last_action_was_tools = True
1985
+ done = True
1986
+ break
1987
+ if (text or "").strip():
1988
+ if log:
1989
+ log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
1990
+ ok = True
1991
+ empty_streak = 0
1992
+ last_action_was_tools = False
1993
+ done = True
1994
+ break
1995
+ # —— 零正文零工具:分辨「思考占满输出预算」还是「模型真没说话」。
1996
+ # 推理模型的思考计入 max_tokens:想满了流会正常收,正文却是空的。
1997
+ last_action_was_tools = False
1998
+ rlen = len(reasons[-1] or "")
1999
+ out_tok = int(usage.get("output") or 0)
2000
+ exhausted = (rlen >= THINK_EXHAUST_MIN_CHARS
2001
+ or out_tok >= int(max_tokens * 0.9))
2002
+ empty_streak += 1
2003
+ if exhausted and not escalated:
2004
+ # 同轮提额重试一次:8K 想不完的 16K 大多就想完了
2005
+ escalated = True
2006
+ old = max_tokens
2007
+ max_tokens = min(max_tokens * 2, MAX_TOKENS_CAP)
2008
+ last_err = ("模型思考占满输出预算(max_tokens=%d,思考 %d 字零正文)"
2009
+ % (old, rlen))
2010
+ if log:
2011
+ log("[第 %d 轮] 思考 %d 字仍无正文/工具 → max_tokens %d→%d 同轮重试"
2012
+ % (it, rlen, old, max_tokens))
2013
+ _fire(on_activity, "思考占满输出预算,提高输出预算重试")
2014
+ reBump = True
2015
+ break
2016
+ if empty_streak >= EMPTY_TEXT_MAX_STREAK:
2017
+ last_err = ("模型思考连续占满输出预算(max_tokens 已提到 %d 仍无正文)"
2018
+ % max_tokens) if exhausted else \
2019
+ ("模型连续 %d 轮零正文零工具" % empty_streak)
2020
+ done = True
2021
+ stopit = True
2022
+ break
2023
+ last_err = "模型未返回文本"
2024
+ if log:
2025
+ log("[第 %d 轮] 模型零正文零工具(思考 %d 字),重试" % (it, rlen))
2026
+ _fire(on_activity, "模型零正文,重试")
2027
+ done = True
2028
+ break
2029
+ if done or reBump:
2030
+ break
2031
+ if reBump:
2032
+ continue # 同一迭代用新预算重来
2033
+ break # 非提额路径一锤定音
2034
+ if not done:
2035
+ break # 所有 wire/KEY 都失败
2036
+ if ok or stopit:
2037
+ break
2038
+ if not ok and not text and finalize_attempted:
2039
+ # 工具已经执行过且收尾调用也未能给正文:把真实执行结果作为“部分完成”
2040
+ # 返回。pipeline 会按成功步骤落盘,用户可以继续追问而不是被迫重跑。
2041
+ partial = _fail(last_err or "已完成工具操作,但未生成最终总结")
2042
+ partial["ok"] = True
2043
+ partial["partial"] = True
2044
+ partial["text"] = ("部分完成:已执行部分操作,但未能生成最终总结。"
2045
+ "请检查工作目录中的产出文件,或继续追问以完成剩余事项。")
2046
+ partial["error"] = last_err or "已完成工具操作,但未生成最终总结"
2047
+ partial.setdefault("raw", {})["partial"] = True
2048
+ return partial
2049
+ if not ok and not text:
2050
+ return _fail(last_err or "工具循环达上限仍无最终回答")
2051
+ return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),
2052
+ "error": "", "model": model, "provider_name": bi["provider_name"],
2053
+ "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
2054
+ "raw": _perf(),
2055
+ "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}