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
@@ -0,0 +1,42 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// This file owns the scheduler's long-term-memory half: handing a sub-agent's task
|
||||
// memory to the injected *memory.Store. It sits next to persona.go because the role
|
||||
// a sub-agent runs as (design/22 §8-2) is exactly the scope its memory is filed
|
||||
// under.
|
||||
|
||||
// SinkMemory persists a task memory item for a sub-agent through the injected
|
||||
// *memory.Store. The role the sub-agent ran as is used as the persona/role key —
|
||||
// the dispatched target persona when one was named, otherwise the legacy RoleID
|
||||
// binding — so a sub-agent standing in for another role writes that role's memory
|
||||
// domain, never the spawning session's (design/22 §8-2/§8-5). 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)
|
||||
}
|
||||
role := sub.PersonaID
|
||||
if role == "" {
|
||||
role = sub.RoleID
|
||||
}
|
||||
item := memory.MemoryItem{
|
||||
PersonaID: role,
|
||||
RoleID: role,
|
||||
Scope: scope,
|
||||
Kind: kind,
|
||||
Content: content,
|
||||
}
|
||||
return s.memory.Add(ctx, item)
|
||||
}
|
||||
@@ -17,6 +17,9 @@ type SubAgentResult struct {
|
||||
Goal string
|
||||
// RoleID is the bound role/persona.
|
||||
RoleID string
|
||||
// PersonaID is the target role the sub-agent runs as (blank = the spawning
|
||||
// turn's role), whose instructions and memory scope its run used (design/22 §8-2).
|
||||
PersonaID string
|
||||
// Mode is the bound behavior mode.
|
||||
Mode string
|
||||
// AttachInfo is the supplied attachment text.
|
||||
|
||||
@@ -68,6 +68,15 @@ type Options struct {
|
||||
// Approve. Policy judgement belongs to the upper layer. Optional.
|
||||
Approver *audit.Approver
|
||||
|
||||
// PersonaExists reports whether a role id can run with its own instructions,
|
||||
// i.e. whether the per-role agent pool behind Loop (loop.Options.Agents) has an
|
||||
// entry for it (design/22 §8-2). It gates AgentSpec.PersonaID: a dispatch naming
|
||||
// an id this returns false for is rejected with ErrUnknownPersona rather than
|
||||
// running as the default role's prompt. Nil means "this deployment knows no
|
||||
// role", so a dispatch that names a role is rejected too — deciding nothing
|
||||
// silently is the point. Omitting the persona is unaffected by this.
|
||||
PersonaExists func(personaID string) bool
|
||||
|
||||
// Logger is the base logger. Nil is replaced with a discard logger tagged with
|
||||
// source "orchestration".
|
||||
Logger *slog.Logger
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// This file owns the "派出去的子代理以目标角色身份运行" half of design/22 §8-2: a
|
||||
// sub-agent dispatched with a target persona must answer with that role's
|
||||
// instructions and use that role's memory scope, and a role this process cannot run
|
||||
// must be rejected loudly instead of silently degrading into the default role.
|
||||
//
|
||||
// The resolution itself stays in the loop (internal/runtime/loop/persona.go): every
|
||||
// role-scoped read — prompt (agent pool), memory namespace, emotion, thinking knobs
|
||||
// — is derived from the session env's PersonaID, one input, so the four can never
|
||||
// disagree. The orchestration layer therefore has exactly two jobs: validate the id,
|
||||
// and stamp it onto the sub-agent's run context. Neither touches the identity lane
|
||||
// (ActorEnv / MemWritable), which is decided structurally by the channel layer and
|
||||
// is never a persona's business (project red line: 身份识别靠元数据).
|
||||
|
||||
// PersonaKnown reports whether personaID is a role this process can run with its
|
||||
// own instructions (Options.PersonaExists). A blank id is trivially known: the
|
||||
// caller is naming no role, which is the pre-existing single-role dispatch. A nil
|
||||
// resolver makes every named role unknown, so a role-naming dispatch fails instead
|
||||
// of quietly running as the default role.
|
||||
func (s *Scheduler) PersonaKnown(personaID string) bool {
|
||||
return personaID == "" || (s.personaExists != nil && s.personaExists(personaID))
|
||||
}
|
||||
|
||||
// effectiveRole returns the role to record on the sub-agent as its binding. A
|
||||
// dispatched persona IS the binding (the sub-agent answers as that role), so it fills
|
||||
// a blank RoleID; an explicit RoleID that names no persona is left as the caller set
|
||||
// it. The recorded role is what the progress events and the ws subagent_event frame
|
||||
// report, so the frontend never shows a sub-agent running as yuki with an empty role.
|
||||
func effectiveRole(spec AgentSpec) string {
|
||||
if spec.RoleID == "" {
|
||||
return spec.PersonaID
|
||||
}
|
||||
return spec.RoleID
|
||||
}
|
||||
|
||||
// checkPersona rejects a role the process cannot run, wrapping ErrUnknownPersona so
|
||||
// a caller can classify it with errors.Is.
|
||||
func (s *Scheduler) checkPersona(personaID string) error {
|
||||
if s.PersonaKnown(personaID) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("orchestration: spawn: persona %q: %w", personaID, ErrUnknownPersona)
|
||||
}
|
||||
|
||||
// CheckPersonas validates every spec's target role before a batch is spawned, so one
|
||||
// unknown id rejects the whole dispatch instead of leaving the earlier sub-agents of
|
||||
// the batch running with no result ever reported. The dispatch tool calls it right
|
||||
// after decoding the arguments, where the ids are known but nothing has started yet.
|
||||
func (s *Scheduler) CheckPersonas(specs []AgentSpec) error {
|
||||
for _, spec := range specs {
|
||||
if err := s.checkPersona(spec.PersonaID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withPersonaEnv returns ctx with the target role stamped onto the session env, so
|
||||
// the loop resolves that role's prompt and memory scope for every step of the
|
||||
// sub-agent's run. The rest of the env (the identity lane and the memory-write gate)
|
||||
// is copied unchanged — a persona decides how she answers, never what she may touch.
|
||||
// A blank id returns ctx untouched, so a persona-less dispatch keeps inheriting the
|
||||
// spawning turn's env exactly as before.
|
||||
func withPersonaEnv(ctx context.Context, personaID string) context.Context {
|
||||
if personaID == "" {
|
||||
return ctx
|
||||
}
|
||||
env := loop.SessionEnvFrom(ctx)
|
||||
env.PersonaID = personaID
|
||||
return loop.WithSessionEnv(ctx, env)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"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"
|
||||
)
|
||||
|
||||
// This file locks design/22 §8-2 at the dispatch layer: `subagent_spawn` may name a
|
||||
// target persona, and the sub-agent then runs AS that role — that role's instructions
|
||||
// become the leading system message and that role's memory scope is what it reads and
|
||||
// writes ("她以该角色身份临时替班"). Everything is exercised in process over a REAL
|
||||
// *loop.Loop with a per-role agent pool and a REAL memory store, so the assertions are
|
||||
// about what the sub-agent's run actually saw, not about wiring.
|
||||
//
|
||||
// The other half of the file pins the backward-compatibility contract: omitting the
|
||||
// persona must reproduce the pre-change behavior exactly (the sub-agent inherits the
|
||||
// spawning turn's role), an unknown persona must be rejected instead of silently
|
||||
// degrading, and naming a persona must never widen the identity/permission lane.
|
||||
|
||||
const (
|
||||
// parentRole is the role the dispatching session is bound to — deliberately NOT
|
||||
// the role a persona dispatch asks for, so a leak shows up as the wrong prompt.
|
||||
parentRole = "xilian"
|
||||
// targetRole is the role a persona dispatch asks the sub-agent to stand in for.
|
||||
targetRole = "yuki"
|
||||
)
|
||||
|
||||
// parentPrompt and targetPrompt are the two roles' instructions; a test tells which
|
||||
// pool entry the loop resolved from the leading system message alone.
|
||||
const (
|
||||
parentPrompt = "你是昔涟。"
|
||||
targetPrompt = "你是由纪。"
|
||||
)
|
||||
|
||||
// roleAdapter records every request the loop builds and answers with a fixed final
|
||||
// text, so a test can read the exact prompt and memory block a sub-agent's run got.
|
||||
type roleAdapter struct {
|
||||
mu sync.Mutex
|
||||
reqs []model.Request
|
||||
}
|
||||
|
||||
func (a *roleAdapter) Name() string { return "mock" }
|
||||
|
||||
func (a *roleAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.mu.Lock()
|
||||
a.reqs = append(a.reqs, req)
|
||||
a.mu.Unlock()
|
||||
return model.Response{Model: "mock-1", Content: "好的", StopReason: "end_turn"}, nil
|
||||
}
|
||||
|
||||
func (a *roleAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// request returns the n-th recorded request, failing the test when the run produced
|
||||
// fewer. Requests are recorded in call order, i.e. one per sub-agent run.
|
||||
func (a *roleAdapter) request(t *testing.T, n int) model.Request {
|
||||
t.Helper()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if len(a.reqs) <= n {
|
||||
t.Fatalf("adapter recorded %d requests, want at least %d", len(a.reqs), n+1)
|
||||
}
|
||||
return a.reqs[n]
|
||||
}
|
||||
|
||||
// leadingSystem returns the first message of the request, which is where the agent's
|
||||
// instructions land (agent/request.go 首条 system 消息).
|
||||
func (a *roleAdapter) leadingSystem(t *testing.T, n int) string {
|
||||
t.Helper()
|
||||
msgs := a.request(t, n).Messages
|
||||
if len(msgs) == 0 {
|
||||
t.Fatalf("request %d carried no messages", n)
|
||||
}
|
||||
return messageText(msgs[0])
|
||||
}
|
||||
|
||||
// carries reports whether any message of the n-th request contains want.
|
||||
func (a *roleAdapter) carries(t *testing.T, n int, want string) bool {
|
||||
t.Helper()
|
||||
for _, m := range a.request(t, n).Messages {
|
||||
if strings.Contains(messageText(m), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// personaRig is the in-process e2e rig: a real loop with a two-role agent pool over
|
||||
// a capturing adapter, the real scheduler on top (the production shape), the dispatch
|
||||
// tool above that, and a real memory store so role scoping is asserted on persisted
|
||||
// items. The turn hook records what role the loop resolved for each settled run.
|
||||
type personaRig struct {
|
||||
tool *SpawnTool
|
||||
sched *Scheduler
|
||||
ad *roleAdapter
|
||||
memStore *memory.Store
|
||||
|
||||
mu sync.Mutex
|
||||
turns []loop.TurnEnd
|
||||
}
|
||||
|
||||
// newPersonaRig builds the rig. personaExists is the role resolver under test: nil
|
||||
// mirrors a composition root that wired no pool.
|
||||
func newPersonaRig(t *testing.T, personaExists func(string) bool) *personaRig {
|
||||
t.Helper()
|
||||
rig := &personaRig{ad: &roleAdapter{}, memStore: newPersonaMemoryStore(t)}
|
||||
pool := make(map[string]*agent.Agent, 2)
|
||||
for id, instructions := range map[string]string{parentRole: parentPrompt, targetRole: targetPrompt} {
|
||||
ag, err := agent.New(instructions, nil, nil, agent.WithModel(rig.ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New(%s): %v", id, err)
|
||||
}
|
||||
pool[id] = ag
|
||||
}
|
||||
|
||||
lp := loop.New(loop.Options{
|
||||
Agent: pool[parentRole],
|
||||
Agents: pool,
|
||||
PersonaID: parentRole,
|
||||
Memory: rig.memStore,
|
||||
Retriever: memory.NewRetriever(rig.memStore),
|
||||
// Built without a persona, exactly as cmd/cyrene builds it: stamping the
|
||||
// turn's role onto each item is the loop's job.
|
||||
Extractor: memory.NewManualExtractor("", "owner"),
|
||||
OnTurnEnd: func(end loop.TurnEnd) string {
|
||||
rig.mu.Lock()
|
||||
rig.turns = append(rig.turns, end)
|
||||
rig.mu.Unlock()
|
||||
return ""
|
||||
},
|
||||
})
|
||||
rig.sched = newScheduler(t, Options{MaxParallel: 2, Loop: lp, PersonaExists: personaExists})
|
||||
rig.tool = NewSpawnTool(rig.sched, WithSpawnWait(5*time.Second))
|
||||
return rig
|
||||
}
|
||||
|
||||
// turn returns the n-th settled turn the loop reported, failing the test when the
|
||||
// rig observed fewer.
|
||||
func (r *personaRig) turn(t *testing.T, n int) loop.TurnEnd {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.turns) <= n {
|
||||
t.Fatalf("loop reported %d settled turns, want at least %d", len(r.turns), n+1)
|
||||
}
|
||||
return r.turns[n]
|
||||
}
|
||||
|
||||
// parentTurnCtx is the context the dispatching turn runs under: the parent session's
|
||||
// own role on the owner-full identity lane, as the gateway resolves it. A persona
|
||||
// dispatch must be able to change the first without touching the second.
|
||||
func parentTurnCtx() context.Context {
|
||||
env := loop.OwnerEnv()
|
||||
env.PersonaID = parentRole
|
||||
return loop.WithSessionEnv(context.Background(), env)
|
||||
}
|
||||
|
||||
// newPersonaMemoryStore opens an in-memory store for the rig.
|
||||
func newPersonaMemoryStore(t *testing.T) *memory.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
store, err := memory.NewStore(ctx, d, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// seedRoleMemory files one persona-scope memory under roleID.
|
||||
func seedRoleMemory(t *testing.T, store *memory.Store, roleID, content string) {
|
||||
t.Helper()
|
||||
_, err := store.Add(context.Background(), memory.MemoryItem{
|
||||
PersonaID: roleID,
|
||||
RoleID: roleID,
|
||||
Scope: memory.ScopePersona,
|
||||
Kind: memory.KindRelationship,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %s memory: %v", roleID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// storedPersonaOf returns the persona a memory with the given content was filed
|
||||
// under, or "" when it is absent.
|
||||
func storedPersonaOf(t *testing.T, store *memory.Store, content string) string {
|
||||
t.Helper()
|
||||
items, err := store.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("memory list: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if strings.Contains(it.Content, content) {
|
||||
return it.PersonaID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestSpawnToolRunsAsTargetPersona is the design/22 §8-2 acceptance, end to end in
|
||||
// process: a dispatch naming a persona runs with THAT role's instructions and THAT
|
||||
// role's memory, while the dispatching session's own role stays untouched. The same
|
||||
// rig then proves the omitted-persona path is unchanged, which is the pre-change
|
||||
// behavior: the sub-agent inherits the spawning turn's role.
|
||||
func TestSpawnToolRunsAsTargetPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newPersonaRig(t, func(id string) bool { return id == parentRole || id == targetRole })
|
||||
// One memory per role, so the injected block names which scope the run read from.
|
||||
seedRoleMemory(t, rig.memStore, targetRole, "由纪的暗号是香菜")
|
||||
seedRoleMemory(t, rig.memStore, parentRole, "昔涟的暗号是乌冬")
|
||||
|
||||
raw, err := rig.tool.Execute(parentTurnCtx(), map[string]any{
|
||||
"goal": "替我确认一下口味", "persona": targetRole,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
got := decodeSpawnResult(t, raw)
|
||||
if len(got.Agents) != 1 || got.Agents[0].Status != string(StatusDone) {
|
||||
t.Fatalf("agents = %+v, want one settled sub-agent", got.Agents)
|
||||
}
|
||||
// The reported role is the target persona, so the tool result, the progress events
|
||||
// and the ws subagent_event frame all name the role it really answered as.
|
||||
if got.Agents[0].Role != targetRole {
|
||||
t.Fatalf("reported role = %q, want the target persona %q", got.Agents[0].Role, targetRole)
|
||||
}
|
||||
|
||||
// 1) The prompt the model actually received is the TARGET role's instructions —
|
||||
// not the dispatching role's, and not a shared default.
|
||||
if prompt := rig.ad.leadingSystem(t, 0); prompt != targetPrompt {
|
||||
t.Fatalf("sub-agent system prompt = %q, want the target persona's %q", prompt, targetPrompt)
|
||||
}
|
||||
// 2) The role the loop resolved for the run (which scopes memory, emotion and the
|
||||
// thinking knobs) is the target persona, although the dispatching env said xilian.
|
||||
if turn := rig.turn(t, 0); turn.PersonaID != targetRole {
|
||||
t.Fatalf("sub-agent turn persona = %q, want %q", turn.PersonaID, targetRole)
|
||||
}
|
||||
// 3) Memory reads follow the target role: only yuki's memory was injected.
|
||||
if !rig.ad.carries(t, 0, "由纪的暗号是香菜") {
|
||||
t.Fatalf("sub-agent did not recall the target persona's memory: %+v", rig.ad.request(t, 0).Messages)
|
||||
}
|
||||
if rig.ad.carries(t, 0, "昔涟的暗号是乌冬") {
|
||||
t.Fatalf("sub-agent read the dispatching persona's memory: %+v", rig.ad.request(t, 0).Messages)
|
||||
}
|
||||
// 4) The identity lane travelled unchanged: naming a persona is never a permission
|
||||
// (project red line: 身份识别靠元数据 + 策略引擎).
|
||||
env := rig.turn(t, 0).Env
|
||||
if env.ActorEnv != restrict.ActorOwner || !env.MemWritable {
|
||||
t.Fatalf("sub-agent env = %+v, want the dispatching owner lane unchanged", env)
|
||||
}
|
||||
|
||||
// Backward compatibility: a dispatch without persona still runs as the spawning
|
||||
// role (xilian) and reads xilian's memory — byte for byte the pre-change behavior.
|
||||
raw, err = rig.tool.Execute(parentTurnCtx(), map[string]any{
|
||||
"goal": "老路子", "role": "legacy-label",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute without persona: %v", err)
|
||||
}
|
||||
got = decodeSpawnResult(t, raw)
|
||||
if prompt := rig.ad.leadingSystem(t, 1); prompt != parentPrompt {
|
||||
t.Fatalf("persona-less sub-agent prompt = %q, want the spawning role's %q", prompt, parentPrompt)
|
||||
}
|
||||
if turn := rig.turn(t, 1); turn.PersonaID != parentRole {
|
||||
t.Fatalf("persona-less sub-agent persona = %q, want the inherited %q", turn.PersonaID, parentRole)
|
||||
}
|
||||
if rig.ad.carries(t, 1, "由纪的暗号是香菜") {
|
||||
t.Fatalf("persona-less sub-agent read another role's memory: %+v", rig.ad.request(t, 1).Messages)
|
||||
}
|
||||
if !rig.ad.carries(t, 1, "昔涟的暗号是乌冬") {
|
||||
t.Fatalf("persona-less sub-agent lost the inherited role's memory: %+v", rig.ad.request(t, 1).Messages)
|
||||
}
|
||||
// The legacy role label still round-trips untouched when no persona is named.
|
||||
if got.Agents[0].Role != "legacy-label" {
|
||||
t.Fatalf("legacy role = %q, want it untouched when persona is omitted", got.Agents[0].Role)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona pins the write half of design/22
|
||||
// §8-2: a memory the sub-agent's turn extracts is filed under the target persona, not
|
||||
// under the role of the session that dispatched it. It also pins that a persona never
|
||||
// widens the identity lane: in a restricted (read-only) env the same dispatch still
|
||||
// writes nothing, even though it names a role.
|
||||
func TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newPersonaRig(t, func(string) bool { return true })
|
||||
|
||||
sub, err := rig.sched.Spawn(parentTurnCtx(), AgentSpec{
|
||||
Goal: "记住主人的口味",
|
||||
RoleID: targetRole,
|
||||
PersonaID: targetRole,
|
||||
Context: []model.Message{model.TextMessage(model.RoleUser, "记住 我讨厌香菜")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
if _, err := rig.sched.Wait(context.Background(), []string{sub.ID}); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if got := storedPersonaOf(t, rig.memStore, "我讨厌香菜"); got != targetRole {
|
||||
t.Fatalf("extracted memory filed under %q, want the target persona %q", got, targetRole)
|
||||
}
|
||||
|
||||
// Restricted env: the dispatching turn was a group chat (read-only). Naming a
|
||||
// persona must not create a write path where the lane forbids one.
|
||||
restricted := loop.WithSessionEnv(context.Background(), loop.SessionEnv{
|
||||
ActorEnv: restrict.ActorGroupMember,
|
||||
MemWritable: false,
|
||||
PersonaID: parentRole,
|
||||
})
|
||||
sub, err = rig.sched.Spawn(restricted, AgentSpec{
|
||||
Goal: "群聊里的她被派去记住东西",
|
||||
PersonaID: targetRole,
|
||||
Context: []model.Message{model.TextMessage(model.RoleUser, "记住 我喜欢吃辣")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn (restricted): %v", err)
|
||||
}
|
||||
if _, err := rig.sched.Wait(context.Background(), []string{sub.ID}); err != nil {
|
||||
t.Fatalf("Wait (restricted): %v", err)
|
||||
}
|
||||
if got := storedPersonaOf(t, rig.memStore, "我喜欢吃辣"); got != "" {
|
||||
t.Fatalf("a restricted sub-agent wrote memory under %q, want no write at all", got)
|
||||
}
|
||||
env := rig.turn(t, 1).Env
|
||||
if env.MemWritable || env.ActorEnv != restrict.ActorGroupMember {
|
||||
t.Fatalf("restricted sub-agent env = %+v, want the read-only group lane preserved", env)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file locks the CONTRACT half of design/22 §8-2 (the behavior half lives in
|
||||
// spawnpersona_test.go): the tool schema and result shape a model sees, the rejection
|
||||
// of an unknown role, and the session env a sub-agent run is stamped with. The point of
|
||||
// these tests is that a dispatch which names no persona stays byte-identical to the
|
||||
// pre-change behavior, while a dispatch that names an unusable role fails loudly rather
|
||||
// than degrading into the default role.
|
||||
|
||||
// TestSpawnPersonaRejectsUnknownRole is the "未知角色 → 明确报错,不静默回落" half: an id
|
||||
// the process cannot run is refused before anything starts, for a single dispatch and
|
||||
// for a batch (all-or-nothing, so no half-run batch is left behind), and a blank id is
|
||||
// never treated as a role.
|
||||
func TestSpawnPersonaRejectsUnknownRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
exists func(string) bool
|
||||
args map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "unknown persona",
|
||||
exists: func(id string) bool { return id == targetRole },
|
||||
args: map[string]any{"goal": "x", "persona": "nobody"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no resolver wired",
|
||||
exists: nil,
|
||||
args: map[string]any{"goal": "x", "persona": targetRole},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "batch with one unknown persona",
|
||||
exists: func(id string) bool { return id == targetRole },
|
||||
args: map[string]any{"tasks": []any{map[string]any{"goal": "a", "persona": targetRole}, map[string]any{"goal": "b", "persona": "nobody"}}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "known persona",
|
||||
exists: func(id string) bool { return id == targetRole },
|
||||
args: map[string]any{"goal": "x", "persona": targetRole},
|
||||
},
|
||||
{
|
||||
name: "blank persona is not a role",
|
||||
exists: nil,
|
||||
args: map[string]any{"goal": "x", "persona": " "},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newPersonaRig(t, tc.exists)
|
||||
_, err := rig.tool.Execute(parentTurnCtx(), tc.args)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, ErrUnknownPersona) {
|
||||
t.Fatalf("err = %v, want ErrUnknownPersona", err)
|
||||
}
|
||||
if got := len(rig.sched.Agents()); got != 0 {
|
||||
t.Fatalf("a rejected dispatch started %d sub-agents, want 0", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchedulerSpawnCarriesPersonaEnv pins the library half of the same contract for
|
||||
// callers that bypass the tool: Spawn stamps the target role onto the run's env (so
|
||||
// the loop resolves that role's prompt and memory scope), a blank persona leaves the
|
||||
// inherited env untouched, and Spawn itself refuses an unknown role.
|
||||
func TestSchedulerSpawnCarriesPersonaEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newScheduler(t, Options{PersonaExists: func(id string) bool { return id == targetRole }})
|
||||
envs := make(chan loop.SessionEnv, 4)
|
||||
s.runner = envRunner{envs: envs}
|
||||
|
||||
sub, err := s.Spawn(parentTurnCtx(), AgentSpec{Goal: "g", PersonaID: targetRole})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
// The recorded binding names the target role too, so the progress events and the
|
||||
// ws subagent_event frame report the role the sub-agent answered as.
|
||||
if sub.RoleID != targetRole || sub.PersonaID != targetRole {
|
||||
t.Fatalf("sub-agent binding = %q/%q, want the target role on both", sub.RoleID, sub.PersonaID)
|
||||
}
|
||||
if _, err := s.Wait(context.Background(), []string{sub.ID}); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if env := <-envs; env.PersonaID != targetRole || env.ActorEnv != restrict.ActorOwner || !env.MemWritable {
|
||||
t.Fatalf("run env = %+v, want the target persona on the unchanged owner lane", env)
|
||||
}
|
||||
|
||||
legacy, err := s.Spawn(parentTurnCtx(), AgentSpec{Goal: "g"})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn (no persona): %v", err)
|
||||
}
|
||||
if _, err := s.Wait(context.Background(), []string{legacy.ID}); err != nil {
|
||||
t.Fatalf("Wait (no persona): %v", err)
|
||||
}
|
||||
if env := <-envs; env.PersonaID != parentRole {
|
||||
t.Fatalf("persona-less run env = %+v, want the inherited role %q untouched", env, parentRole)
|
||||
}
|
||||
|
||||
if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "g", PersonaID: "nobody"}); !errors.Is(err, ErrUnknownPersona) {
|
||||
t.Fatalf("Spawn with an unknown persona: err = %v, want ErrUnknownPersona", err)
|
||||
}
|
||||
}
|
||||
|
||||
// envRunner records the session env each sub-agent run starts with. The memory scope
|
||||
// a run uses is decided from exactly this value, so it is the boundary under test for
|
||||
// callers that go through Scheduler.Spawn directly.
|
||||
type envRunner struct {
|
||||
envs chan loop.SessionEnv
|
||||
}
|
||||
|
||||
func (r envRunner) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
|
||||
r.envs <- loop.SessionEnvFrom(ctx)
|
||||
return &loop.Result{Text: "ok", Steps: 1}, nil
|
||||
}
|
||||
|
||||
// TestSpawnToolResultShapeIsUnchangedWithoutPersona pins the strict backward
|
||||
// compatibility of the model-visible result: a dispatch that names no persona still
|
||||
// returns exactly the same JSON keys as before this feature — no "persona" field is
|
||||
// added anywhere — so nothing about the omitted-persona contract moved.
|
||||
func TestSpawnToolResultShapeIsUnchangedWithoutPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, sp := spawnToolFor(t, Options{}, time.Second)
|
||||
s.runner = &mockRunner{result: &loop.Result{Text: "done", Steps: 2, StopReason: "stop"}}
|
||||
|
||||
raw, err := sp.Execute(context.Background(), map[string]any{
|
||||
"goal": "g", "role": "cyrene", "mode": "engineer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var top map[string]any
|
||||
if err := json.Unmarshal(b, &top); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got := sortedKeys(top); strings.Join(got, ",") != "agents,status,summary" {
|
||||
t.Fatalf("result keys = %v, want the unchanged agents/status/summary", got)
|
||||
}
|
||||
agents, _ := top["agents"].([]any)
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("agents = %v, want one entry", top["agents"])
|
||||
}
|
||||
agent, _ := agents[0].(map[string]any)
|
||||
want := "goal,id,mode,role,status,steps,summary,text"
|
||||
if got := sortedKeys(agent); strings.Join(got, ",") != want {
|
||||
t.Fatalf("agent keys = %v, want %s (no new client-visible field)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys returns m's keys in sorted order, for a stable comparison in tests.
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSpawnSchemaKeepsPersonaOptional pins the tool contract: persona is an optional
|
||||
// string at both the single-goal and the tasks level, nothing new is required, and the
|
||||
// pre-existing arguments keep their meaning — so a model (or an old client) that never
|
||||
// sends persona sees byte-identical validation behavior.
|
||||
func TestSpawnSchemaKeepsPersonaOptional(t *testing.T) {
|
||||
t.Parallel()
|
||||
props, err := NewSpawnTool(nil).Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
// The required set is unchanged: goal or tasks, nothing else.
|
||||
if got := requiredAnyOf(t, props); strings.Join(got, ",") != "goal,tasks" {
|
||||
t.Fatalf("anyOf required = %v, want goal/tasks only", got)
|
||||
}
|
||||
top := stringProp(t, props, "persona")
|
||||
if top == nil {
|
||||
t.Fatal("the top-level persona property is missing")
|
||||
}
|
||||
if top["type"] != "string" {
|
||||
t.Fatalf("persona type = %v, want string", top["type"])
|
||||
}
|
||||
items, _ := props["properties"].(map[string]any)["tasks"].(map[string]any)["items"].(map[string]any)
|
||||
taskProps, _ := items["properties"].(map[string]any)
|
||||
if _, ok := taskProps["persona"]; !ok {
|
||||
t.Fatal("a tasks entry cannot name a persona")
|
||||
}
|
||||
if req, _ := items["required"].([]any); len(req) != 1 || req[0] != "goal" {
|
||||
t.Fatalf("tasks required = %v, want goal only", req)
|
||||
}
|
||||
}
|
||||
|
||||
// requiredAnyOf flattens the anyOf required lists of a tool schema.
|
||||
func requiredAnyOf(t *testing.T, props map[string]any) []string {
|
||||
t.Helper()
|
||||
anyOf, _ := props["anyOf"].([]any)
|
||||
out := make([]string, 0, len(anyOf))
|
||||
for _, entry := range anyOf {
|
||||
m, _ := entry.(map[string]any)
|
||||
req, _ := m["required"].([]any)
|
||||
for _, r := range req {
|
||||
s, _ := r.(string)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stringProp returns the named top-level property of a tool schema property block.
|
||||
func stringProp(t *testing.T, props map[string]any, name string) map[string]any {
|
||||
t.Helper()
|
||||
properties, _ := props["properties"].(map[string]any)
|
||||
prop, _ := properties[name].(map[string]any)
|
||||
return prop
|
||||
}
|
||||
@@ -102,13 +102,15 @@ func (t *SpawnTool) Description() string {
|
||||
}
|
||||
|
||||
// Parameters returns the input JSON Schema: either a single goal shorthand or a
|
||||
// tasks array for a parallel batch, plus an optional background flag.
|
||||
// tasks array for a parallel batch, plus an optional persona (the role the
|
||||
// sub-agent runs as, design/22 §8-2) and an optional background flag.
|
||||
func (t *SpawnTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"goal": spawnStringProp("Objective the sub-agent works toward alone. Required unless tasks is given."),
|
||||
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional)."),
|
||||
"persona": spawnPersonaProp(),
|
||||
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona, which is the role the sub-agent actually runs as."),
|
||||
"mode": spawnStringProp("Behavior mode for the role, e.g. engineer (optional)."),
|
||||
"attach_info": spawnStringProp("Extra context or data to hand the sub-agent (optional)."),
|
||||
"tasks": spawnTasksProp(),
|
||||
@@ -145,6 +147,13 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
|
||||
}
|
||||
// Reject an unknown target role before anything starts: a batch must be
|
||||
// all-or-nothing, otherwise the sub-agents already dispatched would keep running
|
||||
// with their result never reported to the model (design/22 §8-2: 未知角色明确报错,
|
||||
// 不静默回落到默认角色).
|
||||
if perr := t.sched.CheckPersonas(specs); perr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, perr)
|
||||
}
|
||||
|
||||
spawnCtx := context.WithoutCancel(ctx)
|
||||
ids := make([]string, 0, len(specs))
|
||||
@@ -156,7 +165,7 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
ids = append(ids, sub.ID)
|
||||
}
|
||||
t.log.Info("sub-agents dispatched", "count", len(ids), "role", specs[0].RoleID,
|
||||
"mode", specs[0].Mode, "session", specs[0].ParentSession)
|
||||
"persona", specs[0].PersonaID, "mode", specs[0].Mode, "session", specs[0].ParentSession)
|
||||
|
||||
if boolArg(m, "background") {
|
||||
go t.await(ids)
|
||||
@@ -218,7 +227,8 @@ type SpawnAgentResult struct {
|
||||
ID string `json:"id"`
|
||||
// Goal is the objective the sub-agent was given.
|
||||
Goal string `json:"goal"`
|
||||
// Role is the bound role/persona, if any.
|
||||
// Role is the bound role/persona, if any: the dispatched target persona when one
|
||||
// was named, otherwise the legacy role label.
|
||||
Role string `json:"role,omitempty"`
|
||||
// Mode is the bound behavior mode, if any.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
@@ -310,9 +320,20 @@ func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
|
||||
if goal == "" {
|
||||
return AgentSpec{}, ErrMissingGoal
|
||||
}
|
||||
personaID := strings.TrimSpace(stringArg(m, "persona"))
|
||||
roleID := strings.TrimSpace(stringArg(m, "role"))
|
||||
// A named persona is the role the sub-agent actually runs as, so it also fills
|
||||
// the binding metadata: leaving role and persona disagreeing would make the tool
|
||||
// result, the progress events and the ws subagent_event frame name a role the
|
||||
// sub-agent is not answering as. Omitting persona keeps the pre-existing
|
||||
// role-only path byte for byte (design/22 §8-2 backward compatibility).
|
||||
if personaID != "" {
|
||||
roleID = personaID
|
||||
}
|
||||
return AgentSpec{
|
||||
Goal: goal,
|
||||
RoleID: strings.TrimSpace(stringArg(m, "role")),
|
||||
RoleID: roleID,
|
||||
PersonaID: personaID,
|
||||
Mode: strings.TrimSpace(stringArg(m, "mode")),
|
||||
AttachInfo: strings.TrimSpace(stringArg(m, "attach_info")),
|
||||
ParentSession: parentSession,
|
||||
@@ -350,13 +371,14 @@ func spawnStringProp(desc string) map[string]any {
|
||||
func spawnTasksProp() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"description": "Sub-agents to dispatch in parallel. When given, it overrides goal/role/mode/attach_info.",
|
||||
"description": "Sub-agents to dispatch in parallel. When given, it overrides goal/persona/role/mode/attach_info.",
|
||||
"maxItems": maxSpawnBatch,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"goal": spawnStringProp("Objective this sub-agent works toward alone."),
|
||||
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional)."),
|
||||
"persona": spawnPersonaProp(),
|
||||
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona."),
|
||||
"mode": spawnStringProp("Behavior mode for the role (optional)."),
|
||||
"attach_info": spawnStringProp("Extra context or data for this sub-agent (optional)."),
|
||||
},
|
||||
@@ -365,6 +387,15 @@ func spawnTasksProp() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// spawnPersonaProp builds the persona property schema. It is the design/22 §8-2 派发
|
||||
// switch: naming a role makes the sub-agent stand in for it (that role's
|
||||
// instructions and memory scope) instead of working as the spawning session's role.
|
||||
func spawnPersonaProp() map[string]any {
|
||||
return spawnStringProp("Persona/role id the sub-agent runs AS: it answers with that role's " +
|
||||
"instructions and reads/writes that role's memory scope (optional, default: the current " +
|
||||
"role). A role id this runtime does not know is rejected. Supersedes role.")
|
||||
}
|
||||
|
||||
// stringArg reads a string argument, tolerating a missing or non-string value.
|
||||
func stringArg(m map[string]any, key string) string {
|
||||
s, _ := m[key].(string)
|
||||
|
||||
@@ -40,6 +40,17 @@ type AgentSpec struct {
|
||||
Goal string
|
||||
// RoleID selects the role/persona the sub-agent is bound to.
|
||||
RoleID string
|
||||
// PersonaID is the role the sub-agent actually runs as (design/22 §8-2): it
|
||||
// answers with that role's instructions and reads/writes that role's memory
|
||||
// scope — "她以该角色身份临时替班". Spawn stamps it onto the sub-agent run's
|
||||
// session env, which is the single input the loop resolves the prompt, the
|
||||
// memory namespace, the emotion and the thinking knobs from, so the four can
|
||||
// never disagree. Blank keeps the pre-existing behavior byte for byte: the
|
||||
// sub-agent inherits the spawning turn's env, i.e. the parent session's role.
|
||||
// The id must be one this process can run with its own agent
|
||||
// (*Scheduler).PersonaKnown; Spawn rejects an unknown one with
|
||||
// ErrUnknownPersona instead of silently falling back to the default role.
|
||||
PersonaID string
|
||||
// Mode selects the behavior mode (e.g. "engineer") for the role.
|
||||
Mode string
|
||||
// Context is the initial, isolated conversation context. Spawn deep-copies
|
||||
@@ -107,6 +118,12 @@ var (
|
||||
// ErrAgentNotFound is returned by Abort when the sub-agent id is unknown.
|
||||
ErrAgentNotFound = errors.New("orchestration: sub-agent not found")
|
||||
|
||||
// ErrUnknownPersona is returned by Spawn when a spec names a role this process
|
||||
// has no per-role agent for (design/22 §8-2). It is a rejection, not a
|
||||
// fallback: running the sub-agent with the default role's prompt while the
|
||||
// caller asked for another role would silently defeat the request.
|
||||
ErrUnknownPersona = errors.New("orchestration: unknown persona")
|
||||
|
||||
// ErrNoScheduler is returned by the spawn tool when it was built without a
|
||||
// scheduler (a wiring mistake, reported instead of panicking).
|
||||
ErrNoScheduler = errors.New("orchestration: no scheduler configured")
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// SubAgent is a single spawned sub-agent instance. The identity fields (ID, Goal,
|
||||
// RoleID, Mode, AttachInfo, ParentSession, Data, Context, WorkingMemory) are set
|
||||
// once at Spawn and never mutated. The lifecycle fields (Status, Summary, Result,
|
||||
// Err) are written by the sub-agent's background goroutine and read through the
|
||||
// synchronized accessors below, so reading them before the sub-agent finishes is
|
||||
// RoleID, PersonaID, Mode, AttachInfo, ParentSession, Data, Context, WorkingMemory)
|
||||
// are set once at Spawn and never mutated. The lifecycle fields (Status, Summary,
|
||||
// Result, Err) are written by the sub-agent's background goroutine and read through
|
||||
// the synchronized accessors below, so reading them before the sub-agent finishes is
|
||||
// safe. A sub-agent is terminal exactly once its Done reports true; Join returns
|
||||
// once every sub-agent is terminal, Wait once the named ids are.
|
||||
//
|
||||
@@ -26,6 +26,11 @@ type SubAgent struct {
|
||||
Goal string
|
||||
// RoleID is the bound role/persona.
|
||||
RoleID string
|
||||
// PersonaID is the target role the sub-agent runs as: its instructions and its
|
||||
// memory scope (design/22 §8-2). Blank means "run as the spawning turn's role",
|
||||
// the pre-existing behavior. The dispatch tool fills it (validated against the
|
||||
// per-role agent pool) and Spawn stamps it onto the run's session env.
|
||||
PersonaID string
|
||||
// Mode is the bound behavior mode.
|
||||
Mode string
|
||||
// AttachInfo is the free-form attachment text supplied at spawn.
|
||||
@@ -130,6 +135,7 @@ func (s *SubAgent) Snapshot() SubAgentResult {
|
||||
ID: s.ID,
|
||||
Goal: s.Goal,
|
||||
RoleID: s.RoleID,
|
||||
PersonaID: s.PersonaID,
|
||||
Mode: s.Mode,
|
||||
AttachInfo: s.AttachInfo,
|
||||
Status: s.status,
|
||||
|
||||
Reference in new issue
Block a user