magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -16,8 +16,8 @@ import (
16
16
  "errors"
17
17
  "os/exec"
18
18
  "strconv"
19
- "strings"
20
19
  "syscall"
20
+ "unsafe"
21
21
  )
22
22
 
23
23
  // createNewProcessGroup 对应 Windows CREATE_NEW_PROCESS_GROUP 标志。
@@ -53,14 +53,45 @@ func KillPID(pid int, group bool) error {
53
53
  return exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
54
54
  }
55
55
 
56
- // PIDAlive 用 tasklist 查该 pid 是否还在。
56
+ /*
57
+ ── PIDAlive:直接问内核,**绝不起子进程** ──
58
+
59
+ 2026-10-24 实测:原来的 `tasklist /FI "PID eq N"` 每次要 **6.7–7.5 秒**
60
+ (控制台进程 + Defender 扫描)。而 `--sessions` 会对每条**活着的**记录问一次
61
+ (normalizeDead)——有团队任务在跑时就是 5–10 秒,手机端「返回会话列表很忙」
62
+ 的真正根因(插件侧 exec 的 3 秒是另一层,见 client/corecmd.go)。
63
+ OpenProcess + GetExitCodeProcess 是纯内核调用,微秒级。
64
+ */
65
+ var (
66
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
67
+ procOpenProcess = kernel32.NewProc("OpenProcess")
68
+ procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
69
+ )
70
+
71
+ const (
72
+ // processQueryLimitedInformation 只要查询权限(Vista+),比 QUERY_INFORMATION 更容易拿到。
73
+ processQueryLimitedInformation = 0x1000
74
+ // stillActive STILL_ACTIVE:进程还活着时 GetExitCodeProcess 返回它。
75
+ stillActive = 259
76
+ // errorAccessDenied 打不开句柄(受保护 / 提权进程):进程**存在**,但查不了状态。
77
+ errorAccessDenied = syscall.Errno(5)
78
+ )
79
+
80
+ // PIDAlive 该 pid 是否还在(OpenProcess / GetExitCodeProcess,无子进程)。
57
81
  func PIDAlive(pid int) bool {
58
82
  if pid <= 0 {
59
83
  return false
60
84
  }
61
- out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
62
- if err != nil {
85
+ h, _, callErr := procOpenProcess.Call(uintptr(processQueryLimitedInformation), 0, uintptr(pid))
86
+ if h == 0 {
87
+ /* 打不开:ACCESS_DENIED 说明进程在(只是权限不够)→ 当活着,
88
+ 避免把受保护进程误判成 gone;其它错误(INVALID_PARAMETER)才是真没了。 */
89
+ return errors.Is(callErr, errorAccessDenied)
90
+ }
91
+ defer func() { _ = syscall.CloseHandle(syscall.Handle(h)) }()
92
+ var code uint32
93
+ if r, _, _ := procGetExitCodeProcess.Call(h, uintptr(unsafe.Pointer(&code))); r == 0 {
63
94
  return false
64
95
  }
65
- return strings.Contains(string(out), strconv.Itoa(pid))
96
+ return code == stillActive
66
97
  }
@@ -27,11 +27,9 @@ package agent
27
27
  // 收尾 {"type":"result","subtype":"success","result":"全文",...}
28
28
  //
29
29
  // trae:
30
- // -p --output-format stream-json --include-partial-messages
31
- // NDJSON 行 {"type":"stream_event","delta":{"role":"assistant",
32
- // "content":"增量"}};无 thinking 通道(模型侧不开 reasoning);
33
- // trae 的 stream-json 协议**不暴露独立工具事件通道**,KindToolUse/KindToolResult
34
- // 在 trae 下恒为空——这是协议限制,不是解析缺陷。
30
+ // traex exec --json,Codex 同源事件流(thread.started / item.* /
31
+ // turn.completed),解析在 trae_exec.go;正文、思考与工具都在 item 事件里。
32
+ // 无逐 token 正文增量:正文在 item.completed(agent_message) 整段给出。
35
33
 
36
34
  import (
37
35
  "bufio"
@@ -150,6 +148,15 @@ const (
150
148
  // 为什么不再走「每个子任务一张委派卡」:子任务已经是**独立会话**
151
149
  //(2026-10-22 用户定稿),过程在自己的会话里看;主会话只需要一张进度总览。
152
150
  KindTasks StreamEventKind = "tasks"
151
+ // KindFallback 主备降级(2026-10-26):主引擎失败 / 只有过程没有正文,本轮已由
152
+ // 备用引擎(或同引擎的备用模型)接上。StreamEvent.Fallback 带 from/to 两端。
153
+ //
154
+ // 为什么要有它:以前降级只发一条 notice(人话一行),客户端无法**结构化地**知道
155
+ // 「这条回答其实是我的备用模型给的」—— 额度用尽时用户配的主备是否真的生效,
156
+ // 界面上没有任何可认的证据(2026-10-26 报障:「主备模型还没真的发挥作用」,
157
+ // 作业记录里主备两条都配了、却查不到降级发生过没有)。
158
+ // 这条帧紧跟降级之后、备用引擎第一条事件之前;没降级时的原因走 `notice`。
159
+ KindFallback StreamEventKind = "fallback"
153
160
  )
154
161
 
155
162
  // StreamStatus 事件所描述的 item 状态 —— 对齐 agents-anywhere 的
@@ -196,6 +203,8 @@ type StreamEvent struct {
196
203
  // 有了它,调用方不必再分辨这次是 claude 的 tool_use 形态还是 control_request 形态
197
204
  //(两种形态在 wire 上字段完全不同,见 ask.go 的 ParseAskLine)。
198
205
  Ask *AskRequest
206
+ // Fallback 降级详情(仅 KindFallback 携带;其余为 nil)。
207
+ Fallback *FallbackEvent
199
208
 
200
209
  /* ── 状态收敛字段(2026-10-02,对齐 agents-anywhere 的 timeline item)──
201
210
  *
@@ -249,6 +258,25 @@ type StreamEvent struct {
249
258
  //(同走 agent.ReasonOf),同一次失败在事件流与 stderr 两处的说法必然一致。
250
259
  Reason string
251
260
 
261
+ /* ── 失败分类(2026-10-25)──
262
+ *
263
+ * 为什么要有这三个字段:客户端要画一张**额度卡**(什么时候恢复、该怎么办),
264
+ * 而 Error / Reason 只有一段人话原文。让每个客户端各自从原文里正则反推,
265
+ * 就是三端三套判据、各错各的;分类口径收在 core(见 limitnotice.go),
266
+ * 客户端只做展示。
267
+ *
268
+ * 空串 = 与以前完全一致(非 turn_failed 的事件、以及认不出类别的失败)。
269
+ * 按 CLIENT-CONTRACT 的「只增字段不升 contractVersion」规则添加,
270
+ * 老消费方忽略它们即可。 */
271
+
272
+ // ErrorKind 失败类别:quota(额度用尽)/ rate_limit(频率限制)/ ""(未分类)。
273
+ ErrorKind string
274
+ // ErrorCode 通告里的状态码(429 / 402 / 401 / 403 / 5xx),空 = 原文里没有。
275
+ ErrorCode string
276
+ // ResetAt 供应商给的恢复时刻(RFC3339)。空 = 通告里没说什么时候恢复 ——
277
+ // 客户端此时**不要**编「稍后」,把这一行整条省掉。
278
+ ResetAt string
279
+
252
280
  /* Parent 父委派 id(2026-10-21,团队模式)。
253
281
  *
254
282
  * 语义与 claude 系原生的 `parent_tool_use_id` 完全一致:非空表示「这条事件发生在
@@ -312,7 +340,8 @@ func streamArgsFor(e Engine) []string {
312
340
  case *CodeBuddyAIEngine:
313
341
  return []string{"stream-json"}
314
342
  case *TraeEngine:
315
- return []string{"stream-json"}
343
+ // Codex 同源的 `exec --json` JSONL 事件流(trae_exec.go)。
344
+ return []string{"jsonl"}
316
345
  case *LLMEngine:
317
346
  return []string{"stream"} // llm prompt 默认流式(纯文本 stdout)
318
347
  case *DshEngine:
@@ -526,10 +555,19 @@ func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []strin
526
555
  // extraEnv 追加到继承环境之后(重复 key 取后者)—— dsh 的权限档位就是这么下发的
527
556
  // (它没有逐调用的权限 flag,只有 DSH_PERMISSION_MODE,见 permission.go)。
528
557
  func runStreamLinesIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, streamStdout bool, onLine func(line string) error) (string, string, error) {
558
+ return runStreamLinesInStdin(ctx, dir, extraEnv, "", bin, args, streamStdout, onLine)
559
+ }
560
+
561
+ // runStreamLinesInStdin 同 runStreamLinesIn,额外把 stdinText 喂给子进程 stdin
562
+ // (空 = 旧行为)。dsh headless 在 Windows 上靠它传多行提示词,见 dshHeadlessPromptArg。
563
+ func runStreamLinesInStdin(ctx context.Context, dir string, extraEnv []string, stdinText, bin string, args []string, streamStdout bool, onLine func(line string) error) (string, string, error) {
529
564
  cmd := newStreamCmdIn(dir, bin, args)
530
565
  if len(extraEnv) > 0 {
531
566
  cmd.Env = append(cmd.Env, extraEnv...)
532
567
  }
568
+ if stdinText != "" {
569
+ cmd.Stdin = strings.NewReader(stdinText)
570
+ }
533
571
 
534
572
  stdoutR, stdoutW, err := os.Pipe()
535
573
  if err != nil {
@@ -748,6 +786,12 @@ type streamAccumulator struct {
748
786
  // 万一引擎推了多条 max_tokens 也只报一次(与 askSeen / imgSeen 同一手法)。
749
787
  maxTokensNoticed bool
750
788
 
789
+ // toolMissingSeen 已提示过「工具不在当前工具集」的工具名(2026-10-24)。
790
+ //
791
+ // 同一句 CLI 错误可能走多条路各出现一次(增量 delta + assistant 聚合行 +
792
+ // result 行),提示只该出现一次 —— 与 askSeen / imgSeen 同一手法。
793
+ toolMissingSeen map[string]bool
794
+
751
795
  /* ── 状态收敛状态机(2026-10-02,见 StreamEvent 的状态收敛字段注释)──
752
796
  *
753
797
  * agents-anywhere 的 ClaudeStreamAccumulator(runtimes/claude/timeline/stream.py)
@@ -931,6 +975,16 @@ func (a *streamAccumulator) emitTextDelta(index int, kind StreamEventKind, text
931
975
  if text == "" {
932
976
  return
933
977
  }
978
+ /* 「工具不存在」是 CLI 注入的**正文**(见 stripToolMissingLines 的说明):
979
+ 摘掉并改发过程提示,别让一句英文报错混进回答。 */
980
+ if kind == KindText {
981
+ var missing []string
982
+ text, missing = stripToolMissingLines(text)
983
+ a.noteToolMissing(missing)
984
+ if text == "" {
985
+ return
986
+ }
987
+ }
934
988
  if a.textBlocks == nil {
935
989
  a.textBlocks = make(map[int]string)
936
990
  }
@@ -964,6 +1018,15 @@ func (a *streamAccumulator) emitTextDelta(index int, kind StreamEventKind, text
964
1018
  // 所以先与已累积正文做前缀比对:完全一致静默收敛、多尾巴只补差量。
965
1019
  // 旧版 CLI(聚合行是正文唯一来源)行为不变:accumulated 为空时原样发。
966
1020
  func (a *streamAccumulator) emitAggregateText(full string) {
1021
+ if full == "" {
1022
+ return
1023
+ }
1024
+ /* 过滤必须放在**前缀比对之前**:聚合行是整条消息全文,若把错误行留在
1025
+ 里面,与已发增量(已过滤)永远对不上 → 走 default 分支整段重发,
1026
+ 正文里又会多出一份全文。 */
1027
+ var missing []string
1028
+ full, missing = stripToolMissingLines(full)
1029
+ a.noteToolMissing(missing)
967
1030
  if full == "" {
968
1031
  return
969
1032
  }
@@ -1408,6 +1471,88 @@ func extractToolResultText(raw json.RawMessage) string {
1408
1471
  return ""
1409
1472
  }
1410
1473
 
1474
+ /* ── 「工具不在当前工具集」的 CLI 错误行(2026-10-24)──────────────
1475
+ *
1476
+ * 现象(真机抓包,codebuddy + hy3):模型凭训练记忆调用 AskUserQuestion,
1477
+ * codebuddy CLI 没有这个工具,于是把错误当成**正文 text 块**推回来:
1478
+ *
1479
+ * {"type":"assistant","message":{"content":[{"type":"text",
1480
+ * "text":"Tool \"AskUserQuestion\" does not exist in the current tool set."}]}}
1481
+ *
1482
+ * 不处理的话这句话被拼进正文(用户看到「一段回答中间夹着这句英文」),
1483
+ * 历史回放同样脏。这里把它从正文里摘掉,改发一条 KindNotice(工具失败提示),
1484
+ * 正文只留模型真正说的话。
1485
+ *
1486
+ * 只认这一句固定模板(CLI 自己注入的错误,不可能是模型正文);模型正常讨论
1487
+ * 工具时不会逐字写成这个形状。 */
1488
+ const (
1489
+ toolMissingPrefix = `Tool "`
1490
+ toolMissingSuffix = ` does not exist in the current tool set.`
1491
+ )
1492
+
1493
+ // stripToolMissingLines 从正文里摘掉 CLI 注入的「工具不存在」错误行。
1494
+ // 返回清洗后的正文与被摘掉的工具名(调用方据此发提示)。
1495
+ func stripToolMissingLines(s string) (string, []string) {
1496
+ if !strings.Contains(s, toolMissingPrefix) {
1497
+ return s, nil
1498
+ }
1499
+ var (
1500
+ b strings.Builder
1501
+ tools []string
1502
+ )
1503
+ for {
1504
+ i := strings.Index(s, toolMissingPrefix)
1505
+ if i < 0 {
1506
+ break
1507
+ }
1508
+ rest := s[i+len(toolMissingPrefix):]
1509
+ j := strings.IndexByte(rest, '"')
1510
+ if j <= 0 {
1511
+ break
1512
+ }
1513
+ name := rest[:j]
1514
+ after := rest[j+1:]
1515
+ if !strings.HasPrefix(after, toolMissingSuffix) {
1516
+ // 只是正文里恰好出现了同样的开头 → 原样留下,继续往后找。
1517
+ b.WriteString(s[:i+len(toolMissingPrefix)])
1518
+ s = rest
1519
+ continue
1520
+ }
1521
+ b.WriteString(s[:i])
1522
+ tools = append(tools, name)
1523
+ s = after[len(toolMissingSuffix):]
1524
+ // 摘掉整行:两侧各吞掉一个换行,避免正文里多出空行。
1525
+ left := b.String()
1526
+ if strings.HasSuffix(left, "\n") && strings.HasPrefix(s, "\n") {
1527
+ s = s[1:]
1528
+ }
1529
+ }
1530
+ b.WriteString(s)
1531
+ if len(tools) == 0 {
1532
+ // 只是正文里出现了同样的开头:原样返回(b 就是原文,不能返回被
1533
+ // 拆过的 s —— 那会丢掉前面已写进 b 的部分)。
1534
+ return b.String(), nil
1535
+ }
1536
+ return strings.Trim(b.String(), "\n"), tools
1537
+ }
1538
+
1539
+ // noteToolMissing 为被摘掉的错误行发一条过程提示(同一工具名只报一次)。
1540
+ func (a *streamAccumulator) noteToolMissing(names []string) {
1541
+ if len(names) == 0 {
1542
+ return
1543
+ }
1544
+ if a.toolMissingSeen == nil {
1545
+ a.toolMissingSeen = make(map[string]bool)
1546
+ }
1547
+ for _, n := range names {
1548
+ if a.toolMissingSeen[n] {
1549
+ continue
1550
+ }
1551
+ a.toolMissingSeen[n] = true
1552
+ a.emit(KindNotice, fmt.Sprintf("工具 %q 不在当前工具集,本次调用已忽略", n))
1553
+ }
1554
+ }
1555
+
1411
1556
  // assistantLineText 从 assistant 聚合行里取出纯文本正文。
1412
1557
  //
1413
1558
  // 行形状(claude / codebuddy 同族):
@@ -1551,6 +1696,10 @@ func (a *streamAccumulator) emitTurnEnd(text, sessionID string) {
1551
1696
  if a.OnEvent == nil {
1552
1697
  return
1553
1698
  }
1699
+ // result 行也可能带着同一条 CLI 错误(与增量路重复出现时 noteToolMissing 去重)。
1700
+ var missing []string
1701
+ text, missing = stripToolMissingLines(text)
1702
+ a.noteToolMissing(missing)
1554
1703
  if a.SanitizeTurnText != nil {
1555
1704
  text = a.SanitizeTurnText(text)
1556
1705
  }
@@ -1599,6 +1748,10 @@ func (a *streamAccumulator) emitTurnFailed(sessionID string, err error) {
1599
1748
  Reason: ReasonOf(err),
1600
1749
  Status: StatusFailed,
1601
1750
  }
1751
+ /* 「协议没跑完」的错误串里也可能夹着供应商的 429 原文(runner 的重试判据就是
1752
+ 靠这种子串认出来的),所以这里同样分一次类 —— 认出来客户端才画得出额度卡。 */
1753
+ ev.ErrorKind, ev.ErrorCode = classifyNotice(ev.Reason + "\n" + ev.Error)
1754
+ ev.ResetAt = noticeResetRFC3339(ev.Reason + "\n" + ev.Error)
1602
1755
  // 已建立 item 时把失败也挂到同一个 item 上:partial 的内容仍然存在,
1603
1756
  // 只是这条 item 不会再更新了(Status=failed 明确终止增长)。
1604
1757
  if attrs, ok := a.trackers().failText(a.joinBlocks(a.textBlocks)); ok {
@@ -1632,6 +1785,13 @@ func (a *streamAccumulator) emitLimitNoticeFailed(sessionID, notice, body string
1632
1785
  Reason: notice,
1633
1786
  Status: StatusFailed,
1634
1787
  }
1788
+ /* 分类与恢复时刻就在这里定(客户端只展示,不自己解析原文)——
1789
+ 这条路径**已经认定**是限流/额度通告,所以 kind 至少是 rate_limit。 */
1790
+ ev.ErrorKind, ev.ErrorCode = classifyNotice(notice)
1791
+ if ev.ErrorKind == "" {
1792
+ ev.ErrorKind = ErrorKindRateLimit
1793
+ }
1794
+ ev.ResetAt = noticeResetRFC3339(notice)
1635
1795
  /* 快照 = 剥掉通告的正文;正文为空(这一轮引擎只回了通告)时退回**累积正文** ——
1636
1796
  引擎把通告混在最后一条 assistant 消息里时,那里面也夹着它,再判一次剥掉。
1637
1797
  (累积正文里判不出来就原样留着:宁可多留一句话,也不把用户真看过的正文抹成空。) */
@@ -5,7 +5,7 @@ package agent
5
5
  // 用 fake CLI 脚本回放三家 CLI 的真实 NDJSON 样本(2026-09-15 实测抓包),
6
6
  // 验证:
7
7
  // - claude/codebuddy:thinking_delta / text_delta 解析与转发
8
- // - trae:delta.content 直出增量解析
8
+ // - trae 2.0:exec --json 的 JSONL(reasoning / command_execution / agent_message)
9
9
  // - result 收尾行:全文以 result.result 为准
10
10
  // - 流中断(无 result 行)→ 报错
11
11
  // - 超时杀进程组
@@ -150,14 +150,16 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"<user_quer
150
150
 
151
151
  // ── trae 流式 ─────────────────────────────────────────────────
152
152
 
153
- func TestTraeStreamParsesDeltaContent(t *testing.T) {
154
- // 样本精简自真实抓包:delta.content 直出,无 thinking 通道。
153
+ func TestTraeStreamExecJSON(t *testing.T) {
154
+ // 2.0:Codex 同源 JSONL(reasoning / command_execution / agent_message)。
155
155
  cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
156
- echo '{"type":"system","subtype":"init","session_id":"t-1"}'
157
- echo '{"type":"stream_event","session_id":"t-1","delta":{"role":"assistant","content":"2"}}'
158
- echo '{"type":"stream_event","session_id":"t-1","delta":{"role":"assistant","content":"就是这个"}}'
159
- echo '{"type":"assistant","session_id":"t-1","message":{"role":"assistant","content":"2就是这个"}}'
160
- echo '{"type":"result","subtype":"success","session_id":"t-1","result":"2就是这个","is_error":false}'
156
+ printf '%s\n' '{"type":"thread.started","thread_id":"t-1"}'
157
+ printf '%s\n' '{"type":"item.completed","item":{"id":"r","type":"reasoning","text":"想"}}'
158
+ printf '%s\n' '{"type":"item.started","item":{"id":"c","type":"command_execution","command":"ls"}}'
159
+ printf '%s\n' '{"type":"item.completed","item":{"id":"c","type":"command_execution","command":"ls","aggregated_output":"a.txt"}}'
160
+ printf '%s\n' '{"type":"item.updated","item":{"id":"m","type":"agent_message","text":"2"}}'
161
+ printf '%s\n' '{"type":"item.completed","item":{"id":"m","type":"agent_message","text":"2就是这个"}}'
162
+ printf '%s\n' '{"type":"turn.completed"}'
161
163
  `)
162
164
  e := &TraeEngine{BinPath: cli}
163
165
  events, onEvent := collectEvents()
@@ -171,11 +173,20 @@ echo '{"type":"result","subtype":"success","session_id":"t-1","result":"2就是
171
173
  if res.Text != "2就是这个" {
172
174
  t.Errorf("Text = %q", res.Text)
173
175
  }
174
- if res.Thinking != "" {
175
- t.Errorf("trae 无 thinking 通道,Thinking 应为空,got %q", res.Thinking)
176
+ if res.Thinking != "想" {
177
+ t.Errorf("Thinking = %q want 想", res.Thinking)
176
178
  }
177
- if len(*events) != 2 {
178
- t.Errorf("events = %d, want 2(assistant 聚合行不重复 emit)", len(*events))
179
+ if res.SessionID != "t-1" {
180
+ t.Errorf("SessionID = %q want t-1", res.SessionID)
181
+ }
182
+ wantKinds := []StreamEventKind{KindThinking, KindToolUse, KindToolResult, KindText, KindText, KindTurnEnd}
183
+ if len(*events) != len(wantKinds) {
184
+ t.Fatalf("events = %d want %d: %+v", len(*events), len(wantKinds), *events)
185
+ }
186
+ for i, want := range wantKinds {
187
+ if (*events)[i].Kind != want {
188
+ t.Errorf("events[%d] = %v want %v", i, (*events)[i].Kind, want)
189
+ }
179
190
  }
180
191
  }
181
192
 
@@ -385,32 +396,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok"}'
385
396
  }
386
397
  }
387
398
 
388
- // trae 协议不暴露工具事件:即使用户开了 --tools on,流式输出也不带工具事件。
389
- // 这是协议限制,不是解析缺陷。
390
- func TestTraeStreamNoToolEvents(t *testing.T) {
391
- cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
392
- echo '{"type":"stream_event","delta":{"role":"assistant","content":"正文"}}'
393
- echo '{"type":"result","subtype":"success","is_error":false,"result":"正文"}'
394
- `)
395
- e := &TraeEngine{BinPath: cli}
396
- events, onEvent := collectEvents()
397
-
398
- res, err := e.Stream(context.Background(), Request{
399
- Messages: []Message{{Role: "user", Content: "hi"}},
400
- }, onEvent)
401
- if err != nil {
402
- t.Fatalf("Stream: %v", err)
403
- }
404
-
405
- for _, ev := range *events {
406
- if ev.Kind == KindToolUse || ev.Kind == KindToolResult {
407
- t.Errorf("trae 不应发射工具事件, got %v", ev.Kind)
408
- }
409
- }
410
- if len(res.Tools) != 0 {
411
- t.Errorf("trae res.Tools 应为 nil/空, got %d", len(res.Tools))
412
- }
413
- }
399
+ // trae 2.0 的工具事件由 TestTraeStreamExecJSON 覆盖(command_execution →
400
+ // tool_use/tool_result);这里不再保留 1.x「协议不暴露工具」的旧断言。
414
401
 
415
402
  // 协议异常:open tool_use 没收到 stop 直接来 result → 兜底回收,不丢事件。
416
403
  func TestClaudeStreamToolUseOrphanRecovery(t *testing.T) {
@@ -704,43 +704,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"<think>思
704
704
  }
705
705
  }
706
706
 
707
- // TestTraeStreamStillDeltaOnly trae 协议没有 message 概念,
708
- // 但降级 id 仍要**本轮内稳定**(否则一条消息被拆成多段)。
709
- func TestTraeStreamStillDeltaOnly(t *testing.T) {
710
- skipIfNoShCLI(t)
711
- cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
712
- echo '{"type":"stream_event","delta":{"role":"assistant","content":"你"}}'
713
- echo '{"type":"stream_event","delta":{"role":"assistant","content":"好"}}'
714
- echo '{"type":"result","subtype":"success","is_error":false,"result":"你好"}'
715
- `)
716
- e := &TraeEngine{BinPath: cli}
717
- events, onEvent := collectEvents()
718
- if _, err := e.Stream(context.Background(), Request{
719
- Messages: []Message{{Role: "user", Content: "hi"}},
720
- }, onEvent); err != nil {
721
- t.Fatalf("Stream: %v", err)
722
- }
723
- var id string
724
- var lastSnap string
725
- for _, ev := range *events {
726
- if ev.Kind != KindText {
727
- continue
728
- }
729
- if ev.ItemID == "" {
730
- t.Fatal("trae 的正文也应有 item id(降级序号)")
731
- }
732
- if id == "" {
733
- id = ev.ItemID
734
- }
735
- if ev.ItemID != id {
736
- t.Fatalf("trae 降级 id 不稳定:%q vs %q", id, ev.ItemID)
737
- }
738
- lastSnap = ev.Snapshot
739
- }
740
- if lastSnap != "你好" {
741
- t.Errorf("最终 snapshot = %q,want 你好", lastSnap)
742
- }
743
- }
707
+ // trae 2.0 走 trae_exec.go 的独立累加器(不使用 streamAccumulator),
708
+ // 其事件映射由 stream_test.go 的 TestTraeStreamExecJSON 覆盖。
744
709
 
745
710
  // ── arkclaw 专项(A2A message/stream)─────────────────────────────────
746
711
  //
@@ -19,6 +19,7 @@ import (
19
19
  "fmt"
20
20
  "io"
21
21
  "strings"
22
+ "sync"
22
23
  )
23
24
 
24
25
  // streamJSONResult 两家 CLI 收尾行(type=result)的公共形状。
@@ -64,7 +65,17 @@ func runStreamJSONIn(
64
65
  ⚠️ 抛错会让 lineHandler 中止读循环,那里会顺手 kill 掉引擎进程组
65
66
  (见 runStreamCLIStdinReaderIn),所以这里不能改成「先把事件转发出去、
66
67
  等收尾再抛」—— 那样 turn_failed 会晚到,消费方早就按 turn_end 收尾了。 */
67
- if notice, body, hit := splitLimitNotice(fin.Result); hit {
68
+ /* ⚠️ **判的是「屏幕上真正显示的那段」,不是 result 字段**(2026-10-25):
69
+ AggregateOnly 引擎(codebuddy 实测)不发 content_block_delta,正文只随
70
+ assistant 聚合行以 text 增量下来,而收尾 result 行的 result **是空串** ——
71
+ 只判 fin.Result 的话,额度通告永远认不出来,界面上就是「一整段 429 英文 +
72
+ 已完成」(用户 2026-10-25 截图报障:「没额度了」)。
73
+ 口径与 emitTurnEnd 的落笔一致:result 非空以它为准,空则退回累积正文。 */
74
+ shown := fin.Result
75
+ if strings.TrimSpace(shown) == "" {
76
+ shown = acc.joinBlocks(acc.textBlocks)
77
+ }
78
+ if notice, body, hit := splitLimitNotice(shown); hit {
68
79
  acc.emitLimitNoticeFailed(fin.SessionID, notice, body)
69
80
  return errors.New(notice)
70
81
  }
@@ -81,9 +92,13 @@ func runStreamJSONIn(
81
92
  // streamJSONInputPipe 常驻会话的 stdin:先写首条 user 消息(提示词 + 附件),
82
93
  // 之后每从 appendCh 收到一条就再写一轮;appendCh 关闭 → 关掉写端 → 子进程收尾。
83
94
  //
95
+ // replyCh 非 nil 时同时接收**原生控制应答行**(control_response,见 Request.Reply):
96
+ // 它和 appendCh 是两条独立的通道 —— 追加是「下一轮用户消息」,应答是「这一轮正等着的裁决」,
97
+ // 混在一条通道里会让 CLI 把应答当 user 消息读(实测后果:整轮卡死)。
98
+ //
84
99
  // 返回的 reader 交给子进程当 stdin(不 EOF 就一直活着)。写失败(子进程已退)时
85
100
  // 直接收摊,不让 goroutine 泄漏。
86
- func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan string) (io.Reader, error) {
101
+ func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan string, replyCh <-chan string) (io.Reader, error) {
87
102
  first, err := streamJSONUserLine(prompt, atts)
88
103
  if err != nil {
89
104
  return nil, err
@@ -94,19 +109,116 @@ func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan strin
94
109
  if _, err := pw.Write([]byte(first)); err != nil {
95
110
  return
96
111
  }
97
- for msg := range appendCh {
98
- line, lerr := streamJSONUserLine(msg, nil)
99
- if lerr != nil {
100
- continue // 追加内容构造失败(不该发生):跳过这一条,别把整个会话带崩
112
+ for {
113
+ select {
114
+ case msg, ok := <-appendCh:
115
+ if !ok {
116
+ return // 追加入口关闭 = 收工
117
+ }
118
+ line, lerr := streamJSONUserLine(msg, nil)
119
+ if lerr != nil {
120
+ continue // 追加内容构造失败(不该发生):跳过这一条,别把整个会话带崩
121
+ }
122
+ if _, err := pw.Write([]byte(line)); err != nil {
123
+ return
124
+ }
125
+ case raw, ok := <-replyCh:
126
+ if !ok {
127
+ replyCh = nil // 应答通道关闭不等于收工:继续等 append
128
+ continue
129
+ }
130
+ if _, err := pw.Write(ensureTrailingNewline(raw)); err != nil {
131
+ return
132
+ }
101
133
  }
102
- if _, err := pw.Write([]byte(line)); err != nil {
134
+ }
135
+ }()
136
+ return pr, nil
137
+ }
138
+
139
+ // streamJSONLiveInput 一轮式调用的「活 stdin」:写完首条 user 消息后**保持写端打开**,
140
+ // 只用来把原生控制应答行(control_response)喂回去;done 关闭即 EOF。
141
+ //
142
+ // 为什么不能继续用 strings.Reader(2026-10-25 实测):那个 reader 一读完就 EOF,
143
+ // CLI 随后发出 control_request(计划审批)时 stdin 已经关掉 —— 应答没有落点,
144
+ // 整轮只能挂到超时。而只要把 stdin 留着,CLI 在被批准后就会继续跑完这一轮。
145
+ // ⚠️ 代价:stdin 一直开着时 CLI 在吐完 result 后**不会自己退出**(实测),
146
+ // 所以调用方必须在收到 turn_end / result / error 时立刻关掉 done —— 不能等 Stream 返回。
147
+ func streamJSONLiveInput(prompt string, atts []Attachment, replyCh <-chan string, done <-chan struct{}) (io.Reader, error) {
148
+ first, err := streamJSONUserLine(prompt, atts)
149
+ if err != nil {
150
+ return nil, err
151
+ }
152
+ pr, pw := io.Pipe()
153
+ go func() {
154
+ defer pw.Close()
155
+ if _, err := pw.Write([]byte(first)); err != nil {
156
+ return
157
+ }
158
+ for {
159
+ select {
160
+ case <-done:
103
161
  return
162
+ case raw, ok := <-replyCh:
163
+ if !ok {
164
+ replyCh = nil
165
+ continue
166
+ }
167
+ if _, err := pw.Write(ensureTrailingNewline(raw)); err != nil {
168
+ return
169
+ }
104
170
  }
105
171
  }
106
172
  }()
107
173
  return pr, nil
108
174
  }
109
175
 
176
+ // streamJSONStdinFor 按 Request 的通道形态挑 stdin 实现(claude / codebuddy 共用)。
177
+ //
178
+ // Append 非 nil → 常驻会话(续轮)流;Reply 一并接上
179
+ // Reply 非 nil → 一轮式「活 stdin」:只为准许回 control_response 而保持打开
180
+ // 都没有 → 老行为:一行写完即 EOF
181
+ //
182
+ // 返回的 close 由调用方 defer —— 活 stdin 下它负责 EOF,让 CLI 退出。
183
+ func streamJSONStdinFor(req Request, prompt string) (io.Reader, func(), error) {
184
+ if req.Append != nil {
185
+ pipe, err := streamJSONInputPipe(prompt, req.Attachments, req.Append, req.Reply)
186
+ return pipe, func() {}, err
187
+ }
188
+ if req.Reply != nil {
189
+ done := make(chan struct{})
190
+ var once sync.Once
191
+ closeFn := func() { once.Do(func() { close(done) }) }
192
+ pipe, err := streamJSONLiveInput(prompt, req.Attachments, req.Reply, done)
193
+ if err != nil {
194
+ return nil, func() {}, err
195
+ }
196
+ return pipe, closeFn, nil
197
+ }
198
+ line, err := streamJSONUserLine(prompt, req.Attachments)
199
+ if err != nil {
200
+ return nil, func() {}, err
201
+ }
202
+ return strings.NewReader(line), func() {}, nil
203
+ }
204
+
205
+ // streamTerminalKind 这一条事件是否代表「本轮结束」(活 stdin 据此立刻 EOF)。
206
+ func streamTerminalKind(k StreamEventKind) bool {
207
+ switch k {
208
+ case KindTurnEnd, KindTurnFailed:
209
+ return true
210
+ }
211
+ return false
212
+ }
213
+
214
+ // ensureTrailingNewline 保证写进 stdin 的每一行都以换行结尾(NDJSON 一行一条)。
215
+ func ensureTrailingNewline(s string) []byte {
216
+ if strings.HasSuffix(s, "\n") {
217
+ return []byte(s)
218
+ }
219
+ return []byte(s + "\n")
220
+ }
221
+
110
222
  // streamJSONUserLine 把「提示词 + 附件」拼成一行 stream-json 输入(含结尾换行)。
111
223
  //
112
224
  // content 块顺序:图片附件(image block)在前、提示词文本在后 —— 与官方
@@ -24,7 +24,10 @@ func argLogger(t *testing.T) (string, func() string) {
24
24
  log := filepath.Join(dir, "args.log")
25
25
  cli := filepath.Join(dir, "fake")
26
26
  script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log +
27
- "\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n"
27
+ "\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n" +
28
+ // trae 2.0 的 JSONL 事件(同一个假 CLI 要同时被 claude/codebuddy/trae 复用)
29
+ "\necho '{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"agent_message\",\"text\":\"ok\"}}'\n" +
30
+ "\necho '{\"type\":\"turn.completed\"}'\n"
28
31
  if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
29
32
  t.Fatal(err)
30
33
  }
@@ -131,9 +134,9 @@ func TestTraeToolModeSuffix(t *testing.T) {
131
134
  }
132
135
  }
133
136
 
134
- // TestTraeToolModeMapping 固化 trae 的工具模式契约(2026-09-16 真机验证)。
137
+ // TestTraeToolModeMapping 固化 trae 的工具模式契约(2026-09-16 真机验证,2.0 同构)。
135
138
  //
136
- // trae-cli 的权限开关实测语义:
139
+ // TRAE CLI 的权限开关语义:
137
140
  // - --allowed-tool 只做「自动批准」,不裁剪工具集(--allowed-tool WebFetch
138
141
  // 仍下发全部 18 个工具);
139
142
  // - --disallowed-tool 才真正做减法;