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
+3
-1
@@ -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 完全一致)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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