magic-agent 2.4.2 → 2.6.0

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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -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 +37 -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 +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -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 +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  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 +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -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)的公共形状。
@@ -81,9 +82,13 @@ func runStreamJSONIn(
81
82
  // streamJSONInputPipe 常驻会话的 stdin:先写首条 user 消息(提示词 + 附件),
82
83
  // 之后每从 appendCh 收到一条就再写一轮;appendCh 关闭 → 关掉写端 → 子进程收尾。
83
84
  //
85
+ // replyCh 非 nil 时同时接收**原生控制应答行**(control_response,见 Request.Reply):
86
+ // 它和 appendCh 是两条独立的通道 —— 追加是「下一轮用户消息」,应答是「这一轮正等着的裁决」,
87
+ // 混在一条通道里会让 CLI 把应答当 user 消息读(实测后果:整轮卡死)。
88
+ //
84
89
  // 返回的 reader 交给子进程当 stdin(不 EOF 就一直活着)。写失败(子进程已退)时
85
90
  // 直接收摊,不让 goroutine 泄漏。
86
- func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan string) (io.Reader, error) {
91
+ func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan string, replyCh <-chan string) (io.Reader, error) {
87
92
  first, err := streamJSONUserLine(prompt, atts)
88
93
  if err != nil {
89
94
  return nil, err
@@ -94,19 +99,116 @@ func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan strin
94
99
  if _, err := pw.Write([]byte(first)); err != nil {
95
100
  return
96
101
  }
97
- for msg := range appendCh {
98
- line, lerr := streamJSONUserLine(msg, nil)
99
- if lerr != nil {
100
- continue // 追加内容构造失败(不该发生):跳过这一条,别把整个会话带崩
102
+ for {
103
+ select {
104
+ case msg, ok := <-appendCh:
105
+ if !ok {
106
+ return // 追加入口关闭 = 收工
107
+ }
108
+ line, lerr := streamJSONUserLine(msg, nil)
109
+ if lerr != nil {
110
+ continue // 追加内容构造失败(不该发生):跳过这一条,别把整个会话带崩
111
+ }
112
+ if _, err := pw.Write([]byte(line)); err != nil {
113
+ return
114
+ }
115
+ case raw, ok := <-replyCh:
116
+ if !ok {
117
+ replyCh = nil // 应答通道关闭不等于收工:继续等 append
118
+ continue
119
+ }
120
+ if _, err := pw.Write(ensureTrailingNewline(raw)); err != nil {
121
+ return
122
+ }
101
123
  }
102
- if _, err := pw.Write([]byte(line)); err != nil {
124
+ }
125
+ }()
126
+ return pr, nil
127
+ }
128
+
129
+ // streamJSONLiveInput 一轮式调用的「活 stdin」:写完首条 user 消息后**保持写端打开**,
130
+ // 只用来把原生控制应答行(control_response)喂回去;done 关闭即 EOF。
131
+ //
132
+ // 为什么不能继续用 strings.Reader(2026-10-25 实测):那个 reader 一读完就 EOF,
133
+ // CLI 随后发出 control_request(计划审批)时 stdin 已经关掉 —— 应答没有落点,
134
+ // 整轮只能挂到超时。而只要把 stdin 留着,CLI 在被批准后就会继续跑完这一轮。
135
+ // ⚠️ 代价:stdin 一直开着时 CLI 在吐完 result 后**不会自己退出**(实测),
136
+ // 所以调用方必须在收到 turn_end / result / error 时立刻关掉 done —— 不能等 Stream 返回。
137
+ func streamJSONLiveInput(prompt string, atts []Attachment, replyCh <-chan string, done <-chan struct{}) (io.Reader, error) {
138
+ first, err := streamJSONUserLine(prompt, atts)
139
+ if err != nil {
140
+ return nil, err
141
+ }
142
+ pr, pw := io.Pipe()
143
+ go func() {
144
+ defer pw.Close()
145
+ if _, err := pw.Write([]byte(first)); err != nil {
146
+ return
147
+ }
148
+ for {
149
+ select {
150
+ case <-done:
103
151
  return
152
+ case raw, ok := <-replyCh:
153
+ if !ok {
154
+ replyCh = nil
155
+ continue
156
+ }
157
+ if _, err := pw.Write(ensureTrailingNewline(raw)); err != nil {
158
+ return
159
+ }
104
160
  }
105
161
  }
106
162
  }()
107
163
  return pr, nil
108
164
  }
109
165
 
166
+ // streamJSONStdinFor 按 Request 的通道形态挑 stdin 实现(claude / codebuddy 共用)。
167
+ //
168
+ // Append 非 nil → 常驻会话(续轮)流;Reply 一并接上
169
+ // Reply 非 nil → 一轮式「活 stdin」:只为准许回 control_response 而保持打开
170
+ // 都没有 → 老行为:一行写完即 EOF
171
+ //
172
+ // 返回的 close 由调用方 defer —— 活 stdin 下它负责 EOF,让 CLI 退出。
173
+ func streamJSONStdinFor(req Request, prompt string) (io.Reader, func(), error) {
174
+ if req.Append != nil {
175
+ pipe, err := streamJSONInputPipe(prompt, req.Attachments, req.Append, req.Reply)
176
+ return pipe, func() {}, err
177
+ }
178
+ if req.Reply != nil {
179
+ done := make(chan struct{})
180
+ var once sync.Once
181
+ closeFn := func() { once.Do(func() { close(done) }) }
182
+ pipe, err := streamJSONLiveInput(prompt, req.Attachments, req.Reply, done)
183
+ if err != nil {
184
+ return nil, func() {}, err
185
+ }
186
+ return pipe, closeFn, nil
187
+ }
188
+ line, err := streamJSONUserLine(prompt, req.Attachments)
189
+ if err != nil {
190
+ return nil, func() {}, err
191
+ }
192
+ return strings.NewReader(line), func() {}, nil
193
+ }
194
+
195
+ // streamTerminalKind 这一条事件是否代表「本轮结束」(活 stdin 据此立刻 EOF)。
196
+ func streamTerminalKind(k StreamEventKind) bool {
197
+ switch k {
198
+ case KindTurnEnd, KindTurnFailed:
199
+ return true
200
+ }
201
+ return false
202
+ }
203
+
204
+ // ensureTrailingNewline 保证写进 stdin 的每一行都以换行结尾(NDJSON 一行一条)。
205
+ func ensureTrailingNewline(s string) []byte {
206
+ if strings.HasSuffix(s, "\n") {
207
+ return []byte(s)
208
+ }
209
+ return []byte(s + "\n")
210
+ }
211
+
110
212
  // streamJSONUserLine 把「提示词 + 附件」拼成一行 stream-json 输入(含结尾换行)。
111
213
  //
112
214
  // 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 才真正做减法;
@@ -1,65 +1,58 @@
1
1
  package agent
2
2
 
3
- // trae.go - Trae CLI 引擎。
3
+ // trae.go - TRAE CLI 引擎(2.0,命令名 traex)。
4
+ //
5
+ // 后端:TRAE CLI 2.0(0.2xx,Rust 版),非交互入口为
6
+ // `traex exec --json`,输出 Codex 同源的 JSONL 事件流。
7
+ // 参数构造与事件解析见 trae_exec.go。
4
8
  //
5
- // 后端:trae-cli(~/.local/bin/trae-cli)。非交互模式:-p,纯文本输出。
6
9
  // 与 claude/codebuddy 的关键差异:
7
10
  //
8
- // - 没有 --model flag;模型默认取 trae 自身配置(~/.trae/trae_cli.yaml
9
- // 的 model.name)。显式指定模型用 -c "model.name=<name>" 覆盖。
10
- // - --output-format 有 json,但实测纯文本已足够稳定,这里用 text +
11
- // noToolSuffix(拼进 system 部分)压制 agent loop。
12
- // - 自带 --query-timeout,把外层超时同时传给 CLI,保证语义一致。
13
- // - 会话续接:--resume=<id>(pflag 可选值风格,必须等号传值);
14
- // 没有 --continue,续接最近一次会话用裸 --resume(AUTO 模式,
15
- // 实测 -p 非交互模式下可用,能接住上一轮记忆)。
16
- // - 工具:禁用靠 --disallowed-tool 逐个减法;启用靠全放行开关 -y。
17
- // trae 没有「只允许白名单这几个工具」的表达能力,白名单模式因此
18
- // 降级为「默认开启所有工具」(详见 buildArgs 的工具模式映射)。
19
- // - MaxTokens / Temperature 不支持:`-c <k>=<v>` 会整体覆盖模型配置块
20
- // (实测注入 model.max_tokens 后模型名漂移并触发配额错误),因此这两个
21
- // 参数构造时静默忽略(见 engine_base.go 矩阵②)。
11
+ // - 没有独立 system prompt 通道;system / 多轮消息由 FlattenPrompt
12
+ // 拼进提示词(off 模式再追加 noToolSuffix)。
13
+ // - 模型用 -m/--model(不是 1.x 的 -c model.name=);默认取
14
+ // ~/.trae/traecli.toml 的 model。
15
+ // - 续接:`exec resume <id>` / `exec resume --last`(没有 --continue)。
16
+ // - 工具:off 靠 --disallowed-tool 逐个减法;on / 白名单用 -y 全放行
17
+ // (trae 没有「只允许白名单」的表达能力)。
18
+ // - 没有 --query-timeout:超时完全由外层 runner 杀进程组。
19
+ // - MaxTokens / Temperature 不支持,静默忽略(见 engine_base.go 矩阵②)。
20
+ // - 附件没有原生图片通道(exec 的 -i 只收图片且与 1.x 行为不一致),
21
+ // 统一把绝对路径写进提示词,靠 CLI 的读文件工具。
22
22
  //
23
- // CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_TRAE_BIN → ~/.local/bin →
24
- // ~/bin → PATH(探测链统一收敛在 engine_base.go 的 cliBase)。
23
+ // CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_TRAE_BIN → 安装器默认位 →
24
+ // PATH(探测链统一收敛在 engine_base.go 的 cliBase)。
25
25
 
26
26
  import (
27
27
  "context"
28
- "encoding/json"
29
28
  "fmt"
30
29
  "os"
31
30
  "path/filepath"
32
- "strings"
33
31
  "time"
34
32
  )
35
33
 
36
- // TraeEngine 通过 trae-cli 实现 Engine。
34
+ // TraeEngine 通过 traex 实现 Engine。
37
35
  type TraeEngine struct {
38
36
  // BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
39
37
  BinPath string
40
38
 
41
- // Model 显式模型(非空时 -c model.name=<model> 覆盖 CLI 默认)。
39
+ // Model 显式模型(非空时 -m <model>)。
42
40
  Model string
43
41
  }
44
42
 
45
43
  // Name 实现 Engine。
46
44
  func (e *TraeEngine) Name() string { return "trae" }
47
45
 
48
- // DefaultTraeTimeout 单次尝试默认超时(2026-10-10 由 10 分钟统一上调到 1 小时)。
46
+ // DefaultTraeTimeout 单次尝试默认超时(1 小时,与其余 CLI 引擎对齐)。
49
47
  const DefaultTraeTimeout = time.Hour
50
48
 
51
49
  // traeOffDisableTools off 模式下逐个 --disallowed-tool 禁用的实体工具。
52
50
  //
53
- // 取自 trae-cli 实测的 18 个内置工具里「会读文件 / 会改机器」的那批:
54
- // Agent、ExitPlanMode、EnterPlanMode 是常驻元工具(实测 --disallowed-tool
55
- // 摘不掉,补集禁用后仍保留),Skill、TaskCreate/Get/List/Update、
56
- // BashOutput、KillShell 属会话内辅助、无外部副作用,故不在禁用之列。
57
- //
58
- // 注意工具名必须是真实存在的:编辑与写入在 trae 里叫 Edit / Write ——
59
- // 此前这里写的是并不存在的 `Replace`,等于 off 模式漏禁 Write。
51
+ // 取「会读文件 / 会改机器」的那批;Skill / 任务管理等会话内辅助工具
52
+ // 无外部副作用,不在禁用之列。
60
53
  var traeOffDisableTools = []string{"Bash", "Edit", "Write", "Glob", "Grep", "Read"}
61
54
 
62
- // bin 探测 trae-cli 路径(委托 cliBase 统一探测链)。
55
+ // bin 探测 traex 路径(委托 cliBase 统一探测链)。
63
56
  func (e *TraeEngine) bin() string {
64
57
  return traeBase.resolve(e.BinPath)
65
58
  }
@@ -73,42 +66,18 @@ func (e *TraeEngine) Detect() (bool, string) {
73
66
  return true, p
74
67
  }
75
68
 
76
- // TraeDefaultModel 读取 ~/.trae/trae_cli.yaml 顶层 model.name,
77
- // 返回 trae CLI 当前配置的默认模型(如 "My-MiniMax-M3")。
78
- // 轻量扫描解析(不引入 yaml 依赖);文件缺失/格式变化返回空串。
69
+ // TraeDefaultModel 返回 trae CLI 当前配置的默认模型(如 "MiniMax-M3.1-Flash")。
70
+ // 2.0 读 ~/.trae/traecli.toml 的顶层 `model = "..."`;文件缺失/格式变化返回空串。
79
71
  func TraeDefaultModel() string {
80
72
  home, err := os.UserHomeDir()
81
73
  if err != nil {
82
74
  return ""
83
75
  }
84
- data, err := os.ReadFile(filepath.Join(home, ".trae", "trae_cli.yaml"))
85
- if err != nil {
86
- return ""
87
- }
88
- inModelBlock := false
89
- for _, ln := range strings.Split(string(data), "\n") {
90
- trimmed := strings.TrimSpace(ln)
91
- if !inModelBlock {
92
- inModelBlock = ln == trimmed && strings.HasPrefix(trimmed, "model:")
93
- continue
94
- }
95
- if strings.HasPrefix(trimmed, "name:") {
96
- v := strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
97
- v = strings.Trim(v, `"'`)
98
- if v != "" {
99
- return v
100
- }
101
- }
102
- if ln == trimmed && trimmed != "" {
103
- inModelBlock = false
104
- }
105
- }
106
- return ""
76
+ return traeDefaultModelFromTOML(filepath.Join(home, ".trae", "traecli.toml"))
107
77
  }
108
78
 
109
- // ListModels 实现 ModelLister:`trae-cli models --json` 是 trae 自己的
110
- // 权威清单(含用户自定义模型,如 ~/.trae/trae_cli.yaml 里的 My-MiniMax-M3)。
111
- // 取 name(= -c model.name=<name> 接受的那个标识),缺失时退回 real_name。
79
+ // ListModels 实现 ModelLister:`traex models --json` 是 CLI 自己的权威清单。
80
+ // 取 name(= -m 接受的标识),缺失时退回 real_name。
112
81
  func (e *TraeEngine) ListModels(ctx context.Context) ([]string, error) {
113
82
  stdout, err := probeCLI(ctx, "trae", e.bin(), "models", "--json")
114
83
  if err != nil {
@@ -120,48 +89,26 @@ func (e *TraeEngine) ListModels(ctx context.Context) ([]string, error) {
120
89
  }
121
90
  models := modelNamesFromObjects(objs, "name", "real_name")
122
91
  if len(models) == 0 {
123
- return nil, fmt.Errorf("%w: trae-cli models --json returned no entries", ErrNoModelSource)
92
+ return nil, fmt.Errorf("%w: traecli models --json returned no entries", ErrNoModelSource)
124
93
  }
125
94
  return models, nil
126
95
  }
127
96
 
128
- // Complete 实现 Engine:单次调用 trae-cli。
97
+ // Complete 实现 Engine:单次 `exec --json`,把 JSONL 聚合成最终正文。
129
98
  func (e *TraeEngine) Complete(ctx context.Context, req Request) (Response, error) {
130
99
  start := time.Now()
131
100
  bin := e.bin()
132
101
  if bin == "" {
133
102
  return Response{}, fmt.Errorf("trae CLI not found; set MAGIC_AGENT_TRAE_BIN")
134
103
  }
104
+ return e.completeExec(ctx, bin, req, start)
105
+ }
135
106
 
136
- prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
137
- if prompt == "" {
138
- return Response{}, fmt.Errorf("trae: empty prompt")
139
- }
140
- // trae 没有独立 system 注入 flag;无 system prompt 时约束也要生效。
141
- // 仅 off 模式追加:on/白名单下 noToolSuffix 会与「允许调用工具」冲突。
142
- if req.SystemPrompt == "" && toolsIsOff(req) {
143
- prompt += noToolSuffix
144
- }
145
- // 附件:trae-cli 没有图片/附件输入通道(--help 无 image/attach/input-format),
146
- // 只能把绝对路径写进提示词,靠 trae 的读文件工具看 —— 因此需要工具可用。
147
- prompt = appendAttachmentSection(prompt, req.Attachments)
148
-
149
- args := e.buildArgs(req, prompt)
150
-
151
- // workspace:trae 无工作目录 flag(有 --add-dir 但那是追加额外目录),子进程 cwd 即原生方式
152
- stdout, stderr, err := runCLIIn(ctx, req.Workspace, bin, args...)
153
- if err != nil {
154
- return Response{}, wrapCliError("trae", stdout, stderr, err)
155
- }
156
-
157
- text := strings.TrimSpace(stdout)
158
- if text == "" {
159
- return Response{}, fmt.Errorf("trae CLI returned empty output")
160
- }
161
-
107
+ // modelLabel 本轮展示用模型名:请求显式 > 引擎注入 > CLI 配置默认。
108
+ func (e *TraeEngine) modelLabel(req Request) string {
162
109
  modelLabel := stripModelPrefix(req.Model)
163
110
  if modelLabel == "" {
164
- modelLabel = e.Model
111
+ modelLabel = stripModelPrefix(e.Model)
165
112
  }
166
113
  if modelLabel == "" {
167
114
  if m := TraeDefaultModel(); m != "" {
@@ -170,175 +117,35 @@ func (e *TraeEngine) Complete(ctx context.Context, req Request) (Response, error
170
117
  modelLabel = "cli-default"
171
118
  }
172
119
  }
173
- return Response{
174
- Text: text,
175
- Model: modelLabel,
176
- // 纯文本模式下 CLI 不回 session_id;续接请求时把传入的 id 原样
177
- // 回传,让调用方可以逐轮透传(新会话仍为空)。
178
- SessionID: req.SessionID,
179
- Latency: time.Since(start),
180
- }, nil
181
- }
182
-
183
- // buildArgs 构造 trae-cli 参数(Complete 与 Stream 共用)。
184
- func (e *TraeEngine) buildArgs(req Request, prompt string) []string {
185
- args := []string{"-p"}
186
- switch {
187
- case req.SessionID != "":
188
- // 续接指定会话:pflag 可选值风格(--resume string[="AUTO"]),
189
- // 必须用等号形式 --resume=<id>;空格形式下裸 id 会被吞成 AUTO 默认值。
190
- args = append(args, "--resume="+req.SessionID)
191
- case req.Continue:
192
- // 续接最近一次会话:裸 --resume 触发 AUTO 模式(实测 -p 可用)。
193
- args = append(args, "--resume")
194
- }
195
- // 模型:显式值 > 引擎注入值;都为空则用 CLI 自身默认。
196
- model := e.Model
197
- if model == "" {
198
- model = stripModelPrefix(req.Model)
199
- }
200
- if model != "" {
201
- args = append(args, "-c", "model.name="+model)
202
- }
203
- // 统一参数矩阵:req.MaxTokens / req.Temperature 在 trae 上明确不支持、
204
- // 静默忽略 —— `-c <k>=<v>` 会整体覆盖模型配置块,实测注入
205
- // model.max_tokens 后模型名漂移并触发配额错误(见 engine_base.go 矩阵②)。
206
- _ = req.MaxTokens
207
- _ = req.Temperature
208
- // 工具模式映射。trae-cli 的权限开关实测语义(2026-09-16 真机验证):
209
- //
210
- // --allowed-tool 只做「自动批准该工具」,**不裁剪**工具集
211
- // (实测 --allowed-tool WebFetch 仍下发全部 18 个工具)
212
- // --disallowed-tool 真正做减法,从工具集里移除该工具(实测 18 → 4)
213
- // -y / --yolo 跳过全部权限检查 ⇒ 全工具可用
214
- //
215
- // 由此得出映射:
216
- //
217
- // off 逐个 --disallowed-tool 关掉内置工具(Bash/Edit/Write/Glob/Grep/Read)
218
- // on -y(全放行;非 -p 交互场景下的权限旁路)
219
- // allowlist 默认开启所有工具 ⇒ 与 on 同一条全放行路径(-y)
220
- //
221
- // ⚠️ 白名单在 trae 上「降级为全放行」是刻意的默认语义,不是实现漏做:
222
- // trae 无法表达「只允许这几个工具」——想收窄只能对补集逐个 --disallowed-tool,
223
- // 而补集随 CLI 版本漂移(今天 18 个工具、明天可能加),漏一条就等于白名单失效,
224
- // 维护成本高于收益。故认定「默认开启所有工具」为白名单模式在 trae 上的契约,
225
- // 与 codex 的白名单降级(engine_base.go 矩阵④:白名单 → 全放行)保持一致。
226
- //
227
- // 返回值里不再出现 --allowed-tool:既然 -y 已全量放行,逐条声明只是
228
- // 「看起来像收窄」的噪音(这正是此前 allowlist 被 -y 架空的观感来源)。
229
- // 需要收窄时请用 off 模式(真减法),而不要指望白名单。
230
- tools := toolsOrDefault(req.Tools)
231
- switch {
232
- case tools.IsOff():
233
- // 真实工具名,逐个减法;`Replace` 不是 trae 的工具(编辑/写入是
234
- // Edit / Write),此前写成 Replace 等于漏禁 Write。
235
- for _, t := range traeOffDisableTools {
236
- args = append(args, "--disallowed-tool", t)
237
- }
238
- case tools.IsOn():
239
- args = append(args, "-y")
240
- default:
241
- // 默认开启所有工具:全放行开关是唯一有效机制。
242
- args = append(args, "-y")
243
- }
244
- // 把外层超时透传给 CLI 的 query-timeout(取上限 600s,CLI 单查询上限)。
245
- if req.Timeout > 0 {
246
- qt := req.Timeout
247
- if qt > 600*time.Second {
248
- qt = 600 * time.Second
249
- }
250
- args = append(args, "--query-timeout", qt.String())
251
- } else {
252
- args = append(args, "--query-timeout", "600s")
253
- }
254
- args = append(args, prompt)
255
- return args
120
+ return modelLabel
256
121
  }
257
122
 
258
- // Stream 实现 Streamer:流式调用 trae-cli。
259
- // trae 的 stream-json 用 delta.content 直出正文增量;
260
- // 模型侧不开 reasoning,无 thinking 通道(Thinking 恒为空)。
123
+ // Stream 实现 Streamer:逐行消费 `exec --json`,正文 / 思考 / 工具实时转发。
261
124
  func (e *TraeEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
262
125
  start := time.Now()
263
126
  bin := e.bin()
264
127
  if bin == "" {
265
128
  return StreamResult{}, fmt.Errorf("trae CLI not found; set MAGIC_AGENT_TRAE_BIN")
266
129
  }
130
+ return e.streamExec(ctx, bin, req, start, onEvent)
131
+ }
267
132
 
133
+ // traePrompt 组装 2.0 的单条提示词(system + 多轮 + off 约束 + 附件路径)。
134
+ func traePrompt(req Request) (string, error) {
268
135
  prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
269
136
  if prompt == "" {
270
- return StreamResult{}, fmt.Errorf("trae: empty prompt")
137
+ return "", fmt.Errorf("trae: empty prompt")
271
138
  }
272
139
  if req.SystemPrompt == "" && toolsIsOff(req) {
273
140
  prompt += noToolSuffix
274
141
  }
275
- prompt = appendAttachmentSection(prompt, req.Attachments)
276
-
277
- args := e.buildArgs(req, prompt)
278
- args = append(args, "--output-format", "stream-json", "--include-partial-messages")
279
-
280
- acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
281
- var fin struct {
282
- Type string `json:"type"`
283
- Subtype string `json:"subtype"`
284
- IsError bool `json:"is_error"`
285
- Result string `json:"result"`
286
- SessionID string `json:"session_id"`
287
- }
288
- seenResult := false
289
-
290
- err := runStreamCLIIn(ctx, req.Workspace, bin, args, func(line string) error {
291
- isResult, perr := acc.handleNDJSONLine(line)
292
- if perr != nil {
293
- return perr
294
- }
295
- if isResult {
296
- _ = json.Unmarshal([]byte(line), &fin)
297
- seenResult = true
298
- }
299
- return nil
300
- })
301
- if err != nil {
302
- return StreamResult{}, err
303
- }
304
- if !seenResult {
305
- err := fmt.Errorf("trae CLI stream ended without result line")
306
- acc.emitTurnFailed(acc.SessionID, err)
307
- return StreamResult{}, err
308
- }
309
- if fin.IsError {
310
- return StreamResult{}, fmt.Errorf("trae CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
311
- }
312
-
313
- text := fin.Result
314
- if text == "" {
315
- text = strings.TrimSpace(acc.Text.String())
316
- }
317
- if text == "" {
318
- return StreamResult{}, fmt.Errorf("trae CLI returned empty result")
319
- }
142
+ return appendAttachmentSection(prompt, req.Attachments), nil
143
+ }
320
144
 
321
- modelLabel := stripModelPrefix(req.Model)
322
- if modelLabel == "" {
323
- modelLabel = e.Model
324
- }
325
- if modelLabel == "" {
326
- if m := TraeDefaultModel(); m != "" {
327
- modelLabel = m
328
- } else {
329
- modelLabel = "cli-default"
330
- }
145
+ // traeModelFor 模型取值:请求显式 > 引擎注入(都去掉 `trae/` 前缀)。
146
+ func traeModelFor(req Request, e *TraeEngine) string {
147
+ if m := stripModelPrefix(req.Model); m != "" {
148
+ return m
331
149
  }
332
- return StreamResult{
333
- Response: Response{
334
- Engine: e.Name(),
335
- Text: text,
336
- Model: modelLabel,
337
- SessionID: fin.SessionID,
338
- Latency: time.Since(start),
339
- },
340
- Thinking: acc.Thinking.String(),
341
- // trae 协议不暴露独立工具事件通道;Tools 恒为 nil。
342
- // 见 stream.go 顶部协议说明。
343
- }, nil
150
+ return stripModelPrefix(e.Model)
344
151
  }