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

+22 -8
View File
@@ -7,15 +7,27 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// defaultSubagentTimeout bounds one sub-agent's whole run. DevDocs/design/05-子代理.md
// §7 requires a timeout and an anti-runaway bound; the scheduler already
// implements both (PerAgentTimeout, Abort/CancelAll), so production must supply a
// value or the per-agent timeout stays inert (the scheduler's zero means "no
// timeout"). The number is deliberately generous — a sub-agent runs up to 8 tool
// rounds like any loop — and is a single knob to revisit when design/05 fixes a
// concrete figure.
// defaultSubagentTimeout bounds one sub-agent's whole run when nothing else
// configures it. DevDocs/design/05-子代理.md §7 requires a timeout and an
// anti-runaway bound; the scheduler already implements both (PerAgentTimeout,
// Abort/CancelAll), so production must supply a value or the per-agent timeout
// stays inert (the scheduler's zero means "no timeout"). The number is
// deliberately generous — a sub-agent runs up to 8 tool rounds like any loop —
// and is the fallback behind SUBAGENT_TIMEOUT_SEC (裁决 02.4.4: 默认 10min,
// 可配置;subagentToolTimeout resolves the effective value).
const defaultSubagentTimeout = 10 * time.Minute
// subagentToolTimeout resolves the dispatch tool's default sub-agent timeout:
// SUBAGENT_TIMEOUT_SEC wins when positive, otherwise the 10-minute code default
// applies (design/05 §7 裁决 02.4.4). A per-dispatch timeout_seconds argument
// still overrides this per call at the tool layer.
func (a *app) subagentToolTimeout() time.Duration {
if a.cfg != nil && a.cfg.Subagent.TimeoutSec > 0 {
return time.Duration(a.cfg.Subagent.TimeoutSec) * time.Second
}
return defaultSubagentTimeout
}
// wireSubagentTool registers the sub-agent dispatch tool with the dispatcher, so
// the agent can finally reach the orchestration layer at runtime (before this,
// (*orchestration.Scheduler).Spawn had no production caller and design/05 was
@@ -33,7 +45,9 @@ func (a *app) wireSubagentTool() {
log.Warn("sub-agent tool skipped: no scheduler")
return
}
spawner := orchestration.NewSpawnTool(a.scheduler, orchestration.WithSpawnLogger(a.log))
spawner := orchestration.NewSpawnTool(a.scheduler,
orchestration.WithSpawnLogger(a.log),
orchestration.WithSpawnTimeout(a.subagentToolTimeout()))
if err := a.dispatcher.Register(spawner); err != nil {
log.Warn("sub-agent tool register skipped", "error", err)
return
+24
View File
@@ -3,9 +3,11 @@ package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -105,3 +107,25 @@ func TestPersonaExistsMirrorsAgentPool(t *testing.T) {
t.Fatal("a persona-less dispatch was rejected by an empty pool; want it unaffected")
}
}
// TestSubagentToolTimeoutFromConfig 锁定组合根的配置接法(design/05 §7 裁决
// 02.4.4):未配置时回落 10min 代码默认,SUBAGENT_TIMEOUT_SEC 生效时被真实
// 采用——派发工具拿到的是配置值而不是常量。
func TestSubagentToolTimeoutFromConfig(t *testing.T) {
a := &app{log: testLog()}
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v, want the 10min code default", got)
}
a.cfg = &config.Config{}
a.cfg.Subagent.TimeoutSec = 90
if got := a.subagentToolTimeout(); got != 90*time.Second {
t.Fatalf("subagentToolTimeout() = %v, want 90s from config", got)
}
// 非法(非正)配置不缩小时限:保持代码默认而不是把子代理变成「立即超时」。
a.cfg.Subagent.TimeoutSec = -5
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v for a non-positive config, want the code default", got)
}
}