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 待确认
This commit is contained in:
AskaEth committed 2026-10-03 19:39:38 +08:00
1 parent 3f79159fbd
commit de1fa83b79
25 files changed
+2173 -97

No files matched your search

+59 -63
View File
@@ -2,11 +2,8 @@ package orchestration
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
@@ -14,7 +11,6 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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"
@@ -47,6 +43,7 @@ type Scheduler struct {
comm *comm.Probe
memory *memory.Store
tracer *trace.Tracer
flow FlowSpanSink
approver *audit.Approver
log *slog.Logger
agentID string
@@ -92,6 +89,7 @@ func New(opts Options) *Scheduler {
bus: opts.Bus,
memory: opts.Memory,
tracer: opts.Tracer,
flow: opts.Flow,
approver: opts.Approver,
log: sourcedLog(opts.Logger),
agentID: agentID,
@@ -136,11 +134,13 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
RoleID: spec.RoleID,
Mode: spec.Mode,
AttachInfo: spec.AttachInfo,
ParentSession: spec.ParentSession,
Data: spec.Data,
Context: cloneMessages(spec.Context),
WorkingMemory: memory.NewWorkingMemory(0),
status: StatusPending,
done: make(chan struct{}),
finishedCh: make(chan struct{}),
}
s.mu.Lock()
@@ -318,17 +318,24 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
s.publish(EventStarted, sub.progress())
runCtx, cancel := s.subCtx(ctx)
// A sub-agent must not leak its inner monologue into the caller's chat stream
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台): muting suppresses the
// UI stream/capacity broadcast while progress still reaches the frontend over
// the bus and the flow lane. The flow branch attributes every span this Run
// emits to the spawning session's graph instead of opening a second graph.
runCtx = loop.WithMutedBroadcast(runCtx)
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
runCtx, span := s.subSpan(runCtx, sub)
sub.setCancel(cancel)
defer cancel()
res, err := s.runner.Run(runCtx, sub.ID, sub.Context)
if err != nil {
s.spanErr(span, err)
s.spanErr(sub, span, err)
s.abortOrFail(sub, err)
return
}
s.spanOK(span, res)
s.spanOK(sub, span, res)
sub.setResult(res)
s.finish(sub, StatusDone, doneSummary(res), nil)
}
@@ -354,6 +361,7 @@ func (s *Scheduler) finish(sub *SubAgent, status Status, summary string, err err
sub.finishOnce.Do(func() {
sub.setOutcome(status, summary, err)
s.publish(eventFor(status), sub.progress())
sub.markFinished()
s.mu.Lock()
s.active--
@@ -373,33 +381,71 @@ func (s *Scheduler) subCtx(parent context.Context) (context.Context, context.Can
}
// subSpan starts a sub-agent span when a tracer is configured, chaining it as a
// child of any span already in ctx. It returns the ctx carrying the span and the
// span itself (nil when the tracer is nil).
// child of any span already in ctx. When the sub-agent is attributed to a flow
// session (AgentSpec.ParentSession) the span is also folded into that session's
// live graph as a subagent lane root, so the branch renders inside the graph of
// the session that spawned it rather than in a graph of its own (design/19 §2). It
// returns the ctx carrying the span and the span itself (nil when the tracer is
// nil).
func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context, *trace.Span) {
if s.tracer == nil {
return ctx, nil
}
return s.tracer.StartSpan(ctx, "subagent."+sub.ID, trace.SpanKindSubagent)
sctx, span := s.tracer.StartSpan(ctx, "subagent."+sub.ID, trace.SpanKindSubagent)
if sess := sub.ParentSession; sess != "" && s.flow != nil {
_ = span.SetProp("session", sess)
s.flow.SpanStarted(sess, span)
}
return sctx, span
}
// spanOK finishes a sub-agent span as ok, recording the result text.
func (s *Scheduler) spanOK(span *trace.Span, res *loop.Result) {
// subagentEventDone is the log event type recorded on a sub-agent span when its
// run settles, so the dashboard node carries the outcome summary (design/19 §1
// 节点实时日志).
const subagentEventDone = "subagent.done"
// spanOK finishes a sub-agent span as ok, recording the result text and a
// desensitized outcome event, then folds the closed span into the parent flow.
func (s *Scheduler) spanOK(sub *SubAgent, span *trace.Span, res *loop.Result) {
if span == nil {
return
}
if res != nil {
_ = span.SetProp("text", res.Text)
_ = span.Record(trace.Event{Type: subagentEventDone, Props: map[string]any{
"status": string(StatusDone),
"steps": res.Steps,
"summary": compactText(res.Text, summaryTextMax),
}})
}
span.Finish(trace.SpanStatusOK)
s.foldSpanFinish(sub, span)
}
// spanErr finishes a sub-agent span as an error, recording the cause.
func (s *Scheduler) spanErr(span *trace.Span, err error) {
// spanErr finishes a sub-agent span as an error, recording the cause, then folds
// the closed span into the parent flow.
func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
if span == nil {
return
}
_ = span.SetProp("error", err.Error())
_ = span.Record(trace.Event{Type: subagentEventDone, Props: map[string]any{
"status": string(StatusFailed),
"error": compactText(err.Error(), summaryTextMax),
}})
span.Finish(trace.SpanStatusError)
s.foldSpanFinish(sub, span)
}
// foldSpanFinish folds a just-closed sub-agent span into its parent session's
// flow graph, flipping the lane root to its final status. The span already
// carries the parent session as its "session" prop, so the publisher knows which
// graph owns it.
func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
if span == nil || s.flow == nil || sub.ParentSession == "" {
return
}
s.flow.SpanFinished(sub.ParentSession, span)
}
// publish broadcasts a progress event over the bus. It is a no-op when no comm
@@ -420,33 +466,6 @@ func (s *Scheduler) nextID() string {
return fmt.Sprintf("%s-%d", s.prefix, s.seq.Add(1))
}
// merge aggregates every registered sub-agent into a MergedResult. It is called by
// Join once all sub-agents are terminal.
func (s *Scheduler) merge() *MergedResult {
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
for _, sub := range s.agents {
subs = append(subs, sub)
}
s.mu.Unlock()
res := &MergedResult{Total: len(subs), Agents: make([]SubAgentResult, 0, len(subs))}
for _, sub := range subs {
snap := sub.Snapshot()
res.Agents = append(res.Agents, snap)
switch snap.Status {
case StatusDone:
res.Succeeded++
case StatusFailed:
res.Failed++
case StatusAborted:
res.Aborted++
}
}
res.Summary = summarizeMerged(res)
return res
}
// eventFor maps a terminal status to its progress event name.
func eventFor(status Status) string {
switch status {
@@ -469,26 +488,3 @@ func wrapLoop(l *loop.Loop) runner {
}
return l
}
// sourcedLog returns the base logger tagged with source "orchestration",
// replacing a nil logger with a discard one so the scheduler can always log.
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "orchestration")
}
// prefixBytes is the number of random bytes used to build the sub-agent id
// prefix.
const prefixBytes = 4
// randPrefix returns a short random prefix for sub-agent ids, falling back to
// "sub" if the system entropy source is unavailable.
func randPrefix() string {
var b [prefixBytes]byte
if _, err := rand.Read(b[:]); err != nil {
return "sub"
}
return "sub-" + hex.EncodeToString(b[:])
}