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.
- 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-agent.js +24 -1
- package/npm/bin/magic-client.js +20 -2
- 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,648 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
// execute.go - 跑完整张计划,把每个子任务当成一条普通会话跑。
|
|
4
|
+
//
|
|
5
|
+
// 并发模型:**就绪即跑**(见 schedule.go)。依赖一结束任务就入队,不等同层其他任务;
|
|
6
|
+
// 并发上限由 --team-parallel 控制,失败级联与取消也在调度器里。本文件只负责
|
|
7
|
+
// 「一个任务怎么跑」,不再自己算依赖。
|
|
8
|
+
//
|
|
9
|
+
// 事件去向(2026-10-22 用户定稿):子任务是独立会话,它的事件**原样**交给
|
|
10
|
+
// Options.OpenSubSession 返回的 SubSession 写进自己的日志,主会话只从 Board 拿到
|
|
11
|
+
// 任务列表快照(agent.KindTasks)。这里不再改写 Parent / session_id / item_id,
|
|
12
|
+
// 也不再合成委派卡 —— 那套投影是「子任务只是父卡下的一段过程」时代的做法。
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"context"
|
|
16
|
+
"encoding/json"
|
|
17
|
+
"errors"
|
|
18
|
+
"fmt"
|
|
19
|
+
"os"
|
|
20
|
+
"os/exec"
|
|
21
|
+
"path/filepath"
|
|
22
|
+
"runtime"
|
|
23
|
+
"strconv"
|
|
24
|
+
"strings"
|
|
25
|
+
"sync"
|
|
26
|
+
"time"
|
|
27
|
+
|
|
28
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// 任务结局词表(与 session 的状态词表不同轴:这里说的是「这个子任务」)。
|
|
32
|
+
const (
|
|
33
|
+
TaskDone = "done"
|
|
34
|
+
TaskFailed = "failed"
|
|
35
|
+
TaskSkipped = "skipped"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// execute 跑完整张计划,返回每个任务的结局(顺序与计划一致)。
|
|
39
|
+
//
|
|
40
|
+
// 这里只做两件事:把「跑一个任务」包成 Worker,把「跳过」包成 SkipFunc(留档 + 落定一行)。
|
|
41
|
+
// 谁先跑、谁被跳过、并发多少,全在 Scheduler 里。
|
|
42
|
+
func execute(ctx context.Context, opts Options, plan Plan, runDir string, board *Board) []TaskResult {
|
|
43
|
+
results, err := (ReadyQueue{Parallel: opts.Parallel}).Run(ctx, plan,
|
|
44
|
+
func(ctx context.Context, t Task, upstream map[string]TaskResult) TaskResult {
|
|
45
|
+
return runTask(ctx, opts, plan, t, runDir, upstream, board)
|
|
46
|
+
},
|
|
47
|
+
func(t Task, reason string) {
|
|
48
|
+
// 跳过也要留档、也要落定那一行:否则任务列表里永远挂着一条「待运行」,
|
|
49
|
+
// 运行目录里也查不到它为什么没跑。
|
|
50
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, State: TaskSkipped, Reason: reason}
|
|
51
|
+
writeOutput(runDir, r, nil)
|
|
52
|
+
board.finish(t, r)
|
|
53
|
+
})
|
|
54
|
+
if err != nil {
|
|
55
|
+
// Validate 已经在 Run 里拦过;走到这里说明计划在校验之后被改过,
|
|
56
|
+
// 按全跳过处理,绝不让一轮团队调用没有终态。
|
|
57
|
+
out := make([]TaskResult, 0, len(plan.Tasks))
|
|
58
|
+
for _, t := range plan.Tasks {
|
|
59
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, State: TaskSkipped, Reason: err.Error()}
|
|
60
|
+
writeOutput(runDir, r, nil)
|
|
61
|
+
board.finish(t, r)
|
|
62
|
+
out = append(out, r)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
return results
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 重试与验收的固定参数。
|
|
70
|
+
const (
|
|
71
|
+
// taskMaxAttempts 一个子任务最多跑几次(1 次 + 1 次重试)。
|
|
72
|
+
//
|
|
73
|
+
// 只对「模型跑完了但没交出东西」重试:执行本身失败(超时 / 引擎报错)不重试 ——
|
|
74
|
+
// 同一提示词再来一次只会把成本和耗时翻倍,而且大概率同样失败。
|
|
75
|
+
taskMaxAttempts = 2
|
|
76
|
+
// checkTimeout 单条验收命令的超时。
|
|
77
|
+
checkTimeout = 2 * time.Minute
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// CheckResult 一条验收命令的执行结果。
|
|
81
|
+
type CheckResult struct {
|
|
82
|
+
// Cmd 计划里写的命令原文。
|
|
83
|
+
Cmd string `json:"cmd"`
|
|
84
|
+
// Code 退出码(0 = 通过;-1 = 起不来 / 超时)。
|
|
85
|
+
Code int `json:"code"`
|
|
86
|
+
// Output 合并后的 stdout+stderr(写进 OUT 时截断)。
|
|
87
|
+
Output string `json:"output"`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// runTask 跑一个子任务:点亮任务列表那一行 → 开子会话 → 核对交付物/验收命令 → 落 OUT / ARTS → 落定结局。
|
|
91
|
+
//
|
|
92
|
+
// 核对不过(缺交付物 / 验收命令非零)重试一次,并把「上次为什么没过」写进重试提示词。
|
|
93
|
+
// 重试前已经产出的文件不会丢:产物按路径去重合并,模型可以接着改而不是推倒重来。
|
|
94
|
+
func runTask(ctx context.Context, opts Options, plan Plan, t Task, runDir string, upstream map[string]TaskResult, board *Board) TaskResult {
|
|
95
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, OutputPath: OutputPath(runDir, t.ID)}
|
|
96
|
+
|
|
97
|
+
route := RouteFor(t, opts)
|
|
98
|
+
r.Engine, r.Model = route.Engine, route.Model
|
|
99
|
+
board.start(t, route.Engine, route.Model)
|
|
100
|
+
|
|
101
|
+
// sub 在「确实要起进程」之后才开;此前的失败路径没有过程可看,开一条空会话只是噪音。
|
|
102
|
+
var sub SubSession
|
|
103
|
+
end := func(res TaskResult) TaskResult {
|
|
104
|
+
if sub != nil {
|
|
105
|
+
sub.Finish(res.SessionID, res.State, res.Reason)
|
|
106
|
+
}
|
|
107
|
+
board.finish(t, res)
|
|
108
|
+
return res
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if strings.TrimSpace(route.Engine) == "" {
|
|
112
|
+
r.State = TaskFailed
|
|
113
|
+
r.Reason = fmt.Sprintf("没有可用引擎:角色 %s(类别 %s)没配引擎,主引擎也为空", t.Role, t.EffectiveKind())
|
|
114
|
+
writeOutput(runDir, r, nil)
|
|
115
|
+
return end(r)
|
|
116
|
+
}
|
|
117
|
+
eng := agent.Lookup(route.Engine)
|
|
118
|
+
if eng == nil {
|
|
119
|
+
r.State = TaskFailed
|
|
120
|
+
r.Reason = fmt.Sprintf("引擎不存在:%s", route.Engine)
|
|
121
|
+
writeOutput(runDir, r, nil)
|
|
122
|
+
return end(r)
|
|
123
|
+
}
|
|
124
|
+
r.Engine, r.Model = eng.Name(), route.Model
|
|
125
|
+
|
|
126
|
+
// 上游输入核对:计划声明「由 deps 产出」的文件却不在,直接失败 —— 子任务看得见
|
|
127
|
+
// 的上下文只有任务书,让它去猜上游为什么没交等于白烧一次模型。
|
|
128
|
+
if missing := MissingInputs(runDir, plan, t); len(missing) > 0 {
|
|
129
|
+
r.State = TaskFailed
|
|
130
|
+
r.Reason = "缺少上游输入:" + strings.Join(missing, "、")
|
|
131
|
+
writeOutput(runDir, r, t.Deliverables)
|
|
132
|
+
return end(r)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 子任务的会话在开跑这一刻登记:会话列表里立刻能看到它挂在主会话下面,
|
|
136
|
+
// 点进去就是这次执行的完整过程。
|
|
137
|
+
if opts.OpenSubSession != nil {
|
|
138
|
+
if s := opts.OpenSubSession(t, runDir, eng.Name(), route.Model); s != nil {
|
|
139
|
+
sub = s
|
|
140
|
+
board.setSession(t.ID, "", s.RunID())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
base := taskPrompt(runDir, plan, t, upstream)
|
|
145
|
+
var problems []string
|
|
146
|
+
for attempt := 1; attempt <= taskMaxAttempts; attempt++ {
|
|
147
|
+
got := runAttempt(ctx, opts, t, runDir, route, eng, attemptPrompt(base, attempt, problems), board, sub)
|
|
148
|
+
// 前几次尝试已经落盘的产物要带上:重试是「补齐」,不是「重来」。
|
|
149
|
+
got.Arts = dedupeStrings(append(got.Arts, r.Arts...))
|
|
150
|
+
if got.SessionID == "" {
|
|
151
|
+
got.SessionID = r.SessionID
|
|
152
|
+
}
|
|
153
|
+
got.ID, got.Kind, got.Title = t.ID, t.EffectiveKind(), t.Title
|
|
154
|
+
got.Role = t.Role
|
|
155
|
+
got.OutputPath = OutputPath(runDir, t.ID)
|
|
156
|
+
got.Engine, got.Model = eng.Name(), route.Model
|
|
157
|
+
got.Attempts = attempt
|
|
158
|
+
r = got
|
|
159
|
+
|
|
160
|
+
// 执行本身失败(超时 / 引擎报错)不重试,见 taskMaxAttempts 的注释。
|
|
161
|
+
if r.State != TaskDone {
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
problems = verifyTask(ctx, runDir, plan, t, &r)
|
|
165
|
+
if len(problems) == 0 {
|
|
166
|
+
r.State = TaskDone
|
|
167
|
+
r.Reason = ""
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
r.State = TaskFailed
|
|
171
|
+
r.Reason = strings.Join(problems, ";")
|
|
172
|
+
if attempt >= taskMaxAttempts || ctx.Err() != nil {
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
board.notice(fmt.Sprintf("团队模式:%s「%s」未通过核对(%s),重试一次", t.ID, t.Title, r.Reason))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
writeOutput(runDir, r, t.Deliverables)
|
|
179
|
+
return end(r)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// runAttempt 跑一次尝试(流式引擎走 Stream,非流式退化成一次 Complete)。
|
|
183
|
+
//
|
|
184
|
+
// prompt 由调用方给:首次是任务书,重试会追加「上一次未通过」那一节。
|
|
185
|
+
// 子任务的事件原样交给 sub(它写自己的日志);主会话只通过 board 看到状态与心跳。
|
|
186
|
+
func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route Route, eng agent.Engine, prompt string, board *Board, sub SubSession) TaskResult {
|
|
187
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, OutputPath: OutputPath(runDir, t.ID)}
|
|
188
|
+
r.Engine, r.Model = eng.Name(), route.Model
|
|
189
|
+
dir := TaskDir(runDir, t.ID)
|
|
190
|
+
|
|
191
|
+
streamer := agent.AsStreamer(eng)
|
|
192
|
+
if streamer == nil {
|
|
193
|
+
// 非流式引擎:退化成一次 Complete(子任务事件少,用不上增量)。
|
|
194
|
+
got := completeTask(ctx, opts, eng, route, t, runDir, prompt)
|
|
195
|
+
if sub != nil {
|
|
196
|
+
if got.SessionID != "" {
|
|
197
|
+
sub.SetSessionID(got.SessionID)
|
|
198
|
+
}
|
|
199
|
+
if text := strings.TrimSpace(got.Text); text != "" {
|
|
200
|
+
sub.Push(agent.StreamEvent{Kind: agent.KindText, Text: got.Text})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return got
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
tctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
|
|
207
|
+
defer cancel()
|
|
208
|
+
// 子会话的 ctx:SpawnHook 是 context 值,换掉只影响这次子任务进程的 pid 归属,
|
|
209
|
+
// 不会动主会话那条(agent.WithSpawnHook 用 WithValue 覆盖,见 runcmd.go)。
|
|
210
|
+
if sub != nil {
|
|
211
|
+
tctx = sub.Context(tctx)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* 子会话 id 的**早期来源**:正文 / 思考事件不携带 session_id(只有 ask 与
|
|
215
|
+
turn_end 带),所以「等事件里出现再记」会让整段执行期都没有会话 id。
|
|
216
|
+
引擎一读到 init 行就会回调 OnSessionID(claude / codebuddy / trae 同一条路),
|
|
217
|
+
这里当场收下并转给子会话(登记表 + 日志文件名都靠它)。
|
|
218
|
+
回调与 onEvent 引擎侧不保证同 goroutine(见 Request.OnSessionID 注释),加锁。 */
|
|
219
|
+
var sidMu sync.Mutex
|
|
220
|
+
subSID := func() string {
|
|
221
|
+
sidMu.Lock()
|
|
222
|
+
defer sidMu.Unlock()
|
|
223
|
+
return r.SessionID
|
|
224
|
+
}
|
|
225
|
+
setSID := func(id string) {
|
|
226
|
+
if id == "" {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
sidMu.Lock()
|
|
230
|
+
r.SessionID = id
|
|
231
|
+
sidMu.Unlock()
|
|
232
|
+
if sub != nil {
|
|
233
|
+
sub.SetSessionID(id)
|
|
234
|
+
}
|
|
235
|
+
board.setSession(t.ID, id, "")
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
res, err := streamer.Stream(tctx, agent.Request{
|
|
239
|
+
Engine: eng.Name(),
|
|
240
|
+
Model: route.Model,
|
|
241
|
+
// 角色片段追加在主系统提示词之后(见 role.go);未设角色时与原来一致。
|
|
242
|
+
SystemPrompt: systemPromptFor(opts, t),
|
|
243
|
+
Tools: toolsFor(opts, t),
|
|
244
|
+
// 子任务**不接续任何会话**:这是「不共享上下文」的落地点。
|
|
245
|
+
SessionID: "",
|
|
246
|
+
Continue: false,
|
|
247
|
+
Workspace: dir,
|
|
248
|
+
Reasoning: opts.Reasoning,
|
|
249
|
+
Permission: permissionFor(opts, t),
|
|
250
|
+
PermissionOptions: opts.PermissionOptions,
|
|
251
|
+
OnSessionID: setSID,
|
|
252
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
253
|
+
}, func(ev agent.StreamEvent) {
|
|
254
|
+
if ev.SessionID != "" {
|
|
255
|
+
setSID(ev.SessionID)
|
|
256
|
+
}
|
|
257
|
+
if ev.Kind == agent.KindImage {
|
|
258
|
+
if p := artFromDataURI(ev.Text, runDir, t.ID, len(r.Arts)); p != "" {
|
|
259
|
+
r.Arts = append(r.Arts, p)
|
|
260
|
+
// 图已落盘 → 事件里改成路径,别再往历史里灌一整段 base64。
|
|
261
|
+
ev.Text = p
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if ev.Kind == agent.KindToolResult {
|
|
265
|
+
r.Arts = append(r.Arts, artsFromText(ev.Text)...)
|
|
266
|
+
}
|
|
267
|
+
// 原样交给子会话:这是它自己的日志,Parent / item_id / session_id 都该是它本来的样子。
|
|
268
|
+
if sub != nil {
|
|
269
|
+
sub.Push(ev)
|
|
270
|
+
}
|
|
271
|
+
board.touch(t.ID)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if err != nil {
|
|
275
|
+
r.State = TaskFailed
|
|
276
|
+
r.Reason = agent.ReasonOf(err)
|
|
277
|
+
if r.Reason == "" {
|
|
278
|
+
r.Reason = err.Error()
|
|
279
|
+
}
|
|
280
|
+
if sid := subSID(); sid == "" {
|
|
281
|
+
setSID(res.SessionID) // 失败也可能已经建过会话(引擎 init 行就给了 id)
|
|
282
|
+
}
|
|
283
|
+
// 失败也要核对交付物:子任务可能在超时**之前**就把文件写出来了(实测:画了
|
|
284
|
+
// 20 分钟的封面在超时前 1 分钟落盘,只报「失败」会让用户以为什么都没产出)。
|
|
285
|
+
collectDeliverables(dir, t.Deliverables, &r)
|
|
286
|
+
r.Arts = dedupeStrings(r.Arts)
|
|
287
|
+
return r
|
|
288
|
+
}
|
|
289
|
+
r.Text = res.Text
|
|
290
|
+
if sid := subSID(); sid == "" {
|
|
291
|
+
setSID(res.SessionID)
|
|
292
|
+
}
|
|
293
|
+
collectDeliverables(dir, t.Deliverables, &r)
|
|
294
|
+
r.Arts = dedupeStrings(r.Arts)
|
|
295
|
+
r.Handoff = parseHandoff(res.Text)
|
|
296
|
+
r.State = TaskDone
|
|
297
|
+
return r
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// verifyTask 核对一次尝试的产出:执行成功 → 交付物齐 → 验收命令全过 → 交接单合格。
|
|
301
|
+
//
|
|
302
|
+
// 返回空 = 通过。Checks 只在执行成功时跑(任务都没跑完,跑验收命令只会得到噪音)。
|
|
303
|
+
func verifyTask(ctx context.Context, runDir string, plan Plan, t Task, r *TaskResult) []string {
|
|
304
|
+
if r.State != TaskDone {
|
|
305
|
+
return []string{"执行失败:" + orDash(r.Reason)}
|
|
306
|
+
}
|
|
307
|
+
if len(r.Missing) > 0 {
|
|
308
|
+
return []string{"缺少交付物:" + strings.Join(r.Missing, "、")}
|
|
309
|
+
}
|
|
310
|
+
r.Checks = runChecks(ctx, runDir, t)
|
|
311
|
+
var problems []string
|
|
312
|
+
for _, c := range r.Checks {
|
|
313
|
+
if c.Code == 0 {
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
msg := fmt.Sprintf("验收命令未通过(退出码 %d):%s", c.Code, c.Cmd)
|
|
317
|
+
if out := strings.TrimSpace(c.Output); out != "" {
|
|
318
|
+
msg += " —— " + oneLine(Truncate(out, 200))
|
|
319
|
+
}
|
|
320
|
+
problems = append(problems, msg)
|
|
321
|
+
}
|
|
322
|
+
// 交接单是下游的输入:被下游当硬输入的任务必须交齐 files / summary。
|
|
323
|
+
// 叶子任务不强制 —— 没人读它的交接单,强制只会多一次重试。
|
|
324
|
+
problems = append(problems, handoffProblems(plan, t, r)...)
|
|
325
|
+
return problems
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// attemptPrompt 重试时在任务书后面追加「上一次为什么没过」。
|
|
329
|
+
//
|
|
330
|
+
// 只追加不替换:任务书里是全部上下文,重试需要的是同一份要求 + 明确的差距。
|
|
331
|
+
func attemptPrompt(base string, attempt int, problems []string) string {
|
|
332
|
+
if attempt <= 1 || len(problems) == 0 {
|
|
333
|
+
return base
|
|
334
|
+
}
|
|
335
|
+
var b strings.Builder
|
|
336
|
+
b.WriteString(base)
|
|
337
|
+
b.WriteString("\n## 上一次尝试未通过\n\n")
|
|
338
|
+
b.WriteString("core 核对上一次的产出,发现:\n")
|
|
339
|
+
for _, p := range problems {
|
|
340
|
+
b.WriteString("- " + oneLine(p) + "\n")
|
|
341
|
+
}
|
|
342
|
+
b.WriteString("\n请在工作目录里补齐 / 修正后重新收尾;已经产出的文件可以继续用,不要推倒重来。\n")
|
|
343
|
+
return b.String()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// runChecks 逐条执行任务声明的验收命令。
|
|
347
|
+
func runChecks(ctx context.Context, runDir string, t Task) []CheckResult {
|
|
348
|
+
var out []CheckResult
|
|
349
|
+
for _, raw := range t.Check {
|
|
350
|
+
cmd := strings.TrimSpace(raw)
|
|
351
|
+
if cmd == "" {
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
out = append(out, runCheck(ctx, runDir, t.ID, cmd))
|
|
355
|
+
}
|
|
356
|
+
return out
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// runCheck 在工作目录里跑一条命令:退出码 0 才算通过。
|
|
360
|
+
func runCheck(ctx context.Context, runDir, taskID, cmd string) CheckResult {
|
|
361
|
+
cctx, cancel := context.WithTimeout(ctx, checkTimeout)
|
|
362
|
+
defer cancel()
|
|
363
|
+
c := exec.CommandContext(cctx, shellName(), shellArgs(cmd)...)
|
|
364
|
+
c.Dir = TaskDir(runDir, taskID)
|
|
365
|
+
out, err := c.CombinedOutput()
|
|
366
|
+
res := CheckResult{Cmd: cmd, Output: string(out)}
|
|
367
|
+
if err == nil {
|
|
368
|
+
return res
|
|
369
|
+
}
|
|
370
|
+
res.Code = -1
|
|
371
|
+
var ee *exec.ExitError
|
|
372
|
+
if errors.As(err, &ee) && ee.ExitCode() != 0 {
|
|
373
|
+
res.Code = ee.ExitCode()
|
|
374
|
+
}
|
|
375
|
+
return res
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// shellName / shellArgs 验收命令的解释器:Windows 没有 sh,得用 cmd /c。
|
|
379
|
+
func shellName() string {
|
|
380
|
+
if runtime.GOOS == "windows" {
|
|
381
|
+
return "cmd"
|
|
382
|
+
}
|
|
383
|
+
return "sh"
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func shellArgs(cmd string) []string {
|
|
387
|
+
if runtime.GOOS == "windows" {
|
|
388
|
+
return []string{"/c", cmd}
|
|
389
|
+
}
|
|
390
|
+
return []string{"-c", cmd}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// completeTask 非流式引擎的退化路径(一次性 Complete)。
|
|
394
|
+
func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Route, t Task, runDir, prompt string) TaskResult {
|
|
395
|
+
r := TaskResult{ID: t.ID, Kind: t.EffectiveKind(), Role: t.Role, Title: t.Title, OutputPath: OutputPath(runDir, t.ID)}
|
|
396
|
+
r.Engine, r.Model = eng.Name(), route.Model
|
|
397
|
+
rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
|
|
398
|
+
defer cancel()
|
|
399
|
+
runner := &agent.Runner{Engine: eng, Timeout: taskTimeout(opts)}
|
|
400
|
+
resp, err := runner.Run(rctx, agent.Request{
|
|
401
|
+
Engine: eng.Name(),
|
|
402
|
+
Model: route.Model,
|
|
403
|
+
SystemPrompt: systemPromptFor(opts, t),
|
|
404
|
+
Tools: toolsFor(opts, t),
|
|
405
|
+
Workspace: TaskDir(runDir, t.ID),
|
|
406
|
+
Reasoning: opts.Reasoning,
|
|
407
|
+
Permission: permissionFor(opts, t),
|
|
408
|
+
PermissionOptions: opts.PermissionOptions,
|
|
409
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
410
|
+
})
|
|
411
|
+
r.SessionID = resp.SessionID
|
|
412
|
+
r.Text = resp.Text
|
|
413
|
+
collectDeliverables(TaskDir(runDir, t.ID), t.Deliverables, &r)
|
|
414
|
+
r.Arts = dedupeStrings(r.Arts)
|
|
415
|
+
if err != nil {
|
|
416
|
+
r.State = TaskFailed
|
|
417
|
+
r.Reason = agent.ReasonOf(err)
|
|
418
|
+
if r.Reason == "" {
|
|
419
|
+
r.Reason = err.Error()
|
|
420
|
+
}
|
|
421
|
+
return r
|
|
422
|
+
}
|
|
423
|
+
r.Handoff = parseHandoff(resp.Text)
|
|
424
|
+
r.State = TaskDone
|
|
425
|
+
return r
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// taskPrompt 子任务的完整提示词(任务书全文内联 + 关键路径)。
|
|
429
|
+
//
|
|
430
|
+
// 为什么不只给路径让子引擎自己读:trae / openclaw / codebuddy-gateway 这类引擎的
|
|
431
|
+
// 附件通道只有 prompt 降级(把路径写进提示词、靠它自己的读文件工具),而「读文件的
|
|
432
|
+
// 工具」在 tools=off 或引擎没有文件系统语义时并不存在。**内联是最低保证**,
|
|
433
|
+
// 路径是给有工具的引擎留的第二条路。
|
|
434
|
+
func taskPrompt(runDir string, plan Plan, t Task, upstream map[string]TaskResult) string {
|
|
435
|
+
return TaskDoc(runDir, plan, t, upstream)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// writeOutput 写 OUT-<id>.md、ARTS-<id>.json 与 HANDOFF-<id>.json(core 自己写,不信任子引擎自报)。
|
|
439
|
+
func writeOutput(runDir string, r TaskResult, deliverables []string) {
|
|
440
|
+
var b strings.Builder
|
|
441
|
+
b.WriteString("# " + strings.ToUpper(r.ID) + " · " + orDash(r.Title) + "\n\n")
|
|
442
|
+
b.WriteString("结局:" + r.State + "\n")
|
|
443
|
+
if r.Role != "" {
|
|
444
|
+
b.WriteString("角色:" + roleDetail(r.Role) + " · 类别:" + orDash(r.Kind) + "\n")
|
|
445
|
+
}
|
|
446
|
+
if r.Engine != "" {
|
|
447
|
+
b.WriteString("引擎:" + r.Engine + modelSlash(r.Model) + "\n")
|
|
448
|
+
}
|
|
449
|
+
// 子引擎自己的会话 id:绑定在主会话下(不当作本会话的锚点),留档便于回去追。
|
|
450
|
+
if r.SessionID != "" {
|
|
451
|
+
b.WriteString("会话:" + r.SessionID + "\n")
|
|
452
|
+
}
|
|
453
|
+
// 只在真重试过时留档:一次通过的任务写「尝试:1 次」是噪音。
|
|
454
|
+
if r.Attempts > 1 {
|
|
455
|
+
b.WriteString("尝试:" + strconv.Itoa(r.Attempts) + " 次\n")
|
|
456
|
+
}
|
|
457
|
+
if r.Reason != "" {
|
|
458
|
+
b.WriteString("原因:" + r.Reason + "\n")
|
|
459
|
+
}
|
|
460
|
+
b.WriteString("\n")
|
|
461
|
+
if s := strings.TrimSpace(r.Text); s != "" {
|
|
462
|
+
b.WriteString("## 正文\n\n" + s + "\n\n")
|
|
463
|
+
}
|
|
464
|
+
if len(deliverables) > 0 {
|
|
465
|
+
b.WriteString("## 交付物\n\n")
|
|
466
|
+
for _, d := range deliverables {
|
|
467
|
+
mark := "✓"
|
|
468
|
+
for _, m := range r.Missing {
|
|
469
|
+
if m == d {
|
|
470
|
+
mark = "✗"
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
b.WriteString("- " + mark + " `" + d + "`\n")
|
|
474
|
+
}
|
|
475
|
+
b.WriteString("\n")
|
|
476
|
+
}
|
|
477
|
+
if len(r.Checks) > 0 {
|
|
478
|
+
b.WriteString("## 验收命令\n\n")
|
|
479
|
+
for _, c := range r.Checks {
|
|
480
|
+
mark := "✓"
|
|
481
|
+
if c.Code != 0 {
|
|
482
|
+
mark = "✗"
|
|
483
|
+
}
|
|
484
|
+
b.WriteString("- " + mark + " `" + c.Cmd + "`(退出码 " + strconv.Itoa(c.Code) + ")\n")
|
|
485
|
+
if c.Code != 0 {
|
|
486
|
+
if out := strings.TrimSpace(c.Output); out != "" {
|
|
487
|
+
b.WriteString("\n```\n" + Truncate(out, 2000) + "\n```\n\n")
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
b.WriteString("\n")
|
|
492
|
+
}
|
|
493
|
+
if r.Handoff != nil {
|
|
494
|
+
b.WriteString("## 交接单\n\n" + handoffMarkdown(runDir, r.ID, r.Handoff) + "\n")
|
|
495
|
+
}
|
|
496
|
+
if len(r.Arts) > 0 {
|
|
497
|
+
b.WriteString("## 产物\n\n")
|
|
498
|
+
for _, a := range r.Arts {
|
|
499
|
+
b.WriteString("- " + a + "\n")
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
_ = os.WriteFile(OutputPath(runDir, r.ID), []byte(b.String()), 0o644)
|
|
503
|
+
arts, _ := json.MarshalIndent(map[string]any{
|
|
504
|
+
"id": r.ID, "state": r.State, "kind": r.Kind, "role": r.Role,
|
|
505
|
+
"engine": r.Engine, "model": r.Model,
|
|
506
|
+
"session_id": r.SessionID,
|
|
507
|
+
"attempts": r.Attempts,
|
|
508
|
+
"arts": r.Arts, "missing": r.Missing, "checks": r.Checks,
|
|
509
|
+
}, "", " ")
|
|
510
|
+
_ = os.WriteFile(ArtsPath(runDir, r.ID), arts, 0o644)
|
|
511
|
+
// 交接单单独落一份 JSON:下游任务与外部工具都可以直接读,不必从 OUT 里再解析一遍。
|
|
512
|
+
if r.Handoff != nil {
|
|
513
|
+
if hb, err := json.MarshalIndent(r.Handoff, "", " "); err == nil {
|
|
514
|
+
_ = os.WriteFile(HandoffPath(runDir, r.ID), hb, 0o644)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// collectDeliverables 逐个 stat 任务书里声明的交付物:存在的记进 Arts(绝对路径,
|
|
520
|
+
// 前端按产物卡渲染 / 点得开),缺的记进 Missing。
|
|
521
|
+
//
|
|
522
|
+
// 失败路径同样要调 —— 子任务可能在超时前就把文件写出来了,只报「失败」会让用户以为
|
|
523
|
+
// 什么都没产出(实测:t2 画完 cover.png 后 1 分钟超时,汇总说「没有封面」)。
|
|
524
|
+
//
|
|
525
|
+
// ⚠️ 声明是**相对任务目录**的路径,允许带子目录(`impl/fizzbuzz.js`):模型常把文件
|
|
526
|
+
// 分目录放,早前一律取 basename 再去任务根目录找,于是明明写出来了也判「缺少交付物」
|
|
527
|
+
// (2026-10-22 实测 6/6 全误判)。越界(绝对路径 / `../`)仍然只取文件名,落地路径
|
|
528
|
+
// 必须仍在任务目录内。
|
|
529
|
+
func collectDeliverables(dir string, want []string, r *TaskResult) {
|
|
530
|
+
for _, w := range want {
|
|
531
|
+
// 保留子目录相对路径(impl/fizzbuzz.js);越界写法在 cleanRel 里收窄成文件名。
|
|
532
|
+
name := cleanRel(w)
|
|
533
|
+
if name == "" {
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
p := filepath.Join(dir, name)
|
|
537
|
+
if !withinDir(dir, p) { // `a/../../etc/passwd` 这类清洗后才越界的写法
|
|
538
|
+
name = filepath.Base(name)
|
|
539
|
+
p = filepath.Join(dir, name)
|
|
540
|
+
}
|
|
541
|
+
if _, err := os.Stat(p); err != nil {
|
|
542
|
+
r.Missing = append(r.Missing, name)
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
545
|
+
r.Arts = append(r.Arts, p)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// withinDir 判断 p 是否落在 dir 之内(含 dir 本身)。
|
|
550
|
+
func withinDir(dir, p string) bool {
|
|
551
|
+
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p))
|
|
552
|
+
if err != nil {
|
|
553
|
+
return false
|
|
554
|
+
}
|
|
555
|
+
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// artsFromText 从工具结果正文里捞产物路径(与前端 qaArtCollect 同一份后缀口径的简化版)。
|
|
559
|
+
//
|
|
560
|
+
// 只认**绝对路径且带已知后缀**:正文里出现的相对文件名太容易误判("报告.md" 可能
|
|
561
|
+
// 只是模型在说话),而团队产物的价值在于「点得开」,宁缺勿滥。
|
|
562
|
+
func artsFromText(text string) []string {
|
|
563
|
+
var out []string
|
|
564
|
+
for _, tok := range strings.FieldsFunc(text, func(r rune) bool {
|
|
565
|
+
switch r {
|
|
566
|
+
case ' ', '\t', '\n', '\r', '"', '\'', '`', '(', ')', '[', ']', '<', '>', ',', '。', ';', '、':
|
|
567
|
+
return true
|
|
568
|
+
}
|
|
569
|
+
return false
|
|
570
|
+
}) {
|
|
571
|
+
tok = strings.TrimRight(tok, ",.;:!?")
|
|
572
|
+
if !strings.HasPrefix(tok, "/") && !strings.HasPrefix(tok, "~/") {
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
if !artExt(tok) {
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
if _, err := os.Stat(tok); err != nil {
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
out = append(out, tok)
|
|
582
|
+
}
|
|
583
|
+
return out
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// artExt 是否为已知产物后缀(与 client-ui/shared/conv/02-media-kinds.js 的清单同源)。
|
|
587
|
+
func artExt(p string) bool {
|
|
588
|
+
ext := strings.ToLower(filepath.Ext(p))
|
|
589
|
+
switch ext {
|
|
590
|
+
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".heic", ".avif",
|
|
591
|
+
".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi",
|
|
592
|
+
".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac",
|
|
593
|
+
".md", ".markdown", ".txt", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
594
|
+
".csv", ".zip", ".glb", ".gltf", ".psd", ".fig", ".html", ".json":
|
|
595
|
+
return true
|
|
596
|
+
}
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// artFromDataURI 把子引擎产出的内嵌图片落成文件(data URI 只有超长时才落盘)。
|
|
601
|
+
//
|
|
602
|
+
// 为什么落盘:内嵌在事件里的图**在历史回放里会变成一条巨大的 base64**,而且用户
|
|
603
|
+
// 拿不走。落到运行目录后,事件里只往前端发路径(前端按产物卡渲染)。
|
|
604
|
+
func artFromDataURI(uri, runDir, taskID string, seq int) string {
|
|
605
|
+
const prefix = "data:image/"
|
|
606
|
+
if !strings.HasPrefix(uri, prefix) {
|
|
607
|
+
return ""
|
|
608
|
+
}
|
|
609
|
+
comma := strings.IndexByte(uri, ',')
|
|
610
|
+
if comma < 0 || !strings.Contains(uri[:comma], "base64") {
|
|
611
|
+
return ""
|
|
612
|
+
}
|
|
613
|
+
media := strings.TrimSuffix(strings.TrimPrefix(uri[:comma], prefix), ";base64")
|
|
614
|
+
ext := ".png"
|
|
615
|
+
switch strings.ToLower(media) {
|
|
616
|
+
case "image/jpeg", "image/jpg":
|
|
617
|
+
ext = ".jpg"
|
|
618
|
+
case "image/gif":
|
|
619
|
+
ext = ".gif"
|
|
620
|
+
case "image/webp":
|
|
621
|
+
ext = ".webp"
|
|
622
|
+
case "image/svg+xml":
|
|
623
|
+
ext = ".svg"
|
|
624
|
+
}
|
|
625
|
+
dir := TaskDir(runDir, taskID)
|
|
626
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
627
|
+
return ""
|
|
628
|
+
}
|
|
629
|
+
name := fmt.Sprintf("image-%02d%s", seq+1, ext)
|
|
630
|
+
path := filepath.Join(dir, name)
|
|
631
|
+
if err := os.WriteFile(path, []byte(uri[comma+1:]), 0o644); err != nil {
|
|
632
|
+
return ""
|
|
633
|
+
}
|
|
634
|
+
return path
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
func dedupeStrings(in []string) []string {
|
|
638
|
+
seen := make(map[string]bool, len(in))
|
|
639
|
+
out := make([]string, 0, len(in))
|
|
640
|
+
for _, s := range in {
|
|
641
|
+
if s == "" || seen[s] {
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
644
|
+
seen[s] = true
|
|
645
|
+
out = append(out, s)
|
|
646
|
+
}
|
|
647
|
+
return out
|
|
648
|
+
}
|