feat(orchestration,runtime): subagent_spawn 支持指定目标角色(以该角色 instructions + 记忆域运行)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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:
AskaEth committed 2026-10-03 21:30:45 +08:00
1 parent c7ecc3a249
commit 05d34853f0
14 files changed
+857 -36

No files matched your search

+20 -24
View File
@@ -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
}