fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。
- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
`Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
(顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
`scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
`ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。
验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。
⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
This commit is contained in:
1 parent
6e7ec56f9a
commit
7e7f53bb41
15 files changed
+974
-50
No files matched your search
@@ -123,7 +123,8 @@ func (s *Scheduler) Comm() *comm.Probe { return s.comm }
|
||||
// when spec.Goal is blank, and a wrapped ErrUnknownPersona when spec.PersonaID
|
||||
// names a role this process has no agent for (design/22 §8-2).
|
||||
//
|
||||
// The sub-agent's Context is a deep copy of spec.Context and its WorkingMemory is
|
||||
// The sub-agent's Context is a deep copy of spec.Context plus the task itself
|
||||
// as one trailing user message (裁决 2026-10-04), and its WorkingMemory is
|
||||
// a fresh, isolated window, so it never shares state with the front session.
|
||||
func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error) {
|
||||
if strings.TrimSpace(spec.Goal) == "" {
|
||||
@@ -145,8 +146,10 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
|
||||
AttachInfo: spec.AttachInfo,
|
||||
ParentSession: spec.ParentSession,
|
||||
Data: spec.Data,
|
||||
Context: cloneMessages(spec.Context),
|
||||
ToolWhitelist: cloneStrings(spec.ToolWhitelist),
|
||||
Context: taskContext(spec),
|
||||
WorkingMemory: memory.NewWorkingMemory(0),
|
||||
timeout: effectiveTimeout(spec.Timeout, s.perAgentTimeout),
|
||||
status: StatusPending,
|
||||
done: make(chan struct{}),
|
||||
finishedCh: make(chan struct{}),
|
||||
@@ -304,7 +307,7 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
}
|
||||
s.publish(EventStarted, sub.progress())
|
||||
|
||||
runCtx, cancel := s.subCtx(ctx)
|
||||
runCtx, cancel := s.subCtx(ctx, sub)
|
||||
// A sub-agent must not leak its inner monologue into the caller's chat stream
|
||||
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台): muting suppresses the
|
||||
// UI stream/capacity broadcast while progress still reaches the frontend over
|
||||
@@ -316,11 +319,14 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
// of this run (design/22 §8-2). Blank keeps the inherited env: the pre-existing
|
||||
// behavior, where a sub-agent runs as the spawning turn's role.
|
||||
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
|
||||
// 工具白名单(design/05 §7 裁决 02.4.1)随运行上下文传递:名单非空时
|
||||
// 只放行所列工具;空名单=全开,与挂载前的行为一致。
|
||||
runCtx = loop.WithToolWhitelist(runCtx, sub.ToolWhitelist)
|
||||
runCtx, span := s.subSpan(runCtx, sub)
|
||||
sub.setCancel(cancel)
|
||||
defer cancel()
|
||||
|
||||
res, err := s.runner.Run(runCtx, sub.ID, sub.Context)
|
||||
res, err := s.scopedRunner(sub).Run(runCtx, sub.ID, sub.Context)
|
||||
if err != nil {
|
||||
s.spanErr(sub, span, err)
|
||||
s.abortOrFail(sub, err)
|
||||
@@ -333,11 +339,12 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
|
||||
// abortOrFail classifies an error after a sub-agent's run: a deadline exceeded or
|
||||
// a cancellation yields the aborted status (timeout/stop), anything else is a
|
||||
// real failure.
|
||||
// real failure. The timeout notice names the deadline that actually applied to
|
||||
// this sub-agent (per-call override or scheduler default), not the global one.
|
||||
func (s *Scheduler) abortOrFail(sub *SubAgent, err error) {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
s.finish(sub, StatusAborted, timeoutSummary(s.perAgentTimeout), err)
|
||||
s.finish(sub, StatusAborted, timeoutSummaryFor(sub.timeout), err)
|
||||
case errors.Is(err, context.Canceled):
|
||||
s.finish(sub, StatusAborted, canceledSummary(), err)
|
||||
default:
|
||||
@@ -363,10 +370,13 @@ func (s *Scheduler) finish(sub *SubAgent, status Status, summary string, err err
|
||||
})
|
||||
}
|
||||
|
||||
// subCtx derives the run context, applying the per-agent timeout when configured.
|
||||
func (s *Scheduler) subCtx(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
if s.perAgentTimeout > 0 {
|
||||
return context.WithTimeout(parent, s.perAgentTimeout)
|
||||
// subCtx derives the run context, applying the sub-agent's effective timeout:
|
||||
// a per-call AgentSpec.Timeout (design/05 §7 裁决 02.4.4 按调用覆盖) wins over
|
||||
// the scheduler's PerAgentTimeout; neither configured means no deadline beyond
|
||||
// cancellation.
|
||||
func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Context, context.CancelFunc) {
|
||||
if sub.timeout > 0 {
|
||||
return context.WithTimeout(parent, sub.timeout)
|
||||
}
|
||||
return context.WithCancel(parent)
|
||||
}
|
||||
@@ -475,12 +485,3 @@ func eventFor(status Status) string {
|
||||
return EventStarted
|
||||
}
|
||||
}
|
||||
|
||||
// wrapLoop adapts a *loop.Loop into the runner interface, substituting a no-op
|
||||
// runner (fail-fast) when the template is nil.
|
||||
func wrapLoop(l *loop.Loop) runner {
|
||||
if l == nil {
|
||||
return noopRunner{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
Reference in new issue
Block a user