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
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// defaultSubagentTimeout bounds one sub-agent's whole run. DevDocs/design/05-子代理.md
|
||||
// §7 requires a timeout and an anti-runaway bound; the scheduler already
|
||||
// implements both (PerAgentTimeout, Abort/CancelAll), so production must supply a
|
||||
// value or the per-agent timeout stays inert (the scheduler's zero means "no
|
||||
// timeout"). The number is deliberately generous — a sub-agent runs up to 8 tool
|
||||
// rounds like any loop — and is a single knob to revisit when design/05 fixes a
|
||||
// concrete figure.
|
||||
const defaultSubagentTimeout = 10 * time.Minute
|
||||
|
||||
// wireSubagentTool registers the sub-agent dispatch tool with the dispatcher, so
|
||||
// the agent can finally reach the orchestration layer at runtime (before this,
|
||||
// (*orchestration.Scheduler).Spawn had no production caller and design/05 was
|
||||
// library-only).
|
||||
//
|
||||
// The capability is registered with its default reachability markers (both
|
||||
// false), which keeps it owner-only: the design/11 §3 policy engine hides the
|
||||
// schema from a group/stranger session and the loop's security gate rejects the
|
||||
// call there. Design/05 §6/§7 give no group/stranger allowance for spawning
|
||||
// background agents, and being able to spawn one is a heavy operation, so the
|
||||
// safe default is chosen deliberately rather than enabled by accident.
|
||||
func (a *app) wireSubagentTool() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.scheduler == nil {
|
||||
log.Warn("sub-agent tool skipped: no scheduler")
|
||||
return
|
||||
}
|
||||
spawner := orchestration.NewSpawnTool(a.scheduler, orchestration.WithSpawnLogger(a.log))
|
||||
if err := a.dispatcher.Register(spawner); err != nil {
|
||||
log.Warn("sub-agent tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("sub-agent tool registered", "tool", orchestration.SpawnToolName,
|
||||
"owner_only", true)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// TestWireSubagentToolRegistersOwnerOnly locks the composition-root wiring: the
|
||||
// dispatch tool must actually land in the capability registry (otherwise
|
||||
// orchestration stays library-only), and it must carry the default reachability
|
||||
// markers so a group/stranger session can neither see nor call it.
|
||||
func TestWireSubagentToolRegistersOwnerOnly(t *testing.T) {
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), dispatcher: d, scheduler: orchestration.New(orchestration.Options{})}
|
||||
|
||||
a.wireSubagentTool()
|
||||
|
||||
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr != nil {
|
||||
t.Fatalf("capability %q is not registered: %v", orchestration.SpawnToolName, gerr)
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, s := range d.ToolSchemas() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
found := false
|
||||
for _, n := range names {
|
||||
if n == orchestration.SpawnToolName {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool schemas = %v, want the spawn tool advertised", names)
|
||||
}
|
||||
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("owner was denied the spawn tool")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("group member may invoke the spawn tool; want owner-only")
|
||||
}
|
||||
if restrict.SetContext(reg, restrict.ActorStranger).Allow(orchestration.SpawnToolName) {
|
||||
t.Fatal("stranger may invoke the spawn tool; want owner-only")
|
||||
}
|
||||
// The registered capability must be the dispatch tool itself, not a placeholder.
|
||||
cap, _ := reg.Get(orchestration.SpawnToolName)
|
||||
sp, ok := cap.Value.(*orchestration.SpawnTool)
|
||||
if !ok {
|
||||
t.Fatalf("capability value = %T, want *orchestration.SpawnTool", cap.Value)
|
||||
}
|
||||
if _, err := sp.Execute(context.Background(), map[string]any{}); err == nil {
|
||||
t.Fatal("the registered tool accepted a goal-less call")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireSubagentToolNilScheduler asserts a degraded boot (no scheduler) logs and
|
||||
// skips the registration instead of panicking.
|
||||
func TestWireSubagentToolNilScheduler(t *testing.T) {
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), dispatcher: d}
|
||||
|
||||
a.wireSubagentTool()
|
||||
|
||||
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr == nil {
|
||||
t.Fatal("the tool was registered without a scheduler")
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -435,14 +435,19 @@ func (a *app) wireRuntime() error {
|
||||
})
|
||||
|
||||
a.scheduler = orchestration.New(orchestration.Options{
|
||||
MaxParallel: defaultMaxParallel,
|
||||
Loop: a.loop,
|
||||
Bus: a.bus,
|
||||
Memory: store,
|
||||
Tracer: tracer,
|
||||
Approver: audit.NewApprover(a.log),
|
||||
Logger: logger.WithSource(a.log, "orchestration"),
|
||||
AgentID: defaultAgentID,
|
||||
MaxParallel: defaultMaxParallel,
|
||||
PerAgentTimeout: defaultSubagentTimeout,
|
||||
Loop: a.loop,
|
||||
Bus: a.bus,
|
||||
Memory: store,
|
||||
Tracer: tracer,
|
||||
// The sub-agent's span and its inner llm/tool steps fold into the spawning
|
||||
// session's live graph, so a branch shows up as a swimlane of that session's
|
||||
// flow instead of opening a second graph (design/19 §2).
|
||||
Flow: flowPub,
|
||||
Approver: audit.NewApprover(a.log),
|
||||
Logger: logger.WithSource(a.log, "orchestration"),
|
||||
AgentID: defaultAgentID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -575,6 +580,7 @@ func (a *app) wireAuxiliary() error {
|
||||
a.wireMCP()
|
||||
a.wireSkills()
|
||||
a.wireTerminalTool()
|
||||
a.wireSubagentTool()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user