magic-agent 1.0.46 → 1.0.108

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 (95) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2168 -143
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-agent.js +24 -1
  84. package/npm/bin/magic-client.js +20 -2
  85. package/npm/dist/darwin-arm64/magic-agent +0 -0
  86. package/npm/dist/darwin-arm64/magic-client +0 -0
  87. package/npm/dist/darwin-x64/magic-agent +0 -0
  88. package/npm/dist/darwin-x64/magic-client +0 -0
  89. package/npm/dist/linux-arm64/magic-agent +0 -0
  90. package/npm/dist/linux-arm64/magic-client +0 -0
  91. package/npm/dist/linux-x64/magic-agent +0 -0
  92. package/npm/dist/linux-x64/magic-client +0 -0
  93. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  94. package/npm/dist/win32-x64/magic-client.exe +0 -0
  95. package/package.json +1 -1
@@ -0,0 +1,334 @@
1
+ package team
2
+
3
+ // role.go - 子任务角色(对齐 Claude subagent:一个任务只有一个执行角色)。
4
+ //
5
+ // **角色就是执行者**,类别是角色的属性而不是另一个作者字段:
6
+ //
7
+ // role writer / coder / designer / …
8
+ // └─ kind text / code / image / … ← 由角色派生,只读
9
+ //
10
+ // 所以计划里只写 role。类别由 RoleKind(role) 推出来,用来选引擎(见 RouteFor)、
11
+ // 渲染计划文档与事件流;模型即便写了 kind 也以角色为准,避免出现
12
+ // `{"kind":"image","role":"coder"}` 这种「派给图片引擎却带着代码人设」的矛盾。
13
+ //
14
+ // 角色决定三件事:注入的系统提示词片段、默认模型、所属类别。
15
+ // 它**不改变交付要求**:交付物、验收命令、依赖仍由计划里的任务本身决定。
16
+
17
+ import (
18
+ "fmt"
19
+ "strings"
20
+ "unicode/utf8"
21
+
22
+ "github.com/darren/magic-agent/internal/agent"
23
+ )
24
+
25
+ // Role 一个执行角色。字段刻意只留「会影响请求」的那些,不做角色编排系统。
26
+ type Role struct {
27
+ // Name 规范名(计划里写的就是它)。
28
+ Name string
29
+ // Duty 一句话职责(写进计划文档与任务书)。
30
+ Duty string
31
+ // SystemPrompt 该角色的系统提示词片段(追加在主系统提示词之后)。
32
+ SystemPrompt string
33
+ // Kind 该角色所属的类别;任务的类别一律由它派生(RoleKind)。
34
+ Kind string
35
+ // Model 该角色的默认模型:只在路由没指定模型时生效。
36
+ Model string
37
+ // Tools 工具白名单(空 = 沿用 Options.Tools)。
38
+ Tools []string
39
+ // Permission 权限档位(空 = 沿用 Options.Permission)。
40
+ Permission agent.PermissionTier
41
+ }
42
+
43
+ // builtinRoles 内置角色表。
44
+ //
45
+ // **按五类子任务对齐**(text / code / image / video / game):每一类都至少有一个角色,
46
+ // 表里的 Kind 就是该类任务的类别 —— 计划只写角色,类别由角色派生。
47
+ // 顺序即 roleHint / 文档里的顺序(先按类别,再按同类别内的常用度)。
48
+ //
49
+ // 只放跨项目都成立的通用分工;未知名会被清空并回退到默认角色,所以这里保持
50
+ // 「少而稳」,不追求覆盖所有职业。
51
+ var builtinRoles = []Role{
52
+ {
53
+ Name: "writer",
54
+ Duty: "把材料写成结构清晰、可直接交付的文档",
55
+ SystemPrompt: "你的角色是写作者。先想清楚读者与用途,再组织结构;只写任务书要求的内容,不编造事实与数据;交付前自检标题层级、术语一致性与文件名。",
56
+ Kind: KindText,
57
+ },
58
+ {
59
+ Name: "researcher",
60
+ Duty: "查证事实、比对方案,给出有依据的结论",
61
+ SystemPrompt: "你的角色是调研者。先列出需要回答的问题,再逐条取证;区分事实、推断与未知,找不到依据就明说;结论要能落到可核对的来源或文件路径上。",
62
+ Kind: KindText,
63
+ },
64
+ {
65
+ Name: "reviewer",
66
+ Duty: "按验收标准挑问题,不重写别人的产出",
67
+ SystemPrompt: "你的角色是评审者。逐条对照任务书的验收标准给出结论(通过 / 不通过 + 证据);只指出可验证的问题,并给出最小修改建议;不要顺手重写交付物。",
68
+ Kind: KindText,
69
+ },
70
+ {
71
+ Name: "coder",
72
+ Duty: "把复杂代码改动落到可构建、可测试的文件上",
73
+ SystemPrompt: "你的角色是代码实现者。先读懂现有代码与改动面,再动手;只改任务书点名的范围,不做顺手重构;每处改动都要能通过任务书给出的构建或测试命令,失败就修到通过为止;交付时说明改了哪些文件、怎么验证。",
74
+ Kind: KindCode,
75
+ },
76
+ {
77
+ Name: "designer",
78
+ Duty: "产出可直接使用的画面资产",
79
+ SystemPrompt: "你的角色是视觉设计者。先定构图与信息层级,再落笔;严格遵守任务书给出的尺寸、格式与文件名;除非任务要求,不要把说明文字画进图里。",
80
+ Kind: KindImage,
81
+ },
82
+ {
83
+ Name: "director",
84
+ Duty: "把脚本 / 分镜变成可播放的影像",
85
+ SystemPrompt: "你的角色是影像导演。先定镜头顺序与节奏,再逐段生成;严格遵守任务书给出的时长、画幅、格式与文件名;交付前确认成片能完整播放、字幕与画面同步。",
86
+ Kind: KindVideo,
87
+ },
88
+ {
89
+ Name: "builder",
90
+ Duty: "交付能跑起来的工程与可复现的启动方式",
91
+ SystemPrompt: "你的角色是游戏 / 工程构建者。优先让产物可运行:先搭最小可运行骨架,再补功能;所有命令都要在工作目录内可复现,依赖与启动方式写进交付物。",
92
+ Kind: KindGame,
93
+ },
94
+ }
95
+
96
+ // DefaultRoleForKind 某一类的默认角色。
97
+ //
98
+ // 只在两条兼容路径上使用:计划漏写 role(或写了认不出的角色)时,按旧的 kind 字段
99
+ // 回退到该类的默认角色;直接构造 Task{Kind: …} 的调用方同理。
100
+ func DefaultRoleForKind(kind string) *Role {
101
+ for i := range builtinRoles {
102
+ if builtinRoles[i].Kind == kind {
103
+ return &builtinRoles[i]
104
+ }
105
+ }
106
+ return nil
107
+ }
108
+
109
+ // RoleKind 角色所属的类别(空 = 未知角色)。任务的 Kind 一律由它派生,不再从模型输出取。
110
+ func RoleKind(name string) string {
111
+ if r := LookupRole(name); r != nil {
112
+ return r.Kind
113
+ }
114
+ return ""
115
+ }
116
+
117
+ // roleAliases 计划里可能出现的近义写法 → 规范名。
118
+ //
119
+ // 与 NormalizeKind 同一套思路:收不拢的写法不该让整份计划作废,丢掉角色即可。
120
+ var roleAliases = map[string]string{
121
+ "writer": "writer",
122
+ "author": "writer",
123
+ "写作": "writer",
124
+ "写手": "writer",
125
+ "researcher": "researcher",
126
+ "research": "researcher",
127
+ "调研": "researcher",
128
+ "研究": "researcher",
129
+ "designer": "designer",
130
+ "design": "designer",
131
+ "视觉": "designer",
132
+ "设计": "designer",
133
+ // 影像导演。刻意不收录 "video" / "视频":它们是类别路由的键,收进来会把
134
+ // `--team-route video=…` 抢成 `role:director=…`(normalizeRouteKind 先查角色)。
135
+ "director": "director",
136
+ "film": "director",
137
+ "filmmaker": "director",
138
+ "导演": "director",
139
+ "影像": "director",
140
+ "短片": "director",
141
+ // 代码实现角色。别名只收「职种」写法,刻意不收 "code" / "代码":它们是类别路由的键,
142
+ // 收进来会把 `--team-route code=…` 抢成 `role:coder=…`(normalizeRouteKind 先查角色)。
143
+ "coder": "coder",
144
+ "engineer": "coder",
145
+ "developer": "coder",
146
+ "programmer": "coder",
147
+ "工程": "coder",
148
+ "开发": "coder",
149
+ "程序员": "coder",
150
+ "builder": "builder",
151
+ "gamedev": "builder",
152
+ "游戏": "builder",
153
+ "reviewer": "reviewer",
154
+ "review": "reviewer",
155
+ "评审": "reviewer",
156
+ "审查": "reviewer",
157
+ }
158
+
159
+ // NormalizeRole 把模型 / 配置里的角色写法归一到规范名;认不出返回空串。
160
+ func NormalizeRole(s string) string {
161
+ s = strings.ToLower(strings.TrimSpace(s))
162
+ s = strings.TrimSpace(strings.TrimPrefix(s, "role:"))
163
+ if s == "" {
164
+ return ""
165
+ }
166
+ if name, ok := roleAliases[s]; ok {
167
+ return name
168
+ }
169
+ return ""
170
+ }
171
+
172
+ // LookupRole 按名字取角色;未知 / 空返回 nil。
173
+ func LookupRole(name string) *Role {
174
+ n := NormalizeRole(name)
175
+ if n == "" {
176
+ return nil
177
+ }
178
+ for i := range builtinRoles {
179
+ if builtinRoles[i].Name == n {
180
+ return &builtinRoles[i]
181
+ }
182
+ }
183
+ return nil
184
+ }
185
+
186
+ // RoleInfo 角色的**机器可读描述**,供 `--contract` 的 teamRoles 使用。
187
+ //
188
+ // 客户端据此渲染角色路由行(移动端「团队模式」子页面,缩进在所属类别行下面),
189
+ // 不必在客户端硬编码角色名 —— 以后往 builtinRoles 里加角色,界面自动多一行。
190
+ type RoleInfo struct {
191
+ Name string `json:"name"`
192
+ Kind string `json:"kind"`
193
+ Duty string `json:"duty"`
194
+ // Model 角色默认模型(空 = 不指定);客户端只展示,不参与路由决策。
195
+ Model string `json:"model,omitempty"`
196
+ }
197
+
198
+ // RoleInfos 输出角色清单(顺序同 builtinRoles,即提示词与界面里的顺序)。
199
+ func RoleInfos() []RoleInfo {
200
+ out := make([]RoleInfo, 0, len(builtinRoles))
201
+ for _, r := range builtinRoles {
202
+ out = append(out, RoleInfo{Name: r.Name, Kind: r.Kind, Duty: r.Duty, Model: r.Model})
203
+ }
204
+ return out
205
+ }
206
+
207
+ // RoleNames 规范名清单(顺序即提示词里的顺序)。
208
+ func RoleNames() []string {
209
+ out := make([]string, 0, len(builtinRoles))
210
+ for _, r := range builtinRoles {
211
+ out = append(out, r.Name)
212
+ }
213
+ return out
214
+ }
215
+
216
+ // RoleKey 角色在路由表里的键。
217
+ //
218
+ // 加 `role:` 前缀是为了与类别键(plan / text / code / image / video / game)共处一张表
219
+ // 而不打架:`--team-route role:reviewer=claude`。
220
+ func RoleKey(name string) string {
221
+ n := NormalizeRole(name)
222
+ if n == "" {
223
+ return ""
224
+ }
225
+ return "role:" + n
226
+ }
227
+
228
+ // roleHint 把角色清单写进计划提示词。
229
+ //
230
+ // 角色是任务唯一的作者字段,类别由它决定:这里带上类别,模型挑角色时就等于同时
231
+ // 决定了「派给哪类引擎」,不会写出角色与类别互相矛盾的计划。
232
+ //
233
+ // rules 是用户改写的「这类任务适合什么」(--team-rule role:<name>=…);有就替掉
234
+ // 内置 Duty,没有就用内置的一句。只影响**计划怎么判断**,不改执行者的系统提示词。
235
+ func roleHint(rules map[string]string) string {
236
+ var b strings.Builder
237
+ b.WriteString("角色表(任务的 role 字段必填,只能选一个;方括号是它决定的类别):\n")
238
+ for _, r := range builtinRoles {
239
+ duty := r.Duty
240
+ if v, ok := rules[RoleKey(r.Name)]; ok && strings.TrimSpace(v) != "" {
241
+ duty = strings.TrimSpace(v)
242
+ }
243
+ b.WriteString("- " + r.Name + " [" + r.Kind + "] —— " + duty + "\n")
244
+ }
245
+ b.WriteString("角色就是执行者:它决定类别(进而决定派给哪个引擎)、系统提示词与默认模型;" +
246
+ "角色的职责描述不改变任务书里的交付物与验收要求。")
247
+ return b.String()
248
+ }
249
+
250
+ // RulesMaxLen 单条角色规则的长度上限(字符)。规则整段进系统提示词,
251
+ // 太长会挤掉计划本身的输出空间。
252
+ const RulesMaxLen = 500
253
+
254
+ // NormalizeRuleText 把用户写的规则收成一行(折叠空白):它要嵌进角色表的
255
+ // 「- <role> [<kind>] —— <规则>」这一条,换行会把列表结构撑断。
256
+ func NormalizeRuleText(s string) string {
257
+ return strings.Join(strings.Fields(s), " ")
258
+ }
259
+
260
+ // ParseRules 解析 --team-rule 的取值:`role:<name>=<规则文本>`。
261
+ //
262
+ // 只认角色:计划与类别没有自由文本规则 —— 计划的「规则」是拆解粒度
263
+ // (--team-split / --team-split-code),类别是角色的派生。
264
+ func ParseRules(values []string) (map[string]string, error) {
265
+ if len(values) == 0 {
266
+ return nil, nil
267
+ }
268
+ out := make(map[string]string, len(values))
269
+ for _, v := range values {
270
+ eq := strings.IndexByte(v, '=')
271
+ if eq <= 0 {
272
+ return nil, fmt.Errorf("--team-rule 取值应为 role:<角色>=<规则>,收到 %q", v)
273
+ }
274
+ role := LookupRole(v[:eq])
275
+ if role == nil {
276
+ return nil, fmt.Errorf("--team-rule 的角色 %q 不存在(可选:%s)",
277
+ strings.TrimSpace(v[:eq]), strings.Join(RoleNames(), " / "))
278
+ }
279
+ text := NormalizeRuleText(v[eq+1:])
280
+ if text == "" {
281
+ return nil, fmt.Errorf("--team-rule %s 的规则为空", role.Name)
282
+ }
283
+ if utf8.RuneCountInString(text) > RulesMaxLen {
284
+ return nil, fmt.Errorf("--team-rule %s 的规则超过 %d 字", role.Name, RulesMaxLen)
285
+ }
286
+ out[RoleKey(role.Name)] = text
287
+ }
288
+ return out, nil
289
+ }
290
+
291
+ // roleText 计划总览表格里的角色列(未设角色给 ——)。
292
+ func roleText(name string) string {
293
+ if r := LookupRole(name); r != nil {
294
+ return r.Name
295
+ }
296
+ return "—"
297
+ }
298
+
299
+ // roleDetail 计划详情里的角色描述:`writer(把材料写成…)`;未设角色给「—」。
300
+ func roleDetail(name string) string {
301
+ r := LookupRole(name)
302
+ if r == nil {
303
+ return "—"
304
+ }
305
+ if strings.TrimSpace(r.Duty) == "" {
306
+ return r.Name
307
+ }
308
+ return r.Name + "(" + r.Duty + ")"
309
+ }
310
+
311
+ // systemPromptFor 子任务实际使用的系统提示词:主提示词 + 角色片段。
312
+ func systemPromptFor(opts Options, t Task) string {
313
+ parts := []string{strings.TrimSpace(opts.SystemPrompt)}
314
+ if r := LookupRole(t.Role); r != nil && strings.TrimSpace(r.SystemPrompt) != "" {
315
+ parts = append(parts, strings.TrimSpace(r.SystemPrompt))
316
+ }
317
+ return strings.TrimSpace(strings.Join(parts, "\n\n"))
318
+ }
319
+
320
+ // toolsFor 子任务实际使用的工具模式:角色白名单优先,否则沿用全局。
321
+ func toolsFor(opts Options, t Task) agent.ToolsMode {
322
+ if r := LookupRole(t.Role); r != nil && len(r.Tools) > 0 {
323
+ return agent.ToolsAllowlist(r.Tools)
324
+ }
325
+ return opts.Tools
326
+ }
327
+
328
+ // permissionFor 子任务实际使用的权限档位:角色声明优先,否则沿用全局。
329
+ func permissionFor(opts Options, t Task) agent.PermissionTier {
330
+ if r := LookupRole(t.Role); r != nil && r.Permission != "" {
331
+ return r.Permission
332
+ }
333
+ return opts.Permission
334
+ }
@@ -0,0 +1,206 @@
1
+ package team
2
+
3
+ // route.go - 类别 → 引擎/模型的路由,以及运行目录的选址。
4
+ //
5
+ // 两条规矩:
6
+ //
7
+ // 1. **显式优先**。--team-route / 客户端下发的 routes 里写了的,一律照办(引擎不存在
8
+ // 才报错)。用户说「图像任务用 X」,这里不去猜 X 到底行不行。
9
+ // 2. **没配就跟随主引擎**。计划阶段与五类子任务(plan / text / code / image / video / game)
10
+ // 一致:这一类没被指定引擎时,就用主引擎 + 主模型 —— 「跟随主引擎」是默认,也是一个
11
+ // 可改的选择(客户端「我的 → 团队模式」里每类都能单独改)。主引擎为空才返回空路由,
12
+ // 由 execute 标失败。
13
+ //
14
+ // 任务的类别不是用户/模型直接写的字段,而是由角色派生(Task.EffectiveKind):
15
+ // 角色表是唯一作者面,路由表仍是「类别」这一级,两者用 RoleKind 对齐。
16
+
17
+ import (
18
+ "fmt"
19
+ "os"
20
+ "path/filepath"
21
+ "strings"
22
+
23
+ "github.com/darren/magic-agent/internal/agent"
24
+ )
25
+
26
+ // RouteFor 解析某个任务最终该用哪个引擎 / 模型。
27
+ //
28
+ // 顺序:任务里写死的 > 角色路由(role:<name>)> 类别路由(Options.Routes)> 主引擎。
29
+ // 角色排在类别之前:角色是**更具体**的那一层(用户点名「评审都用 X」),而类别是
30
+ // 「这类活默认派给谁」。返回的 engine 为空只发生在主引擎也为空时,调用方必须按失败处理。
31
+ func RouteFor(t Task, opts Options) Route {
32
+ if e := strings.TrimSpace(t.Engine); e != "" {
33
+ return Route{Engine: e, Model: strings.TrimSpace(t.Model)}
34
+ }
35
+ role := LookupRole(t.Role)
36
+ if role != nil {
37
+ if r, ok := routeForRole(opts.Routes, role.Name); ok {
38
+ m := strings.TrimSpace(r.Model)
39
+ if m == "" {
40
+ m = strings.TrimSpace(role.Model)
41
+ }
42
+ return Route{Engine: strings.TrimSpace(r.Engine), Model: m}
43
+ }
44
+ }
45
+ if r, ok := opts.Routes[t.EffectiveKind()]; ok && strings.TrimSpace(r.Engine) != "" {
46
+ return Route{Engine: strings.TrimSpace(r.Engine), Model: strings.TrimSpace(r.Model)}
47
+ }
48
+ // 默认「跟随主引擎」:不分类别,text / image / video / game 一视同仁。
49
+ if opts.Engine != nil {
50
+ m := opts.Model
51
+ // 主引擎没配模型时,角色声明的默认模型才生效(不覆盖用户显式选择)。
52
+ if strings.TrimSpace(m) == "" && role != nil {
53
+ m = strings.TrimSpace(role.Model)
54
+ }
55
+ return Route{Engine: opts.Engine.Name(), Model: m}
56
+ }
57
+ return Route{}
58
+ }
59
+
60
+ // routeForRole 在路由表里找角色配置:规范键 `role:<name>` 优先,裸角色名也认
61
+ // (客户端旧配置 / 手写 routes 里可能不带前缀)。
62
+ func routeForRole(routes map[string]Route, name string) (Route, bool) {
63
+ if len(routes) == 0 {
64
+ return Route{}, false
65
+ }
66
+ for _, key := range []string{RoleKey(name), NormalizeRole(name)} {
67
+ if key == "" {
68
+ continue
69
+ }
70
+ if r, ok := routes[key]; ok && strings.TrimSpace(r.Engine) != "" {
71
+ return r, true
72
+ }
73
+ }
74
+ return Route{}, false
75
+ }
76
+
77
+ // PlanRoute 计划阶段该用哪个引擎 / 模型。
78
+ //
79
+ // 与子任务同一套语义:显式配了 plan 就用它,没配跟随主引擎 —— 计划本来就是主引擎的活。
80
+ // 返回空引擎只发生在主引擎也为空时(Run 已提前拦掉)。
81
+ func PlanRoute(opts Options) Route {
82
+ if r, ok := opts.Routes[KindPlan]; ok && strings.TrimSpace(r.Engine) != "" {
83
+ return Route{Engine: strings.TrimSpace(r.Engine), Model: strings.TrimSpace(r.Model)}
84
+ }
85
+ if opts.Engine != nil {
86
+ return Route{Engine: opts.Engine.Name(), Model: opts.Model}
87
+ }
88
+ return Route{}
89
+ }
90
+
91
+ // ValidateRoutes 校验显式配置里的引擎都真实存在。
92
+ //
93
+ // 在开跑前一次性报错(而不是等某个任务跑到一半才 failed):配置错了是**配置问题**,
94
+ // 早说一秒就少浪费一轮。
95
+ func ValidateRoutes(routes map[string]Route) error {
96
+ var bad []string
97
+ for kind, r := range routes {
98
+ name := strings.TrimSpace(r.Engine)
99
+ if name == "" {
100
+ continue
101
+ }
102
+ if agent.Lookup(name) == nil {
103
+ bad = append(bad, fmt.Sprintf("%s→%s", kind, name))
104
+ }
105
+ }
106
+ if len(bad) > 0 {
107
+ return fmt.Errorf("未知引擎:%s(可用引擎见 magic-agent --engines)", strings.Join(bad, "、"))
108
+ }
109
+ return nil
110
+ }
111
+
112
+ // ParseRoutes 解析 --team-route 的取值:`<kind>=<engine>[/<model>]`。
113
+ //
114
+ // 模型名里可能有 `/`(如 `anthropic/claude-x`),所以只按**第一个** `/` 切:
115
+ // 前段是引擎,其余全是模型名。
116
+ func ParseRoutes(values []string) (map[string]Route, error) {
117
+ if len(values) == 0 {
118
+ return nil, nil
119
+ }
120
+ out := make(map[string]Route, len(values))
121
+ for _, v := range values {
122
+ s := strings.TrimSpace(v)
123
+ if s == "" {
124
+ continue
125
+ }
126
+ eq := strings.IndexByte(s, '=')
127
+ if eq <= 0 {
128
+ return nil, fmt.Errorf("--team-route 取值应为 <类别>=<引擎>[/<模型>],收到 %q", v)
129
+ }
130
+ kind := normalizeRouteKind(s[:eq])
131
+ if kind == "" {
132
+ return nil, fmt.Errorf("--team-route 的类别 %q 不支持(可选:%s;角色用 role:<%s>)",
133
+ s[:eq], strings.Join(RouteKinds, " / "), strings.Join(RoleNames(), " / "))
134
+ }
135
+ rest := strings.TrimSpace(s[eq+1:])
136
+ if rest == "" {
137
+ return nil, fmt.Errorf("--team-route %s 缺少引擎名", kind)
138
+ }
139
+ r := Route{Engine: rest}
140
+ if i := strings.IndexByte(rest, '/'); i > 0 {
141
+ r.Engine = strings.TrimSpace(rest[:i])
142
+ r.Model = strings.TrimSpace(rest[i+1:])
143
+ }
144
+ out[kind] = r
145
+ }
146
+ return out, nil
147
+ }
148
+
149
+ // normalizeRouteKind 把配置里的键归一到 RouteKinds 或角色键(role:<name>)。
150
+ //
151
+ // plan 是**阶段**不是子任务类别,所以不走 NormalizeKind(那张近义词表只服务类别回退);
152
+ // 这里单独认几个写法,其余先看角色表,再交给 NormalizeKind。
153
+ func normalizeRouteKind(s string) string {
154
+ switch strings.ToLower(strings.TrimSpace(s)) {
155
+ case KindPlan, "计划", "计划任务", "planner", "planning":
156
+ return KindPlan
157
+ }
158
+ if key := RoleKey(s); key != "" {
159
+ return key
160
+ }
161
+ return NormalizeKind(s)
162
+ }
163
+
164
+ // chooseRunDir 决定运行目录(团队模式的全部落盘都在它下面)。
165
+ //
166
+ // 选址规则(与用户 2026-10-21 的确认一致):
167
+ //
168
+ // 选了工作空间 → <ws>/.magic/team/<run_id>
169
+ // 默认空间 → ~/.magic-agent/team/<run_id>
170
+ //
171
+ // 为什么不一律丢 cwd:默认空间下 cwd 是 core 进程的启动目录(可能是任何地方),
172
+ // 往里写团队产物等于污染用户当前的项目。落在 ~/.magic-agent 下有个固定位置可找,
173
+ // 而显式选了空间时,用户本来就期望产物在那个空间里。
174
+ func chooseRunDir(opts Options) (string, error) {
175
+ if base := strings.TrimSpace(opts.BaseDir); base != "" {
176
+ return filepath.Join(base, sanitizeID(opts.RunID)), nil
177
+ }
178
+ if ws := strings.TrimSpace(opts.Workspace); ws != "" {
179
+ return filepath.Join(ws, ".magic", "team", sanitizeID(opts.RunID)), nil
180
+ }
181
+ home, err := os.UserHomeDir()
182
+ if err != nil || home == "" {
183
+ return "", fmt.Errorf("team: 无法确定运行目录(既没有工作空间,也读不到 HOME)")
184
+ }
185
+ return filepath.Join(home, ".magic-agent", "team", sanitizeID(opts.RunID)), nil
186
+ }
187
+
188
+ // sanitizeID 把 id 收成安全的目录名(run_id 里有 `-` 与 base36 字符,正常不会被改)。
189
+ func sanitizeID(s string) string {
190
+ s = strings.TrimSpace(s)
191
+ if s == "" {
192
+ return "run"
193
+ }
194
+ var b strings.Builder
195
+ for _, r := range s {
196
+ switch {
197
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
198
+ b.WriteRune(r)
199
+ case r == '-' || r == '_' || r == '.':
200
+ b.WriteRune(r)
201
+ default:
202
+ b.WriteByte('_')
203
+ }
204
+ }
205
+ return b.String()
206
+ }