修的是「文档说 ✅、生产不可达」:此前 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 待确认
149 lines
4.8 KiB
Go
149 lines
4.8 KiB
Go
package loop
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
|
)
|
|
|
|
// graphUpdateSubject is the bus subject the FlowPublisher publishes each round's
|
|
// live flow graph on. The gateway hub subscribes to ws.> and relays it as the
|
|
// WS event type graph_update (design/19 §1).
|
|
const graphUpdateSubject = "ws.graph_update"
|
|
|
|
// allowedSessions is the predicate signature used to gate which sessions publish
|
|
// flow graphs (the owner's web sessions), so IM/stranger/sub-agent turns never
|
|
// leak into the admin panel. A nil predicate allows every session.
|
|
type allowedSessions func(sessionID string) bool
|
|
|
|
// FlowPublisher accumulates a per-session flow graph from the loop's trace spans
|
|
// and publishes ws.graph_update as each span starts/finishes, giving the frontend
|
|
// a realtime input→think→tool→output graph (design/19 §1). It is nil-tolerant
|
|
// (a nil publisher is a no-op) and safe for concurrent use; session graphs are
|
|
// serialized under one mutex because parallel tool spans finish concurrently.
|
|
//
|
|
// Ownership: one session owns one live round. A Run that declares itself a branch
|
|
// of another session (loop.WithFlowBranch) never calls StartRound/EndRound and
|
|
// publishes its spans under the owning session instead, so a sub-agent renders as
|
|
// a swimlane of the parent's graph rather than as a second graph (design/19 §2).
|
|
type FlowPublisher struct {
|
|
mu sync.Mutex
|
|
publish func(subject string, payload any)
|
|
allow allowedSessions
|
|
graphs map[string]*trace.RunGraph
|
|
rounds map[string]int
|
|
}
|
|
|
|
// NewFlowPublisher builds a FlowPublisher. publish, when non-nil, is called with
|
|
// graphUpdateSubject and a *trace.FlowGraph snapshot on every span transition.
|
|
// allow, when non-nil, gates which session ids are published (returning false
|
|
// drops the session entirely). Both may be nil (a nil publish is a no-op).
|
|
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool) *FlowPublisher {
|
|
return &FlowPublisher{
|
|
publish: publish,
|
|
allow: allow,
|
|
graphs: make(map[string]*trace.RunGraph),
|
|
rounds: make(map[string]int),
|
|
}
|
|
}
|
|
|
|
// StartRound begins a fresh graph for sessionID, seeding an input node from the
|
|
// user text and publishing the initial frame. It resets the session's previous
|
|
// round, so each user turn is its own graph.
|
|
func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
|
if sessionID == "" || !p.allowed(sessionID) {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
round := p.rounds[sessionID] + 1
|
|
g := trace.NewRunGraph(sessionID)
|
|
g.Start(round, time.Now())
|
|
g.SeedInput(inputText)
|
|
p.graphs[sessionID] = g
|
|
p.rounds[sessionID] = round
|
|
p.publishLocked(g)
|
|
}
|
|
|
|
// EndRound finalizes the round by seeding an output node from the reply and
|
|
// publishing the final frame. It keeps the graph around so a client that misses
|
|
// the final frame can replay the last snapshot on refresh.
|
|
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
|
if sessionID == "" || !p.allowed(sessionID) {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
g := p.graphs[sessionID]
|
|
if g == nil {
|
|
return
|
|
}
|
|
g.SeedOutput(outputText)
|
|
p.publishLocked(g)
|
|
}
|
|
|
|
// SpanStarted records a running placeholder node for a span that just began.
|
|
// State-transition spans are omitted by the graph builder.
|
|
func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
|
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
g := p.graphs[sessionID]
|
|
if g == nil {
|
|
return
|
|
}
|
|
if g.AddSpanStart(sp.ID, sp.Name, sp.Kind, sp.ParentID, sp.StartedAt) {
|
|
p.publishLocked(g)
|
|
}
|
|
}
|
|
|
|
// SpanFinished folds a just-finished span into the session graph as a final node
|
|
// (status done/error) and publishes. It must be called after the span's Finish.
|
|
func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
|
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
g := p.graphs[sessionID]
|
|
if g == nil {
|
|
return
|
|
}
|
|
g.AddSpan(sp.Snapshot())
|
|
p.publishLocked(g)
|
|
}
|
|
|
|
// publishLocked publishes a snapshot of g. Callers must hold p.mu.
|
|
func (p *FlowPublisher) publishLocked(g *trace.RunGraph) {
|
|
if p.publish == nil {
|
|
return
|
|
}
|
|
p.publish(graphUpdateSubject, g.Snapshot())
|
|
}
|
|
|
|
// allowed reports whether a session's graphs should be published.
|
|
func (p *FlowPublisher) allowed(sessionID string) bool {
|
|
if p.allow == nil {
|
|
return true
|
|
}
|
|
return p.allow(sessionID)
|
|
}
|
|
|
|
// lastUserText returns the text of the newest user message in messages, used to
|
|
// label the round's input node. It returns "" when no user text is present.
|
|
func lastUserText(messages []model.Message) string {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].Role != model.RoleUser {
|
|
continue
|
|
}
|
|
if t := messageText(messages[i]); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|