diff --git a/Docs/api-client.md b/Docs/api-client.md index a2c42a2..7589d91 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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:` 前缀)发布——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 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。 diff --git a/cmd/cyrene/persona_agents.go b/cmd/cyrene/persona_agents.go index 292bf65..0b808e8 100644 --- a/cmd/cyrene/persona_agents.go +++ b/cmd/cyrene/persona_agents.go @@ -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 + } +} diff --git a/cmd/cyrene/subagent_tool_test.go b/cmd/cyrene/subagent_tool_test.go index 7502218..2b6fae2 100644 --- a/cmd/cyrene/subagent_tool_test.go +++ b/cmd/cyrene/subagent_tool_test.go @@ -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") + } +} diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 7c4d69a..dbc6fa8 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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). diff --git a/internal/runtime/orchestration/memory.go b/internal/runtime/orchestration/memory.go new file mode 100644 index 0000000..da18f5b --- /dev/null +++ b/internal/runtime/orchestration/memory.go @@ -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) +} diff --git a/internal/runtime/orchestration/merge.go b/internal/runtime/orchestration/merge.go index 7efb27f..05236d7 100644 --- a/internal/runtime/orchestration/merge.go +++ b/internal/runtime/orchestration/merge.go @@ -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. diff --git a/internal/runtime/orchestration/options.go b/internal/runtime/orchestration/options.go index 4086016..9376825 100644 --- a/internal/runtime/orchestration/options.go +++ b/internal/runtime/orchestration/options.go @@ -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 diff --git a/internal/runtime/orchestration/persona.go b/internal/runtime/orchestration/persona.go new file mode 100644 index 0000000..f48ea3b --- /dev/null +++ b/internal/runtime/orchestration/persona.go @@ -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) +} diff --git a/internal/runtime/orchestration/scheduler.go b/internal/runtime/orchestration/scheduler.go index ce65d29..0aa653c 100644 --- a/internal/runtime/orchestration/scheduler.go +++ b/internal/runtime/orchestration/scheduler.go @@ -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 } diff --git a/internal/runtime/orchestration/spawnpersona_test.go b/internal/runtime/orchestration/spawnpersona_test.go new file mode 100644 index 0000000..fff157f --- /dev/null +++ b/internal/runtime/orchestration/spawnpersona_test.go @@ -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) + } +} diff --git a/internal/runtime/orchestration/spawnpersonacontract_test.go b/internal/runtime/orchestration/spawnpersonacontract_test.go new file mode 100644 index 0000000..e3d844a --- /dev/null +++ b/internal/runtime/orchestration/spawnpersonacontract_test.go @@ -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 +} diff --git a/internal/runtime/orchestration/spawntool.go b/internal/runtime/orchestration/spawntool.go index 718edf5..462ca70 100644 --- a/internal/runtime/orchestration/spawntool.go +++ b/internal/runtime/orchestration/spawntool.go @@ -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) diff --git a/internal/runtime/orchestration/spec.go b/internal/runtime/orchestration/spec.go index 22edf9e..d82a4a4 100644 --- a/internal/runtime/orchestration/spec.go +++ b/internal/runtime/orchestration/spec.go @@ -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") diff --git a/internal/runtime/orchestration/subagent.go b/internal/runtime/orchestration/subagent.go index 3853e62..c9bf51e 100644 --- a/internal/runtime/orchestration/subagent.go +++ b/internal/runtime/orchestration/subagent.go @@ -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,