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:
1 parent
3f79159fbd
commit
de1fa83b79
25 files changed
+2173
-97
No files matched your search
@@ -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[:])
|
||||
}
|
||||
Reference in new issue
Block a user