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

+3 -1
View File
@@ -110,7 +110,7 @@
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
> **契约版本 `v0.46`**(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
> **契约版本 `v0.47`**(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -287,6 +287,7 @@
### 3.5 实时全链路流程事件(`graph_update`,design/19)
- **触发**:主会话 Web 每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**仅主人 Web 会话**(`sessionId == "web"` 或其 `web:<uuid>` 前缀)发布——IM / 群聊 / 陌生人 / 子代理轮次**不**发布,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。
- **载荷**:`{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}`。
- **子代理分支节点的 `props.persona`(v0.47,design/22 §8-2)**:`subagent_spawn` 指定了目标角色派发时,该子代理在父会话图里的**分支根节点** `props` 会多一个 `persona` 键(值 = 目标角色 id),标注它**正在扮演的角色**;**不指定角色派发时该键不出现**(与旧版逐字一致),客户端按未知键忽略即可。
- `id` 为 `sessionId-round`(每轮一图);`sessionId` 为会话 id;`startedAt/endedAt` 为 RFC3339。
- `kind` ∈ `input|llm|tool|subagent|memory|guardrail|orchestration|output`。
- `status` ∈ `idle|running|done|error`(`running` = span 在跑 / `done` = 成功 / `error` = 失败)。
@@ -367,6 +368,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ `UpsertChannel` → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,~~**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键)~~(**本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 `""` 才解绑**,见上);沿用既有「读当前配置 → `UpsertChannel` → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径,保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
+14
View File
@@ -66,3 +66,17 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc
}
return pool
}
// personaExists returns the resolver the orchestration layer validates a dispatched
// sub-agent's target role with (design/22 §8-2). A role is dispatchable exactly when
// the pool has an agent for it, which is precisely the condition under which the loop
// would answer with that role's instructions. Every other answer — an unregistered
// persona, a persona with no character, an empty pool — makes the dispatch fail with
// orchestration.ErrUnknownPersona instead of quietly running the sub-agent as the
// default role.
func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
return func(personaID string) bool {
_, ok := pool[personaID]
return ok
}
}
+29
View File
@@ -6,6 +6,7 @@ import (
"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/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -76,3 +77,31 @@ func TestWireSubagentToolNilScheduler(t *testing.T) {
t.Fatal("the tool was registered without a scheduler")
}
}
// TestPersonaExistsMirrorsAgentPool locks the composition-root half of design/22 §8-2:
// the resolver the scheduler validates a dispatched sub-agent's target persona with
// must answer exactly like the per-role agent pool — a role is dispatchable precisely
// when the loop has an agent carrying its instructions, and nothing else is. So a
// persona with no agent is rejected at dispatch time rather than silently answering as
// the default role.
func TestPersonaExistsMirrorsAgentPool(t *testing.T) {
t.Parallel()
pool := map[string]*agent.Agent{"yuki": {}, "xilian": {}}
exists := personaExists(pool)
if !exists("yuki") || !exists("xilian") {
t.Fatal("a pooled role was reported as unrunnable")
}
if exists("nobody") || exists("") {
t.Fatal("an unpooled role id was accepted as runnable")
}
// The resolver is what orchestration gates the dispatch on, so wire it the same
// way wire.go does and check the gate agrees.
sched := orchestration.New(orchestration.Options{PersonaExists: personaExists(map[string]*agent.Agent{})})
if sched.PersonaKnown("yuki") {
t.Fatal("an empty pool accepted a persona; want the dispatch rejected")
}
if !sched.PersonaKnown("") {
t.Fatal("a persona-less dispatch was rejected by an empty pool; want it unaffected")
}
}
+5
View File
@@ -455,6 +455,11 @@ func (a *app) wireRuntime() error {
Bus: a.bus,
Memory: store,
Tracer: tracer,
// A sub-agent dispatched as another role (subagent_spawn `persona`, design/22
// §8-2) may only name a role this process can actually run with its own
// instructions, i.e. one the pool above has an agent for; anything else is
// rejected instead of silently answering in the default role's voice.
PersonaExists: personaExists(agents),
// 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).
+42
View File
@@ -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)
}
+3
View File
@@ -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
+79
View File
@@ -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)
}
+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
}
@@ -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
}
+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)
+17
View File
@@ -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")
+10 -4
View File
@@ -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,