feat(orchestration,runtime): subagent_spawn 支持指定目标角色(以该角色 instructions + 记忆域运行)
design/22 §8-2(主人 2026-10-03 拍板):跨角色协作 = 把其他角色以子代理形式派出去,
该子代理以目标角色的 instructions 运行、并按目标角色的记忆作用域读写。
- AgentSpec / SubAgent / SubAgentResult 新增 PersonaID;Options.PersonaExists 作角色解析器
- scheduler:派发前校验角色(ErrUnknownPersona),批量 all-or-nothing;
run ctx 经 withPersonaEnv 只覆盖 SessionEnv.PersonaID —— ActorEnv/MemWritable 原样复制
(身份识别靠 channel 元数据,persona 决定她怎么答、不决定她能碰什么:项目红线)
- 记忆归属键改为 PersonaID 优先(回落 RoleID)→ 替班者写的是目标角色的记忆域
- spawntool:schema 顶层与 tasks[] 新增可选 persona;省略 = 与旧行为逐字节一致
- 分支根节点新增 props.persona(可观测性);SinkMemory 移入 memory.go(守 500 行红线)
- 组合根:personaExists(agent 池);cmd/cyrene/wire.go +5 行
契约:Docs/api-client.md -> v0.47(graph_update 新增可选 props.persona,纯增量)
验证:go build/vet ./... 绿;internal/runtime/orchestration + cmd/cyrene 测试绿;
go test -race ./internal/runtime/orchestration -run Persona 绿;
linux/amd64 + darwin/arm64 交叉编译绿
变异验证:① 目标角色换回父/默认角色(3 处断言 FAIL)② 关调度器侧角色校验 ③ 关工具侧预检
This commit is contained in:
1 parent
c7ecc3a249
commit
05d34853f0
14 files changed
+857
-36
No files matched your search
@@ -47,6 +47,9 @@ type Scheduler struct {
|
||||
approver *audit.Approver
|
||||
log *slog.Logger
|
||||
agentID string
|
||||
// personaExists answers "can this process run that role with its own
|
||||
// instructions" (Options.PersonaExists); nil means no role can be named.
|
||||
personaExists func(personaID string) bool
|
||||
|
||||
mu sync.Mutex
|
||||
agents map[string]*SubAgent
|
||||
@@ -93,6 +96,7 @@ func New(opts Options) *Scheduler {
|
||||
approver: opts.Approver,
|
||||
log: sourcedLog(opts.Logger),
|
||||
agentID: agentID,
|
||||
personaExists: opts.PersonaExists,
|
||||
agents: make(map[string]*SubAgent),
|
||||
allDone: make(chan struct{}),
|
||||
sem: make(chan struct{}, maxParallel),
|
||||
@@ -116,7 +120,8 @@ func (s *Scheduler) Comm() *comm.Probe { return s.comm }
|
||||
// progress event and launches its execution in the background. It returns
|
||||
// immediately so the front session is not blocked; the sub-agent runs
|
||||
// asynchronously and reports progress over the bus. It returns ErrMissingGoal
|
||||
// when spec.Goal is blank.
|
||||
// when spec.Goal is blank, and a wrapped ErrUnknownPersona when spec.PersonaID
|
||||
// names a role this process has no agent for (design/22 §8-2).
|
||||
//
|
||||
// The sub-agent's Context is a deep copy of spec.Context and its WorkingMemory is
|
||||
// a fresh, isolated window, so it never shares state with the front session.
|
||||
@@ -124,6 +129,9 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
|
||||
if strings.TrimSpace(spec.Goal) == "" {
|
||||
return nil, fmt.Errorf("orchestration: spawn: %w", ErrMissingGoal)
|
||||
}
|
||||
if err := s.checkPersona(spec.PersonaID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -131,7 +139,8 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
|
||||
sub := &SubAgent{
|
||||
ID: s.nextID(),
|
||||
Goal: spec.Goal,
|
||||
RoleID: spec.RoleID,
|
||||
RoleID: effectiveRole(spec),
|
||||
PersonaID: spec.PersonaID,
|
||||
Mode: spec.Mode,
|
||||
AttachInfo: spec.AttachInfo,
|
||||
ParentSession: spec.ParentSession,
|
||||
@@ -259,28 +268,6 @@ func (s *Scheduler) Approve(ctx context.Context, actor, action, describe string)
|
||||
return s.approver.ReviewAndApprove(ctx, actor, action, describe)
|
||||
}
|
||||
|
||||
// SinkMemory persists a task memory item for a sub-agent through the injected
|
||||
// *memory.Store. The sub-agent's RoleID is used as the persona/role key; content
|
||||
// and the scope/kind classification (user/persona/project) are supplied by the
|
||||
// caller, keeping scope policy at the call site. It returns ErrNoMemory when no
|
||||
// store was injected.
|
||||
func (s *Scheduler) SinkMemory(ctx context.Context, sub *SubAgent, content string, scope memory.Scope, kind memory.Kind) (memory.MemoryItem, error) {
|
||||
if s.memory == nil {
|
||||
return memory.MemoryItem{}, fmt.Errorf("orchestration: sink memory: %w", ErrNoMemory)
|
||||
}
|
||||
if sub == nil {
|
||||
return memory.MemoryItem{}, fmt.Errorf("orchestration: sink memory: %w", ErrAgentNotFound)
|
||||
}
|
||||
item := memory.MemoryItem{
|
||||
PersonaID: sub.RoleID,
|
||||
RoleID: sub.RoleID,
|
||||
Scope: scope,
|
||||
Kind: kind,
|
||||
Content: content,
|
||||
}
|
||||
return s.memory.Add(ctx, item)
|
||||
}
|
||||
|
||||
// launch starts the sub-agent's background execution exactly once. It is called
|
||||
// by Spawn (auto-launch) and by Run (explicit start); launchOnce guarantees a
|
||||
// single run even if both happen.
|
||||
@@ -325,6 +312,10 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
// emits to the spawning session's graph instead of opening a second graph.
|
||||
runCtx = loop.WithMutedBroadcast(runCtx)
|
||||
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
|
||||
// A dispatched target role decides the prompt and the memory scope for every step
|
||||
// of this run (design/22 §8-2). Blank keeps the inherited env: the pre-existing
|
||||
// behavior, where a sub-agent runs as the spawning turn's role.
|
||||
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
|
||||
runCtx, span := s.subSpan(runCtx, sub)
|
||||
sub.setCancel(cancel)
|
||||
defer cancel()
|
||||
@@ -396,6 +387,11 @@ func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context
|
||||
_ = span.SetProp("session", sess)
|
||||
s.flow.SpanStarted(sess, span)
|
||||
}
|
||||
// The target role rides the branch node so the flow panel shows which role the
|
||||
// sub-agent answered as (design/22 §8-2 可观测性), not just the sub-agent id.
|
||||
if sub.PersonaID != "" {
|
||||
_ = span.SetProp("persona", sub.PersonaID)
|
||||
}
|
||||
return sctx, span
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user