执行方案阶段 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 旋钮管道)。
242 lines
9.6 KiB
Go
242 lines
9.6 KiB
Go
package orchestration
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// Status is the lifecycle state of a spawned sub-agent.
|
||
//
|
||
// The lifecycle is pending -> running -> one of the terminal states
|
||
// (done, failed, aborted). A sub-agent is terminal exactly when Terminal
|
||
// reports true.
|
||
type Status string
|
||
|
||
const (
|
||
// StatusPending means the sub-agent was created but has not started.
|
||
StatusPending Status = "pending"
|
||
// StatusRunning means the sub-agent's loop is executing.
|
||
StatusRunning Status = "running"
|
||
// StatusDone means the loop completed and produced a result.
|
||
StatusDone Status = "done"
|
||
// StatusFailed means the loop returned an error.
|
||
StatusFailed Status = "failed"
|
||
// StatusAborted means the sub-agent was canceled or timed out.
|
||
StatusAborted Status = "aborted"
|
||
)
|
||
|
||
// Terminal reports whether s is a final state. Pending and running are not
|
||
// terminal; every sub-agent ends in one of the three terminal states.
|
||
func (s Status) Terminal() bool {
|
||
return s == StatusDone || s == StatusFailed || s == StatusAborted
|
||
}
|
||
|
||
// AgentSpec describes a sub-agent to spawn. Goal is required; the rest are
|
||
// optional and bound onto the sub-agent for the upper layer to interpret.
|
||
type AgentSpec struct {
|
||
// Goal is the objective the sub-agent works toward. It is required.
|
||
Goal string
|
||
// RoleID selects the role/persona the sub-agent is bound to.
|
||
RoleID string
|
||
// PersonaID is the role the sub-agent actually runs as (design/22 §8-2): it
|
||
// answers with that role's instructions and reads/writes that role's memory
|
||
// scope — "她以该角色身份临时替班". Spawn stamps it onto the sub-agent run's
|
||
// session env, which is the single input the loop resolves the prompt, the
|
||
// memory namespace, the emotion and the thinking knobs from, so the four can
|
||
// never disagree. Blank keeps the pre-existing behavior byte for byte: the
|
||
// sub-agent inherits the spawning turn's env, i.e. the parent session's role.
|
||
// The id must be one this process can run with its own agent
|
||
// (*Scheduler).PersonaKnown; Spawn rejects an unknown one with
|
||
// ErrUnknownPersona instead of silently falling back to the default role.
|
||
PersonaID string
|
||
// Mode selects the behavior mode (e.g. "engineer") for the role.
|
||
Mode string
|
||
// Context is the initial, isolated conversation context. Spawn deep-copies
|
||
// it, so later mutation by the caller does not affect the sub-agent.
|
||
Context []model.Message
|
||
// AttachInfo is free-form attachment/injection text supplied at spawn.
|
||
AttachInfo string
|
||
// ParentSession is the session whose live flow graph owns the branch, so the
|
||
// sub-agent's span and its inner steps render as a swimlane of the spawning
|
||
// session's graph instead of as a graph of their own (design/19 §2). The
|
||
// spawning tool takes it from loop.FlowOwnerFromContext. Blank disables the
|
||
// flow attribution; it never affects which session the sub-agent runs in, so
|
||
// its checkpoint, memory and stream isolation are unchanged.
|
||
ParentSession string
|
||
// Data carries an optional structured payload for the caller's use.
|
||
Data any
|
||
// ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1):
|
||
// 缺省(nil/空)=继承主 agent 的全量工具集;显式列出=只放行所列工具,
|
||
// 名单外的调用在工具循环层被否决并以软结果回灌。它只收缩能力面,绝不扩展。
|
||
ToolWhitelist []string
|
||
// Timeout 覆盖该子代理的运行时限(design/05 §7 裁决 02.4.4 的按调用覆盖)。
|
||
// 非正时回退到调度器的 Options.PerAgentTimeout。
|
||
Timeout time.Duration
|
||
}
|
||
|
||
// Progress event names published on the agent-to-agent bus via the Scheduler's
|
||
// comm probe. External subscribers can match on these to drive a monitoring
|
||
// panel (DevDocs/design/05-子代理.md §9).
|
||
const (
|
||
// EventDispatched is published when a sub-agent is created.
|
||
EventDispatched = "subagent.dispatched"
|
||
// EventStarted is published when a sub-agent begins running.
|
||
EventStarted = "subagent.started"
|
||
// EventDone is published when a sub-agent finishes successfully.
|
||
EventDone = "subagent.done"
|
||
// EventFailed is published when a sub-agent fails.
|
||
EventFailed = "subagent.failed"
|
||
// EventAborted is published when a sub-agent is canceled or times out.
|
||
EventAborted = "subagent.aborted"
|
||
)
|
||
|
||
// Progress is the status snapshot carried by a progress event's Data payload.
|
||
type Progress struct {
|
||
// AgentID is the spawned sub-agent id.
|
||
AgentID string
|
||
// Goal is the sub-agent's objective.
|
||
Goal string
|
||
// RoleID is the bound role/persona.
|
||
RoleID string
|
||
// Mode is the bound behavior mode.
|
||
Mode string
|
||
// Status is the sub-agent's status at publish time.
|
||
Status Status
|
||
}
|
||
|
||
// Sentinel errors returned by the Scheduler. They are wrapped with %w at call
|
||
// sites so callers can test with errors.Is.
|
||
var (
|
||
// ErrMissingGoal is returned by Spawn when the spec Goal is blank.
|
||
ErrMissingGoal = errors.New("orchestration: missing goal")
|
||
|
||
// ErrNoRunner is returned when a sub-agent runs with no loop runner
|
||
// configured (Options.Loop was nil).
|
||
ErrNoRunner = errors.New("orchestration: no loop runner configured")
|
||
|
||
// ErrNoApprover is returned by Approve when no *audit.Approver was injected.
|
||
ErrNoApprover = errors.New("orchestration: no approver configured")
|
||
|
||
// ErrNoMemory is returned by SinkMemory when no *memory.Store was injected.
|
||
ErrNoMemory = errors.New("orchestration: no memory store configured")
|
||
|
||
// ErrAgentNotFound is returned by Abort when the sub-agent id is unknown.
|
||
ErrAgentNotFound = errors.New("orchestration: sub-agent not found")
|
||
|
||
// ErrUnknownPersona is returned by Spawn when a spec names a role this process
|
||
// has no per-role agent for (design/22 §8-2). It is a rejection, not a
|
||
// fallback: running the sub-agent with the default role's prompt while the
|
||
// caller asked for another role would silently defeat the request.
|
||
ErrUnknownPersona = errors.New("orchestration: unknown persona")
|
||
|
||
// ErrNoScheduler is returned by the spawn tool when it was built without a
|
||
// scheduler (a wiring mistake, reported instead of panicking).
|
||
ErrNoScheduler = errors.New("orchestration: no scheduler configured")
|
||
)
|
||
|
||
// cloneMessages returns a deep copy of msgs: the message slice and each message's
|
||
// content parts are copied so the result shares no memory with the input. It is
|
||
// used at Spawn to give each sub-agent an isolated context. A nil input yields
|
||
// nil; a non-nil empty input yields a non-nil empty slice.
|
||
func cloneMessages(msgs []model.Message) []model.Message {
|
||
if msgs == nil {
|
||
return nil
|
||
}
|
||
out := make([]model.Message, len(msgs))
|
||
for i, m := range msgs {
|
||
if m.Content != nil {
|
||
parts := make([]model.Content, len(m.Content))
|
||
copy(parts, m.Content)
|
||
m.Content = parts
|
||
}
|
||
out[i] = m
|
||
}
|
||
return out
|
||
}
|
||
|
||
// compactText shortens s to at most n runes for inclusion in a summary, appending
|
||
// an ellipsis when it was truncated. It never splits a multi-byte rune.
|
||
func compactText(s string, n int) string {
|
||
s = strings.TrimSpace(s)
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// taskContext 返回子代理的隔离上下文:调用方显式给出的消息在前(深拷贝),
|
||
// 任务本身作为最后一条 user 消息注入。裁决 2026-10-04:goal 正文 +
|
||
// attach_info/data 作为同一条 user 消息内的独立段落;system 只承载角色与
|
||
// 安全约束(循环前置的 agent 指令),绝不把任务塞进 system——否则子代理
|
||
// 的模型上下文里没有任何一轮用户消息,任务一个字都到不了模型。
|
||
func taskContext(spec AgentSpec) []model.Message {
|
||
return append(cloneMessages(spec.Context), taskUserMessage(spec))
|
||
}
|
||
|
||
// taskUserMessage 把任务渲染为一条 user 消息(裁决 2026-10-04):
|
||
// [任务目标] 段固定存在(Spawn 已保证 goal 非空),[附加信息] 与
|
||
// [任务数据] 段仅在对应字段非空时出现;Data 以 JSON 编码,编码失败时
|
||
// 整段跳过——宁可少一段噪声,不注入一段坏文本。
|
||
func taskUserMessage(spec AgentSpec) model.Message {
|
||
var b strings.Builder
|
||
b.WriteString("[任务目标]\n")
|
||
b.WriteString(spec.Goal)
|
||
if spec.AttachInfo != "" {
|
||
b.WriteString("\n\n[附加信息]\n")
|
||
b.WriteString(spec.AttachInfo)
|
||
}
|
||
if spec.Data != nil {
|
||
if data, err := json.Marshal(spec.Data); err == nil {
|
||
b.WriteString("\n\n[任务数据]\n")
|
||
b.Write(data)
|
||
}
|
||
}
|
||
return model.TextMessage(model.RoleUser, b.String())
|
||
}
|
||
|
||
// cloneStrings 返回 names 的拷贝,使子代理持有的白名单与调用方的切片
|
||
// 不共享内存。nil 进 nil 出。
|
||
func cloneStrings(names []string) []string {
|
||
if names == nil {
|
||
return nil
|
||
}
|
||
return append([]string(nil), names...)
|
||
}
|
||
|
||
// stringListArg 读取字符串数组参数:容忍缺失/非数组/非字符串元素,
|
||
// 逐项去空白并丢弃空项,使名单恒为「显式列出的工具名」。缺失返回 nil(全开)。
|
||
func stringListArg(m map[string]any, key string) []string {
|
||
raw, ok := m[key].([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(raw))
|
||
for _, v := range raw {
|
||
s, _ := v.(string)
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// timeoutArg 读取 timeout_seconds 按调用覆盖(JSON 解码为 float64):
|
||
// 缺失/非数/越界(<1s 或 >24h)视为未提供并沿用 dft;越界值不静默夹取——
|
||
// 回退到默认而不是替模型猜一个邻近值。
|
||
func timeoutArg(m map[string]any, key string, dft time.Duration) time.Duration {
|
||
f, ok := m[key].(float64)
|
||
if !ok {
|
||
return dft
|
||
}
|
||
sec := int64(f)
|
||
if sec < minSpawnTimeoutSeconds || sec > maxSpawnTimeoutSeconds {
|
||
return dft
|
||
}
|
||
return time.Duration(sec) * time.Second
|
||
}
|