执行方案阶段 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 旋钮管道)。
135 lines
5.4 KiB
Go
135 lines
5.4 KiB
Go
package orchestration
|
||
|
||
import (
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// Defaults for the orchestration layer.
|
||
const (
|
||
// DefaultMaxParallel is the maximum number of sub-agents that run at once
|
||
// when Options.MaxParallel is non-positive.
|
||
DefaultMaxParallel = 4
|
||
|
||
// defaultAgentID is the comm probe identity used when Options.AgentID is
|
||
// blank and a probe must be built from Bus.
|
||
defaultAgentID = "orchestrator"
|
||
)
|
||
|
||
// Options configures a Scheduler built by New. All fields are optional; each nil
|
||
// dependency degrades gracefully (a missing Loop makes sub-agents fail fast, a
|
||
// missing Comm skips progress events, a missing Approver makes Approve error,
|
||
// and so on).
|
||
type Options struct {
|
||
// MaxParallel caps how many sub-agents execute concurrently. Excess sub-agents
|
||
// queue until a slot frees. Non-positive selects DefaultMaxParallel (4).
|
||
MaxParallel int
|
||
|
||
// PerAgentTimeout bounds every sub-agent's run. When it elapses the sub-agent
|
||
// is aborted (status aborted). Non-positive means no timeout.
|
||
PerAgentTimeout time.Duration
|
||
|
||
// Loop is the execution template: a *loop.Loop used to run every sub-agent.
|
||
// It must be safe for concurrent Run calls (as loop.Loop documents); the
|
||
// template's agent/tools/guardrails/persona apply to every sub-agent. When
|
||
// nil, sub-agents fail fast with ErrNoRunner.
|
||
Loop *loop.Loop
|
||
|
||
// Bus is the in-process event bus used for progress events and, when Comm is
|
||
// nil, to build a default comm probe. When nil no events are published.
|
||
Bus bus.Bus
|
||
|
||
// Comm is the comm probe used to publish progress events. When nil, a probe
|
||
// bound to AgentID is built from Bus (when Bus is non-nil).
|
||
Comm *comm.Probe
|
||
|
||
// Memory is the long-term memory store used by SinkMemory. Optional.
|
||
Memory *memory.Store
|
||
|
||
// Tracer emits a per-sub-agent span (kind subagent) around each run. Optional.
|
||
Tracer *trace.Tracer
|
||
|
||
// Flow, when non-nil, receives the sub-agent span transitions so a spawned
|
||
// branch appears in the flow graph of the session named by
|
||
// AgentSpec.ParentSession (design/19 §2). *loop.FlowPublisher is the
|
||
// production implementation. Optional: a nil sink leaves sub-agent branches
|
||
// unvisualized (the sub-agent's own inner spans still fold into the parent
|
||
// graph through the loop's flow branch).
|
||
Flow FlowSpanSink
|
||
|
||
// Approver records sensitive-operation review-and-approve (代审) triggered by
|
||
// Approve. Policy judgement belongs to the upper layer. Optional.
|
||
Approver *audit.Approver
|
||
|
||
// PersonaExists reports whether a role id can run with its own instructions,
|
||
// i.e. whether the per-role agent pool behind Loop (loop.Options.Agents) has an
|
||
// entry for it (design/22 §8-2). It gates AgentSpec.PersonaID: a dispatch naming
|
||
// an id this returns false for is rejected with ErrUnknownPersona rather than
|
||
// running as the default role's prompt. Nil means "this deployment knows no
|
||
// role", so a dispatch that names a role is rejected too — deciding nothing
|
||
// silently is the point. Omitting the persona is unaffected by this.
|
||
PersonaExists func(personaID string) bool
|
||
|
||
// Logger is the base logger. Nil is replaced with a discard logger tagged with
|
||
// source "orchestration".
|
||
Logger *slog.Logger
|
||
|
||
// AgentID is the comm probe identity. When blank and a probe must be built
|
||
// from Bus, "orchestrator" is used.
|
||
AgentID string
|
||
}
|
||
|
||
// FlowSpanSink folds a span transition into a session's live flow graph so the
|
||
// realtime visualization shows the work in flight (design/19 §1). *loop.FlowPublisher
|
||
// is the production implementation; tests substitute a capturing fake. The
|
||
// interface keeps orchestration from depending on the concrete publisher beyond
|
||
// the loop template it already takes.
|
||
type FlowSpanSink interface {
|
||
// SpanStarted registers a running placeholder node for a span that just began.
|
||
SpanStarted(sessionID string, sp *trace.Span)
|
||
// SpanFinished folds a just-finished span into the session graph.
|
||
SpanFinished(sessionID string, sp *trace.Span)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// scopedRunner picks the runner that executes sub: without a tool whitelist the
|
||
// configured template runs as-is(缺省全开,继承主 agent 全量工具集);with one,
|
||
// a *loop.Loop template is derived through WithToolWhitelist so only the listed
|
||
// tools pass(design/05 §7 裁决 02.4.1). A non-loop runner (tests inject mocks)
|
||
// is returned unchanged — the whitelist still rides the run context for it to
|
||
// observe via loop.ToolWhitelistFrom.
|
||
func (s *Scheduler) scopedRunner(sub *SubAgent) runner {
|
||
if len(sub.ToolWhitelist) == 0 {
|
||
return s.runner
|
||
}
|
||
if lp, ok := s.runner.(*loop.Loop); ok {
|
||
return lp.WithToolWhitelist(sub.ToolWhitelist)
|
||
}
|
||
return s.runner
|
||
}
|
||
|
||
// effectiveTimeout resolves the deadline one sub-agent runs under: a positive
|
||
// per-call override wins; otherwise the scheduler's PerAgentTimeout applies
|
||
// (non-positive = no deadline).
|
||
func effectiveTimeout(override, schedulerDefault time.Duration) time.Duration {
|
||
if override > 0 {
|
||
return override
|
||
}
|
||
return schedulerDefault
|
||
}
|