Files
Cyrene_v2/internal/runtime/orchestration/spec.go
T
AskaEth de1fa83b79 feat(orchestration,loop,flow): 接线子代理编排 — 派发入口 + 分支归父会话 + 节点日志(含脱敏)
修的是「文档说 ✅、生产不可达」:此前 Scheduler.Spawn 只有测试调用方、无 bus 入口、无派发工具;Span.Record 无生产调用方 → 泳道画不出、节点日志恒空。
- 入口:subagent_spawn 工具(internal/runtime/orchestration/spawntool.go,同包便于用真 Scheduler/loop/Tracer/Flow 做集成测试;注册在 cmd/cyrene/subagent_tool.go 照 terminal_tool 范式)。默认 owner-only,不设 AllowInGroup/AllowInStranger;安全仍由 restrictSchemas + applyToolSecurityGate 双层保证,未绕代审
- 父会话归属:AgentSpec.ParentSession(请求语义)→ Scheduler 转运行期 ctx 标记 loop.WithFlowBranch → 分支 span 以父 session 发布;**allow 谓词一行未改**;单图语义靠「分支 Run 不调 StartRound/EndRound」+ FlowPublisher 不新建图;未归属 spawn 一张图都不发(有测试锁)
- 前台最多等 2s(对齐 task.autoBackgroundThreshold)→ 合并结果作工具结果;转后台则广播 bus subagent.result(design/05 §9 回填 hook);新增 Scheduler.Wait(ids) 按批等待(Join 会串并发批次,故不重用)
- 子代理跑在 WithoutCancel(ctx):保留 span 归属、不被工具超时/回合结束顺手杀掉;生命周期由 PerAgentTimeout + CancelAll 约束;内部 WithMutedBroadcast 静音(不进前台 stream)
- 节点日志:loop/spanlog.go 新增 llm.done / tool.done / subagent.done;工具参数**只记键名不记值**;仅步骤真正结束才追加(跳过/被拒路径不发,不伪造)
- 脱敏(红线):新增 trace/redact.go(形状规则 sk-/Bearer/JWT/key:value + 键名规则 api_key/token/authorization/password…,递归嵌套、不改调用方事件),graph.go 发布前必经;**顺带修掉一个既有泄漏**:result_summary prop 原先未脱敏(工具结果里的密钥会进图),由图级变异测试抓出
- 测试:新增 22 个(工具级 / 图级 / 日志级),含 4 次变异验证(去掉 WithFlowBranch、去掉 record*、还原未脱敏、去掉静音 各自 FAIL);-race 过 orchestration+trace+loop
- 已知限制:真实模型端到端未验(无凭据/不起服务);工具白名单是 design gap 未做;subagent.result 暂无消费者(design/15 未实现);后台子代理跨轮次边界迟到 span 会丢泳道根;PerAgentTimeout=10min 待确认
2026-10-03 19:39:38 +08:00

145 lines
5.3 KiB
Go

package orchestration
import (
"errors"
"strings"
"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
// 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
}
// 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")
// 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]) + "…"
}