magic-agent 1.0.49 → 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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// planner.go - 让主引擎产出计划。
|
|
4
|
+
//
|
|
5
|
+
// 只用一次「关工具」的非流式调用:计划必须是可解析的结构化输出,而开着工具时模型
|
|
6
|
+
// 会把读文件 / 跑命令的中间过程混进正文,解析成功率会掉。
|
|
7
|
+
//
|
|
8
|
+
// **失败不抛**:调用方拿到 error 就降级为单任务(见 Run)。团队模式的价值是并行,
|
|
9
|
+
// 不是「计划完美」;计划不成也照样把活干了。
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"context"
|
|
13
|
+
"fmt"
|
|
14
|
+
"strings"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// 拆解粒度(--team-split)。三档只改「最多拆几个子任务」这一个口径:
|
|
21
|
+
// - coarse 最多 3 个:宁少勿多,能用一段话讲完的活不单开执行者;
|
|
22
|
+
// - standard 最多 6 个(默认):按交付物拆;
|
|
23
|
+
// - fine 最多 10 个:交付物有独立验收面就单开一个。
|
|
24
|
+
const (
|
|
25
|
+
SplitCoarse = "coarse"
|
|
26
|
+
SplitStandard = "standard"
|
|
27
|
+
SplitFine = "fine"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// NormalizeSplit 拆解粒度归一(大小写不敏感;空 = standard)。err 非空 = 非法值。
|
|
31
|
+
func NormalizeSplit(raw string) (string, error) {
|
|
32
|
+
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
33
|
+
case "", SplitStandard:
|
|
34
|
+
return SplitStandard, nil
|
|
35
|
+
case SplitCoarse:
|
|
36
|
+
return SplitCoarse, nil
|
|
37
|
+
case SplitFine:
|
|
38
|
+
return SplitFine, nil
|
|
39
|
+
}
|
|
40
|
+
return "", fmt.Errorf("拆解粒度 %q 不合法(coarse | standard | fine)", raw)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// splitMaxTasks 该粒度下计划允许的最大子任务数。
|
|
44
|
+
func splitMaxTasks(split string) int {
|
|
45
|
+
switch split {
|
|
46
|
+
case SplitCoarse:
|
|
47
|
+
return 3
|
|
48
|
+
case SplitFine:
|
|
49
|
+
return 10
|
|
50
|
+
default:
|
|
51
|
+
return 6
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// codeGateStrict / codeGateLoose 代码类任务的拆解门槛(填进模板的 {{CODE_GATE}})。
|
|
56
|
+
//
|
|
57
|
+
// 默认 strict:简单代码单开一个执行者,成本远高于主引擎顺手给出。用户要「简单代码也拆」
|
|
58
|
+
// 时用 loose(--team-split-code)。
|
|
59
|
+
const codeGateStrict = `**代码的门槛要高**:只有复杂代码才拆 code 任务;**简单代码**不拆子任务,由主引擎在
|
|
60
|
+
最终回复里直接给出(不要写进 tasks):
|
|
61
|
+
- 单文件脚本、一次性片段、几行配置修改;
|
|
62
|
+
- 用户只问「怎么写 / 为什么报错 / 这段什么意思」的问答式请求;
|
|
63
|
+
- 改动面明确、不需要独立验收命令的小修小补。
|
|
64
|
+
判断标准就一句:**这个代码活是否值得单开一个执行者、独立跑构建或测试**。拿不准就不拆。`
|
|
65
|
+
|
|
66
|
+
const codeGateLoose = `**代码也要拆**:只要需求里要改代码,就拆成 code 任务交给 coder —— 包括单文件脚本、
|
|
67
|
+
一次性片段、几行配置修改、小修小补。唯一不拆的是**纯问答**(用户只问「这段什么意思 /
|
|
68
|
+
为什么报错」,不需要改文件):那种由主引擎直接答。`
|
|
69
|
+
|
|
70
|
+
// plannerPromptTemplate 计划阶段的系统提示词模板({{MAX_TASKS}} / {{CODE_GATE}}
|
|
71
|
+
// 由 plannerPrompt 填)。三条硬要求写在这里,因为它们是**解析器依赖的契约**
|
|
72
|
+
// (不是风格建议):纯 JSON、任务数上限、每项的必填字段。
|
|
73
|
+
const plannerPromptTemplate = `你是任务规划器。把用户的诉求拆成若干**可独立执行**的子任务。
|
|
74
|
+
|
|
75
|
+
只输出一个 JSON 对象,不要任何解释、不要 markdown 围栏。结构:
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
"goal": "一句话说明这次要达成什么",
|
|
79
|
+
"tasks": [
|
|
80
|
+
{
|
|
81
|
+
"id": "t1",
|
|
82
|
+
"role": "writer|researcher|reviewer|coder|designer|director|builder(必填,选一个)",
|
|
83
|
+
"title": "给人看的短标题(不超过 12 字)",
|
|
84
|
+
"prompt": "子任务的完整指令:目标、要用的材料、硬约束、验收标准",
|
|
85
|
+
"deps": ["依赖的任务 id"],
|
|
86
|
+
"inputs": ["本任务需要的上游文件名(必须与产出它的任务的 deliverables 完全一致)"],
|
|
87
|
+
"deliverables": ["交付物文件名(含扩展名)"],
|
|
88
|
+
"constraints": ["必须遵守的约束"],
|
|
89
|
+
"acceptance": ["可核对的验收条件"],
|
|
90
|
+
"check": ["可机器判定的验收命令,如 test -s brief.md"],
|
|
91
|
+
"required": true
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
怎么拆(比什么都重要):
|
|
97
|
+
- **按交付物拆,不按用户的自我描述拆。** 用户写的「这是两个独立的小任务」「帮我做三件事」
|
|
98
|
+
「这块可以合并」只是需求线索,不是计划:最终任务数由你按交付物判定,可以多于也可以少于
|
|
99
|
+
用户说的数。用户没说清楚的地方,按「做完整需要哪些产物」补齐。
|
|
100
|
+
- **逐个交付物选角色**,每个交付物一个子任务;角色决定类别(见下面的角色表),
|
|
101
|
+
所以不要把两个不同类别的交付物塞进同一个任务:
|
|
102
|
+
- text:文档、分析、结论、文案 → writer / researcher / reviewer。
|
|
103
|
+
- code:代码产出(脚本、程序、构建 / 测试相关的改动)→ coder。
|
|
104
|
+
- image:画面类产出(位图 / 矢量:封面、插画、海报、图标)→ designer。
|
|
105
|
+
- video:影像类产出(分镜、剪辑、成片)→ director。
|
|
106
|
+
- game:可运行的游戏工程(代码 + 素材 + 启动方式)→ builder。
|
|
107
|
+
- {{CODE_GATE}}
|
|
108
|
+
- **需求里出现了对应类别的产物,就必须单独拆出该类任务**,哪怕用户没明说「配图 / 做成视频」。
|
|
109
|
+
例:「写 100 字游戏介绍并配 1 个封面」→ 1 个 writer + 1 个 designer;
|
|
110
|
+
「做个能玩的小游戏」→ 1 个 builder(若要同时写介绍文案,再加 1 个 writer)。
|
|
111
|
+
- **反方向同样成立**:纯文字需求不要硬塞 designer / director / builder / coder 任务。
|
|
112
|
+
没有画面产物就别派 designer,没有影像产物就别派 director,代码是否拆分见上面 code 那条。
|
|
113
|
+
- 角色只决定「去哪儿找人干」:某个类别没配专用引擎时跟随主引擎,不代表这类活不能拆。
|
|
114
|
+
|
|
115
|
+
规则:
|
|
116
|
+
1. 每个子任务必须**自带足够上下文**(它们不共享会话,互相看不到对话历史)。
|
|
117
|
+
2. 有先后依赖的用 deps 串起来;互不依赖的并发跑,尽量拆成互不依赖的。
|
|
118
|
+
3. 交付物写成**具体的文件名**(如 cover.png / brief.md),不要写「一份报告」。
|
|
119
|
+
4. 只做用户真的要的东西,不要为了「看起来像团队」硬拆。
|
|
120
|
+
5. 子任务数量不超过 {{MAX_TASKS}} 个;超过就合并同类交付物,而不是砍掉类别。
|
|
121
|
+
6. required 默认 true;只有「做不做都不影响结果」的才写 false。
|
|
122
|
+
7. inputs 只写「下游真的要读上游文件」的那些,文件名必须与上游任务的 deliverables 逐字一致;
|
|
123
|
+
写进去的文件不存在时 core 会在开跑前直接判失败(不重试)。不要用它表达「可以参考一下」。
|
|
124
|
+
8. check 只写**退出码能说明问题**的命令(在工作目录内执行),例如 test -s brief.md;
|
|
125
|
+
没有把握就写空数组 [],不要写 ls / echo 这类永远成功的命令。
|
|
126
|
+
9. 每个任务收尾时要输出一个 JSON 交接单(files / summary / interface / open),
|
|
127
|
+
所以 prompt 里要让执行者知道:做完后把结果写成可被下游直接读的文件。
|
|
128
|
+
10. role 必填,只能从角色表里选一个 —— 它就是执行者,类别由它决定(选错角色等于派错引擎)。
|
|
129
|
+
角色不改变交付物与验收要求:同一个文档任务可以是 writer 也可以是 reviewer。`
|
|
130
|
+
|
|
131
|
+
// plannerPrompt 按 Options 渲染计划提示词:只有「最多几个」和「代码门槛」两项随配置变,
|
|
132
|
+
// 其余规则(按交付物拆、字段契约)是解析器依赖的硬约束,任何档位都不动。
|
|
133
|
+
func plannerPrompt(opts Options) string {
|
|
134
|
+
gate := codeGateStrict
|
|
135
|
+
if opts.SplitSimpleCode {
|
|
136
|
+
gate = codeGateLoose
|
|
137
|
+
}
|
|
138
|
+
split, _ := NormalizeSplit(opts.Split)
|
|
139
|
+
s := strings.ReplaceAll(plannerPromptTemplate, "{{CODE_GATE}}", gate)
|
|
140
|
+
return strings.ReplaceAll(s, "{{MAX_TASKS}}", fmt.Sprint(splitMaxTasks(split)))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// plannerSystemPrompt 默认口径(standard + 简单代码不拆)。
|
|
144
|
+
var plannerSystemPrompt = plannerPrompt(Options{})
|
|
145
|
+
|
|
146
|
+
// planSchema 计划输出的结构化约束。
|
|
147
|
+
//
|
|
148
|
+
// 只约束最关键的两个顶层字段:支持 JSONSchema 的引擎会照着吐,不支持的引擎仍靠
|
|
149
|
+
// 系统提示词(plannerSystemPrompt)与 parsePlan 的容错。刻意不把 tasks 子项展开写进
|
|
150
|
+
// Properties —— 各引擎对嵌套 schema 的支持程度不一致,这里只做「别吐闲聊」这一层。
|
|
151
|
+
func planSchema() *agent.JSONSchema {
|
|
152
|
+
return &agent.JSONSchema{
|
|
153
|
+
Type: "object",
|
|
154
|
+
Properties: map[string]map[string]any{"goal": {"type": "string"}, "tasks": {"type": "array"}},
|
|
155
|
+
Required: []string{"goal", "tasks"},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// makePlan 调主引擎产出计划,并返回该轮的会话 id(汇总阶段要接着同一个会话)。
|
|
160
|
+
//
|
|
161
|
+
// 为什么汇总要接计划的会话:主引擎在计划里已经想过「怎么拆、每块留给谁」,汇总时
|
|
162
|
+
// 它需要的正是这份上下文。两轮接同一条会话,用户后续追问也能自然接上。
|
|
163
|
+
// ⚠️ 只有主引擎如此;子任务永远是空会话(不共享上下文)。
|
|
164
|
+
func makePlan(ctx context.Context, opts Options) (Plan, string, error) {
|
|
165
|
+
if opts.Engine == nil {
|
|
166
|
+
return Plan{}, "", fmt.Errorf("主引擎为空")
|
|
167
|
+
}
|
|
168
|
+
route := PlanRoute(opts)
|
|
169
|
+
eng := agent.Lookup(route.Engine)
|
|
170
|
+
if eng == nil {
|
|
171
|
+
return Plan{}, "", fmt.Errorf("计划引擎 %s 不可用", route.Engine)
|
|
172
|
+
}
|
|
173
|
+
// 计划与汇总共用主引擎的那条会话(见上面的注释)。一旦计划换了引擎,session id
|
|
174
|
+
// 就属于**别人**了:既不能把主引擎的 id 传进去,也不能把计划这一轮的 id 交给汇总
|
|
175
|
+
// (跨引擎续接是错的)。代价是这一轮计划 / 汇总各起一条新会话。
|
|
176
|
+
onMain := strings.EqualFold(eng.Name(), opts.Engine.Name())
|
|
177
|
+
sid := ""
|
|
178
|
+
if onMain {
|
|
179
|
+
sid = opts.SessionID
|
|
180
|
+
}
|
|
181
|
+
// 计划阶段的超时:给一个明确上限,别让「模型一直不吐 JSON」把整轮拖死。
|
|
182
|
+
// 取值与子任务超时同源(见 taskTimeout),因为它本质上也是一次子任务。
|
|
183
|
+
pctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
|
|
184
|
+
defer cancel()
|
|
185
|
+
|
|
186
|
+
// 提示词里带上用户可用的引擎清单:模型据此把 image / video 类任务分配到真的
|
|
187
|
+
// 存在的引擎上(否则会写出一个谁都不认识的引擎名,路由阶段只能报「没有可用引擎」)。
|
|
188
|
+
prompt := strings.TrimSpace(opts.Prompt)
|
|
189
|
+
var hints []string
|
|
190
|
+
if hint := routeHint(opts); hint != "" {
|
|
191
|
+
hints = append(hints, hint)
|
|
192
|
+
}
|
|
193
|
+
if hint := roleHint(opts.Rules); hint != "" {
|
|
194
|
+
hints = append(hints, hint)
|
|
195
|
+
}
|
|
196
|
+
if len(hints) > 0 {
|
|
197
|
+
prompt = strings.Join(hints, "\n\n") + "\n\n用户诉求:\n" + prompt
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
runner := &agent.Runner{Engine: eng, Timeout: taskTimeout(opts)}
|
|
201
|
+
resp, err := runner.Run(pctx, agent.Request{
|
|
202
|
+
Engine: eng.Name(),
|
|
203
|
+
Model: route.Model,
|
|
204
|
+
SystemPrompt: strings.TrimSpace(opts.SystemPrompt + "\n\n" + plannerPrompt(opts)),
|
|
205
|
+
// 关工具:计划要可解析,不要中间过程。
|
|
206
|
+
Tools: agent.ToolsOff,
|
|
207
|
+
// 计划输出本来就是 JSON,让支持结构化输出的引擎直接约束形状:
|
|
208
|
+
// 少一次「正文里找 JSON」的猜测,解析失败率跟着降。
|
|
209
|
+
JSONSchema: planSchema(),
|
|
210
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
211
|
+
Reasoning: opts.Reasoning,
|
|
212
|
+
SessionID: sid,
|
|
213
|
+
Continue: sid == "" && onMain && opts.Continue,
|
|
214
|
+
})
|
|
215
|
+
if err != nil {
|
|
216
|
+
return Plan{}, "", err
|
|
217
|
+
}
|
|
218
|
+
plan, perr := parsePlan(resp.Text)
|
|
219
|
+
if !onMain {
|
|
220
|
+
return plan, "", perr
|
|
221
|
+
}
|
|
222
|
+
return plan, resp.SessionID, perr
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// routeHint 把「这次五类任务分别落到哪个引擎」写进计划提示词。
|
|
226
|
+
//
|
|
227
|
+
// 五类**一律列出**,没配专用引擎的写「跟随主引擎」。早先的做法是把没配的类别整条略去
|
|
228
|
+
// 并注明「不要拆出来」,结果本机全部跟随主引擎时,image / video / game 这类按需求该出现
|
|
229
|
+
// 的子任务被提示词直接抹掉,计划永远只剩 text —— 拆几个任务应由需求决定,不该由
|
|
230
|
+
// 「有没有配专用引擎」决定。
|
|
231
|
+
//
|
|
232
|
+
// 计划里只写角色,所以每行都带上该类别的角色名:模型挑角色时就知道会派到哪个引擎。
|
|
233
|
+
func routeHint(opts Options) string {
|
|
234
|
+
main := ""
|
|
235
|
+
if opts.Engine != nil {
|
|
236
|
+
main = opts.Engine.Name()
|
|
237
|
+
}
|
|
238
|
+
pick := func(kind string) string {
|
|
239
|
+
if r, ok := opts.Routes[kind]; ok && strings.TrimSpace(r.Engine) != "" {
|
|
240
|
+
return r.Engine + modelSlash(r.Model)
|
|
241
|
+
}
|
|
242
|
+
if main == "" {
|
|
243
|
+
return "未配置"
|
|
244
|
+
}
|
|
245
|
+
return main + modelSlash(opts.Model) + "(跟随主引擎)"
|
|
246
|
+
}
|
|
247
|
+
var b strings.Builder
|
|
248
|
+
b.WriteString("子任务的类别由角色决定,各类分别交给:\n")
|
|
249
|
+
b.WriteString("- text(writer / researcher / reviewer)→ " + pick(KindText) + "\n")
|
|
250
|
+
b.WriteString("- code(coder)→ " + pick(KindCode) + "\n")
|
|
251
|
+
b.WriteString("- image(designer)→ " + pick(KindImage) + "\n")
|
|
252
|
+
b.WriteString("- video(director)→ " + pick(KindVideo) + "\n")
|
|
253
|
+
b.WriteString("- game(builder)→ " + pick(KindGame) + "\n")
|
|
254
|
+
b.WriteString("标了「跟随主引擎」的类别同样可以拆;拆不拆只看需求里有没有这类产物。\n")
|
|
255
|
+
if opts.SplitSimpleCode {
|
|
256
|
+
b.WriteString("code(coder)与其他类别一样:只要需求里要改代码就拆;只有纯问答(不改文件)由主引擎直接答。")
|
|
257
|
+
} else {
|
|
258
|
+
b.WriteString("唯一的例外是 code(coder):只有复杂代码才拆,简单代码由主引擎在最终回复里直接给出。")
|
|
259
|
+
}
|
|
260
|
+
return b.String()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func modelSlash(model string) string {
|
|
264
|
+
if strings.TrimSpace(model) == "" {
|
|
265
|
+
return ""
|
|
266
|
+
}
|
|
267
|
+
return "/" + strings.TrimSpace(model)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// taskTimeout 单个子任务的超时。
|
|
271
|
+
//
|
|
272
|
+
// 推导规则(写死,别改成「就用总超时」):总预算的一半,上限 20 分钟,下限 1 分钟。
|
|
273
|
+
// 为什么是「一半」:一整轮里还有计划 + 汇总两段要跑,子任务全吃掉预算的话,
|
|
274
|
+
// 最需要时间的汇总阶段会被 context deadline 掐掉 —— 而那一步才是用户看到的答案。
|
|
275
|
+
func taskTimeout(opts Options) time.Duration {
|
|
276
|
+
if opts.TaskTimeout > 0 {
|
|
277
|
+
return opts.TaskTimeout
|
|
278
|
+
}
|
|
279
|
+
const (
|
|
280
|
+
floor = time.Minute
|
|
281
|
+
cap = 20 * time.Minute
|
|
282
|
+
)
|
|
283
|
+
if opts.Timeout <= 0 {
|
|
284
|
+
return cap
|
|
285
|
+
}
|
|
286
|
+
d := opts.Timeout / 2
|
|
287
|
+
if d > cap {
|
|
288
|
+
d = cap
|
|
289
|
+
}
|
|
290
|
+
if d < floor {
|
|
291
|
+
d = floor
|
|
292
|
+
}
|
|
293
|
+
return d
|
|
294
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// reduce.go - 汇总:把各子任务的产出喂回主引擎,产出一份最终回答。
|
|
4
|
+
//
|
|
5
|
+
// 三条纪律:
|
|
6
|
+
//
|
|
7
|
+
// 1. **只喂摘要,不回灌全文**。子任务的正文已经落在 OUT-<id>.md 里,全灌进主引擎
|
|
8
|
+
// 只会把上下文撑爆、把最有用的那句结论淹掉。每个任务给首段 + 产物清单 + 结局。
|
|
9
|
+
// 2. **失败也要有答案**。reducer 自己失败时回落「产物清单 + 各任务首段」,仍以正常
|
|
10
|
+
// 结果收尾 —— 用户至少知道谁做了什么、东西在哪。
|
|
11
|
+
// 3. **缺失如实报**。交付物没找到就说没找到,不替子任务圆场。
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"context"
|
|
15
|
+
"fmt"
|
|
16
|
+
"path/filepath"
|
|
17
|
+
"strings"
|
|
18
|
+
|
|
19
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// reduceCap 每个子任务喂给汇总阶段的正文上限(rune)。
|
|
23
|
+
//
|
|
24
|
+
// 1200 字 ≈ 一段摘要的量。任务多时总量仍可控(上限 6 个任务 ≈ 7k 字),而首段往往
|
|
25
|
+
// 就是结论所在 —— 真要看细节,OUT-<id>.md 就在那儿。
|
|
26
|
+
const reduceCap = 1200
|
|
27
|
+
|
|
28
|
+
// reduce 调主引擎汇总;失败回落清单。planSession 是计划那一轮的会话 id(接同一条会话)。
|
|
29
|
+
func reduce(ctx context.Context, opts Options, plan Plan, results []TaskResult, planSession string, emit func(agent.StreamEvent)) (string, string) {
|
|
30
|
+
emit(agent.StreamEvent{Kind: agent.KindNotice, Text: "团队模式:正在汇总…"})
|
|
31
|
+
|
|
32
|
+
prompt := reducePrompt(plan, results)
|
|
33
|
+
rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
|
|
34
|
+
defer cancel()
|
|
35
|
+
|
|
36
|
+
runner := &agent.Runner{Engine: opts.Engine, Timeout: taskTimeout(opts)}
|
|
37
|
+
resp, err := runner.Run(rctx, agent.Request{
|
|
38
|
+
Engine: opts.Engine.Name(),
|
|
39
|
+
Model: opts.Model,
|
|
40
|
+
SystemPrompt: strings.TrimSpace(opts.SystemPrompt + "\n\n" + reduceSystemPrompt),
|
|
41
|
+
// 汇总也关工具:它只该整理已完成的产出,不该再去跑新东西。
|
|
42
|
+
Tools: agent.ToolsOff,
|
|
43
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
44
|
+
Reasoning: opts.Reasoning,
|
|
45
|
+
// 接计划那一轮的会话:主引擎在计划里已经想过怎么拆,汇总要用的正是这份上下文。
|
|
46
|
+
SessionID: planSession,
|
|
47
|
+
})
|
|
48
|
+
if err != nil || strings.TrimSpace(resp.Text) == "" {
|
|
49
|
+
reason := "汇总失败"
|
|
50
|
+
if err != nil {
|
|
51
|
+
reason = agent.ReasonOf(err)
|
|
52
|
+
if reason == "" {
|
|
53
|
+
reason = err.Error()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
text := fallbackSummary(plan, results, reason)
|
|
57
|
+
emit(agent.StreamEvent{Kind: agent.KindText, Text: text})
|
|
58
|
+
return text, ""
|
|
59
|
+
}
|
|
60
|
+
// 产物清单由 core 确定性追加,不交给模型(理由见 artsBlock)。
|
|
61
|
+
text := strings.TrimSpace(resp.Text) + artsBlock(results)
|
|
62
|
+
emit(agent.StreamEvent{Kind: agent.KindText, Text: text})
|
|
63
|
+
return text, resp.SessionID
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reduceSystemPrompt = `你是任务汇总者。下面是若干子任务的产出,请整合成一份**给用户看的最终答复**。
|
|
67
|
+
|
|
68
|
+
要求:
|
|
69
|
+
- 直接回答用户最初的诉求,不要把子任务逐个复述一遍。
|
|
70
|
+
- 结果有冲突或缺口时**如实说明**,不要编造不存在的内容。
|
|
71
|
+
- 不要在答复末尾罗列产物路径(系统会自动附上产物清单);正文里提到文件时用短名字或相对路径。
|
|
72
|
+
- 用中文,简洁。不要提到「子任务」「团队模式」这类实现细节。`
|
|
73
|
+
|
|
74
|
+
// reducePrompt 组装汇总输入。
|
|
75
|
+
func reducePrompt(plan Plan, results []TaskResult) string {
|
|
76
|
+
var b strings.Builder
|
|
77
|
+
b.WriteString("目标:" + orDash(plan.Goal) + "\n\n")
|
|
78
|
+
b.WriteString("各子任务产出:\n")
|
|
79
|
+
for _, r := range results {
|
|
80
|
+
b.WriteString("\n### " + r.ID + " · " + orDash(r.Title) + "(" + r.Kind + ")\n")
|
|
81
|
+
b.WriteString("结局:" + r.State)
|
|
82
|
+
if r.Reason != "" && r.State != TaskDone {
|
|
83
|
+
b.WriteString("(" + r.Reason + ")")
|
|
84
|
+
}
|
|
85
|
+
b.WriteString("\n")
|
|
86
|
+
if s := strings.TrimSpace(r.Text); s != "" {
|
|
87
|
+
b.WriteString(Truncate(s, reduceCap) + "\n")
|
|
88
|
+
}
|
|
89
|
+
if len(r.Arts) > 0 {
|
|
90
|
+
b.WriteString("产物:\n")
|
|
91
|
+
for _, a := range r.Arts {
|
|
92
|
+
b.WriteString("- " + a + "\n")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if len(r.Missing) > 0 {
|
|
96
|
+
b.WriteString("**缺少声明的交付物**:" + strings.Join(r.Missing, "、") + "\n")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
b.WriteString("\n请据此产出最终答复。\n")
|
|
100
|
+
return b.String()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// artsBlock 生成确定性的「产物」清单(markdown 无序列表)。
|
|
104
|
+
//
|
|
105
|
+
// 为什么不让模型写(2026-10-22 用户报「产物显示异常」):模型照「一行一个绝对路径」的
|
|
106
|
+
// 要求输出后,markdown 把单换行折成空格 —— 三条长路径(`…/.magic/team/run-xxx/t1/impl/…`)
|
|
107
|
+
// 粘成一大段,既读不出来也把运行内部目录整个摊在用户面前。路径是确定性数据:
|
|
108
|
+
// 由 core 直接出列表,项各占一行、只显示相对**运行目录**的短路径,运行目录在末尾给一次。
|
|
109
|
+
func artsBlock(results []TaskResult) string {
|
|
110
|
+
runDir := runDirOf(results)
|
|
111
|
+
type item struct{ rel, title string }
|
|
112
|
+
var items []item
|
|
113
|
+
for _, r := range results {
|
|
114
|
+
for _, a := range r.Arts {
|
|
115
|
+
items = append(items, item{rel: relArt(runDir, a), title: orDash(r.Title)})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if len(items) == 0 {
|
|
119
|
+
return ""
|
|
120
|
+
}
|
|
121
|
+
var b strings.Builder
|
|
122
|
+
b.WriteString("\n\n**产物**\n\n")
|
|
123
|
+
for _, it := range items {
|
|
124
|
+
b.WriteString("- `" + it.rel + "` · " + it.title + "\n")
|
|
125
|
+
}
|
|
126
|
+
if runDir != "" {
|
|
127
|
+
b.WriteString("\n全部位于:`" + runDir + "`")
|
|
128
|
+
}
|
|
129
|
+
return b.String()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// runDirOf 从任一任务的 OUT 路径反推运行目录(所有任务同属一次运行)。
|
|
133
|
+
// 推不出来(没写过 OUT)就返回空串 —— 那时产物只能给绝对路径,宁可长也不给假短路径。
|
|
134
|
+
func runDirOf(results []TaskResult) string {
|
|
135
|
+
for _, r := range results {
|
|
136
|
+
if r.OutputPath != "" {
|
|
137
|
+
return filepath.Dir(r.OutputPath)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return ""
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// relArt 产物绝对路径 → 相对运行目录的短路径;不在运行目录下就原样返回。
|
|
144
|
+
func relArt(runDir, abs string) string {
|
|
145
|
+
if runDir == "" {
|
|
146
|
+
return abs
|
|
147
|
+
}
|
|
148
|
+
s, err := filepath.Rel(runDir, abs)
|
|
149
|
+
if err != nil || s == ".." || strings.HasPrefix(s, ".."+string(filepath.Separator)) {
|
|
150
|
+
return abs
|
|
151
|
+
}
|
|
152
|
+
return s
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// fallbackSummary reducer 失败时的兜底:清单式汇报。
|
|
156
|
+
//
|
|
157
|
+
// 它不好看,但它**每一句都是真的**,而且产物路径一个不少 —— 这正是失败时最该保证的。
|
|
158
|
+
func fallbackSummary(plan Plan, results []TaskResult, reason string) string {
|
|
159
|
+
runDir := runDirOf(results)
|
|
160
|
+
var b strings.Builder
|
|
161
|
+
b.WriteString("已完成 " + fmt.Sprintf("%d", countDone(results)) + " / " + fmt.Sprintf("%d", len(results)) + " 项。\n")
|
|
162
|
+
b.WriteString("(汇总失败:" + reason + ",以下为原始结果。)\n\n")
|
|
163
|
+
for _, r := range results {
|
|
164
|
+
b.WriteString("## " + orDash(r.Title) + " · " + r.Kind + "\n")
|
|
165
|
+
b.WriteString("结局:" + r.State)
|
|
166
|
+
if r.Reason != "" && r.State != TaskDone {
|
|
167
|
+
b.WriteString("(" + r.Reason + ")")
|
|
168
|
+
}
|
|
169
|
+
b.WriteString("\n")
|
|
170
|
+
if s := strings.TrimSpace(r.Text); s != "" {
|
|
171
|
+
b.WriteString(Truncate(s, reduceCap) + "\n")
|
|
172
|
+
}
|
|
173
|
+
if len(r.Arts) > 0 {
|
|
174
|
+
b.WriteString("\n产物:\n")
|
|
175
|
+
for _, a := range r.Arts {
|
|
176
|
+
b.WriteString("- " + relArt(runDir, a) + "\n")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
b.WriteString("\n")
|
|
180
|
+
}
|
|
181
|
+
return strings.TrimSpace(b.String())
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func countDone(results []TaskResult) int {
|
|
185
|
+
n := 0
|
|
186
|
+
for _, r := range results {
|
|
187
|
+
if r.State == TaskDone {
|
|
188
|
+
n++
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return n
|
|
192
|
+
}
|