fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

执行方案阶段 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:
AskaEth committed 2026-10-04 11:30:39 +08:00
1 parent 6e7ec56f9a
commit 7e7f53bb41
15 files changed
+974 -50

No files matched your search

+89 -18
View File
@@ -36,6 +36,12 @@ const (
// front session's context. The scheduler keeps the full text on its snapshot.
maxSpawnTextRunes = 4000
// minSpawnTimeoutSeconds 与 maxSpawnTimeoutSeconds 界定按调用覆盖
// timeout_seconds 的合法区间(design/05 §7 裁决 02.4.4 的「按调用覆盖」):
// 下限挡住「立即超时」的畸形调用,上限挡住「永远不超时」的失控委托。
minSpawnTimeoutSeconds = 1
maxSpawnTimeoutSeconds = 24 * 60 * 60
// SubagentResultSubject is the bus subject a finished background batch is
// announced on, carrying the *MergedResult. It is the design/05 §9 结果回填 hook
// for whichever component owns the session message queue (design/15); the
@@ -53,9 +59,10 @@ const (
//
// It is safe for concurrent use and holds no per-call state.
type SpawnTool struct {
sched *Scheduler
wait time.Duration
log *slog.Logger
sched *Scheduler
wait time.Duration
timeout time.Duration
log *slog.Logger
}
// SpawnOption customizes a SpawnTool built by NewSpawnTool.
@@ -76,6 +83,18 @@ func WithSpawnLogger(l *slog.Logger) SpawnOption {
return func(t *SpawnTool) { t.log = spawnLog(l) }
}
// WithSpawnTimeout 设置派发层的默认子代理时限(design/05 §7 裁决 02.4.4
// 「agent_timeout 可配置」的接法):正数时作为未按调用覆盖的批次默认,
// 覆盖调度器的 PerAgentTimeout;非正保持「不覆盖」,沿用调度器默认。
// 生产接线用 SUBAGENT_TIMEOUT_SEC(默认 10min)填充它。
func WithSpawnTimeout(d time.Duration) SpawnOption {
return func(t *SpawnTool) {
if d > 0 {
t.timeout = d
}
}
}
// NewSpawnTool builds the dispatch tool over sched. A nil scheduler is tolerated:
// Execute then fails fast with ErrNoScheduler instead of panicking.
func NewSpawnTool(sched *Scheduler, opts ...SpawnOption) *SpawnTool {
@@ -103,17 +122,21 @@ func (t *SpawnTool) Description() string {
// Parameters returns the input JSON Schema: either a single goal shorthand or a
// tasks array for a parallel batch, plus an optional persona (the role the
// sub-agent runs as, design/22 §8-2) and an optional background flag.
// sub-agent runs as, design/22 §8-2), an optional background flag, an optional
// per-dispatch tool whitelist (design/05 §7) and an optional per-call timeout
// override.
func (t *SpawnTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"goal": spawnStringProp("Objective the sub-agent works toward alone. Required unless tasks is given."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona, which is the role the sub-agent actually runs as."),
"mode": spawnStringProp("Behavior mode for the role, e.g. engineer (optional)."),
"attach_info": spawnStringProp("Extra context or data to hand the sub-agent (optional)."),
"tasks": spawnTasksProp(),
"goal": spawnStringProp("Objective the sub-agent works toward alone. Required unless tasks is given."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona, which is the role the sub-agent actually runs as."),
"mode": spawnStringProp("Behavior mode for the role, e.g. engineer (optional)."),
"attach_info": spawnStringProp("Extra context or data to hand the sub-agent (optional)."),
"tasks": spawnTasksProp(),
"tool_whitelist": spawnWhitelistProp("Applies to every sub-agent of this dispatch when a task does not set its own."),
"timeout_seconds": spawnTimeoutProp(t.timeout),
"background": map[string]any{
"type": "boolean",
"description": "Dispatch and return immediately without waiting for the foreground window (optional, default false).",
@@ -143,7 +166,7 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
if t.sched == nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, ErrNoScheduler)
}
specs, err := spawnSpecs(m, loop.FlowOwnerFromContext(ctx))
specs, err := spawnSpecs(m, loop.FlowOwnerFromContext(ctx), t.timeout)
if err != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
}
@@ -286,8 +309,9 @@ func runningSpawnResult(ids []string, specs []AgentSpec) *SpawnResult {
// spawnSpecs decodes the tool arguments into one AgentSpec per sub-agent. A
// non-empty tasks array is a parallel batch and takes precedence over the single
// goal shorthand.
func spawnSpecs(m map[string]any, parentSession string) ([]AgentSpec, error) {
// goal shorthand. dftTimeout is the dispatch-layer default (WithSpawnTimeout);
// a non-positive value leaves the scheduler's per-agent timeout in charge.
func spawnSpecs(m map[string]any, parentSession string, dftTimeout time.Duration) ([]AgentSpec, error) {
tasks, err := taskList(m["tasks"])
if err != nil {
return nil, err
@@ -300,14 +324,27 @@ func spawnSpecs(m map[string]any, parentSession string) ([]AgentSpec, error) {
if serr != nil {
return nil, serr
}
// 派发层默认时限只补位:按调用未给出 timeout_seconds 时才生效。
if spec.Timeout == 0 {
spec.Timeout = dftTimeout
}
return []AgentSpec{spec}, nil
}
// 批次级的白名单是未自带名单任务的缺省值(缺省全开:未列出即继承全量)。
batchWhitelist := stringListArg(m, "tool_whitelist")
batchTimeout := timeoutArg(m, "timeout_seconds", dftTimeout)
specs := make([]AgentSpec, 0, len(tasks))
for i, task := range tasks {
spec, serr := specFromMap(task, parentSession)
if serr != nil {
return nil, fmt.Errorf("tasks[%d]: %w", i, serr)
}
if len(spec.ToolWhitelist) == 0 {
spec.ToolWhitelist = cloneStrings(batchWhitelist)
}
if spec.Timeout == 0 {
spec.Timeout = batchTimeout
}
specs = append(specs, spec)
}
return specs, nil
@@ -337,6 +374,8 @@ func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
Mode: strings.TrimSpace(stringArg(m, "mode")),
AttachInfo: strings.TrimSpace(stringArg(m, "attach_info")),
ParentSession: parentSession,
ToolWhitelist: stringListArg(m, "tool_whitelist"),
Timeout: timeoutArg(m, "timeout_seconds", 0),
}, nil
}
@@ -376,17 +415,49 @@ func spawnTasksProp() map[string]any {
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"goal": spawnStringProp("Objective this sub-agent works toward alone."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona."),
"mode": spawnStringProp("Behavior mode for the role (optional)."),
"attach_info": spawnStringProp("Extra context or data for this sub-agent (optional)."),
"goal": spawnStringProp("Objective this sub-agent works toward alone."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona."),
"mode": spawnStringProp("Behavior mode for the role (optional)."),
"attach_info": spawnStringProp("Extra context or data for this sub-agent (optional)."),
"tool_whitelist": spawnWhitelistProp("Only this sub-agent; empty means every registered tool."),
"timeout_seconds": spawnTimeoutProp(0),
},
"required": []any{"goal"},
},
}
}
// spawnWhitelistProp builds the tool_whitelist property schema (design/05 §7
// 裁决 02.4.1):缺省全开,显式列出则只放行所列。
func spawnWhitelistProp(desc string) map[string]any {
return map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
"description": "Tool names the sub-agent may invoke. Omitted or empty means every registered tool " +
"(the default, the main agent's full set); listing names restricts the sub-agent to exactly those " +
"tools. " + desc,
}
}
// spawnTimeoutProp builds the timeout_seconds property schema (design/05 §7
// 裁决 02.4.4 的按调用覆盖)。dft 非正时描述为「沿用派发默认」。
func spawnTimeoutProp(dft time.Duration) map[string]any {
desc := "Per-call timeout override in seconds for the sub-agent run (optional; omitted keeps the " +
"configured sub-agent default)."
if dft > 0 {
desc = fmt.Sprintf("Per-call timeout override in seconds for the sub-agent run (optional, default %s).", dft)
}
return map[string]any{
"type": "integer",
"minimum": minSpawnTimeoutSeconds,
"maximum": maxSpawnTimeoutSeconds,
"description": desc,
}
}
// spawnPersonaProp builds the persona property schema. It is the design/22 §8-2 派发
// switch: naming a role makes the sub-agent stand in for it (that role's
// instructions and memory scope) instead of working as the spawning session's role.