Files
Cyrene_v2/internal/runtime/orchestration/subagent.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

230 lines
6.6 KiB
Go

package orchestration
import (
"context"
"sync"
"time"
"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/model"
)
// SubAgent is a single spawned sub-agent instance. The identity fields (ID, Goal,
// RoleID, Mode, AttachInfo, ParentSession, Data, Context, WorkingMemory) are set
// once at Spawn and never mutated. The lifecycle fields (Status, Summary, Result,
// Err) are written by the sub-agent's background goroutine and read through the
// synchronized accessors below, so reading them before the sub-agent finishes is
// safe. A sub-agent is terminal exactly once its Done reports true; Join returns
// once every sub-agent is terminal, Wait once the named ids are.
//
// SubAgent is created by (*Scheduler).Spawn; it should not be built directly.
type SubAgent struct {
// ID uniquely identifies the sub-agent within its scheduler.
ID string
// Goal is the objective the sub-agent pursues.
Goal string
// RoleID is the bound role/persona.
RoleID string
// Mode is the bound behavior mode.
Mode string
// AttachInfo is the free-form attachment text supplied at spawn.
AttachInfo string
// ParentSession is the session whose flow graph owns this sub-agent's branch
// (blank when the branch is not visualized).
ParentSession string
// Data is the optional structured payload supplied at spawn.
Data any
// Context is the isolated conversation context. It is a deep copy of the
// AgentSpec.Context and is never shared with the front session.
Context []model.Message
// WorkingMemory is the sub-agent's own working-memory window, created fresh
// at Spawn so it never overlaps another sub-agent or the front session.
WorkingMemory *memory.WorkingMemory
mu sync.RWMutex
status Status
summary string
result *loop.Result
runErr error
started time.Time
finished time.Time
done chan struct{}
cancel context.CancelFunc
abortReq bool
doneClosed bool
// finishedCh is closed exactly once when the sub-agent reaches a terminal
// state, so Wait can block per-id without polling.
finishedCh chan struct{}
launchOnce sync.Once
finishOnce sync.Once
}
// Status returns the sub-agent's current lifecycle status.
func (s *SubAgent) Status() Status {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
// Summary returns the execution summary written when the sub-agent finalized:
// a completion summary on success, or a failure/abort reason otherwise. It is
// empty until the sub-agent is terminal.
func (s *SubAgent) Summary() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.summary
}
// Result returns the loop result on a successful run, or nil when the run failed
// or was aborted.
func (s *SubAgent) Result() *loop.Result {
s.mu.RLock()
defer s.mu.RUnlock()
return s.result
}
// Err returns the error that ended a failed or aborted run (nil on success).
func (s *SubAgent) Err() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.runErr
}
// StartedAt returns when the sub-agent transitioned to running (zero if it never
// started).
func (s *SubAgent) StartedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.started
}
// FinishedAt returns when the sub-agent reached a terminal state (zero if not
// terminal yet).
func (s *SubAgent) FinishedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.finished
}
// Done reports whether the sub-agent reached a terminal state.
func (s *SubAgent) Done() bool { return s.Status().Terminal() }
// waitDone returns the channel closed when the sub-agent reaches a terminal
// state. It is what (*Scheduler).Wait blocks on, per-id, without polling.
func (s *SubAgent) waitDone() <-chan struct{} { return s.finishedCh }
// markFinished closes the completion channel exactly once. It is called from
// finish under finishOnce, so a sub-agent wakes every Wait caller exactly once.
func (s *SubAgent) markFinished() { close(s.finishedCh) }
// Snapshot returns a point-in-time copy of the sub-agent's state, safe to read
// while the sub-agent is still running.
func (s *SubAgent) Snapshot() SubAgentResult {
s.mu.RLock()
defer s.mu.RUnlock()
snap := SubAgentResult{
ID: s.ID,
Goal: s.Goal,
RoleID: s.RoleID,
Mode: s.Mode,
AttachInfo: s.AttachInfo,
Status: s.status,
Summary: s.summary,
Err: s.runErr,
StartedAt: s.started,
FinishedAt: s.finished,
}
if s.result != nil {
snap.Text = s.result.Text
snap.Steps = s.result.Steps
}
return snap
}
// transitionRunning moves a pending sub-agent to running. It returns false when
// the sub-agent was already aborted (so it must not run) or is not pending.
func (s *SubAgent) transitionRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.abortReq || s.status != StatusPending {
return false
}
s.status = StatusRunning
s.started = time.Now()
return true
}
// progress returns the current status snapshot for a progress event.
func (s *SubAgent) progress() Progress {
s.mu.RLock()
defer s.mu.RUnlock()
return Progress{
AgentID: s.ID,
Goal: s.Goal,
RoleID: s.RoleID,
Mode: s.Mode,
Status: s.status,
}
}
// setOutcome records the terminal status, summary and error after the run ends.
// It is called at most once per sub-agent, guarded by finishOnce.
func (s *SubAgent) setOutcome(status Status, summary string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.status = status
s.summary = summary
s.runErr = err
s.finished = time.Now()
}
// setResult records the successful loop result.
func (s *SubAgent) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.result = res
}
// setCancel stores the run context's cancel function so Abort can interrupt the
// loop. If the sub-agent was already aborted (cancelRun raced ahead), the stored
// cancel is invoked immediately.
func (s *SubAgent) setCancel(c context.CancelFunc) {
s.mu.Lock()
s.cancel = c
aborted := s.abortReq
s.mu.Unlock()
if aborted {
c()
}
}
// cancelRun requests cancellation of the sub-agent. It is idempotent and safe for
// concurrent use: the done channel is closed once and the stored cancel (when set)
// is invoked once. A pending sub-agent unblocks from its parallel slot via done; a
// running sub-agent is interrupted via the cancel func.
func (s *SubAgent) cancelRun() {
s.mu.Lock()
s.abortReq = true
c := s.cancel
first := !s.doneClosed
s.doneClosed = true
s.mu.Unlock()
if first {
close(s.done)
}
if c != nil {
c()
}
}
// aborted reports whether a cancellation was requested for the sub-agent.
func (s *SubAgent) aborted() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.abortReq
}