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

+38 -7
View File
@@ -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)