Files
Cyrene_v2/internal/runtime/orchestration/spec.go
T
AskaEth 7e7f53bb41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 旋钮管道)。
2026-10-04 11:30:39 +08:00

242 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}