From 3885efc0682422c768c54f64e52af6550d6daa3e Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 7 Sep 2026 22:14:19 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(openspec):=20add-mesh-swarm-mode=20?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E2=80=94=E2=80=94=E5=A4=8D=E5=88=BB=20Codex?= =?UTF-8?q?=20Multi-Agent=20V2=20=E7=9A=84=E8=9C=82=E7=BE=A4=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=88=E7=BD=91=E7=8A=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 现有蜂群模式改名为蜂群模式(星型),行为不变 - 新增会话级 off/star/mesh 三态、六个 agent_* 协作工具、邮箱投递、驻留换出 - 四份能力规格:swarm-mode-selection / mesh-agent-collaboration / mesh-agent-residency / mesh-agent-observability - openspec/ 在 .gitignore 中,本目录以 git add -f 纳入跟踪 --- .../add-mesh-swarm-mode/.openspec.yaml | 2 + .../changes/add-mesh-swarm-mode/design.md | 149 ++++++++++++ .../changes/add-mesh-swarm-mode/proposal.md | 44 ++++ .../specs/mesh-agent-collaboration/spec.md | 212 ++++++++++++++++++ .../specs/mesh-agent-observability/spec.md | 81 +++++++ .../specs/mesh-agent-residency/spec.md | 73 ++++++ .../specs/swarm-mode-selection/spec.md | 119 ++++++++++ openspec/changes/add-mesh-swarm-mode/tasks.md | 74 ++++++ 8 files changed, 754 insertions(+) create mode 100644 openspec/changes/add-mesh-swarm-mode/.openspec.yaml create mode 100644 openspec/changes/add-mesh-swarm-mode/design.md create mode 100644 openspec/changes/add-mesh-swarm-mode/proposal.md create mode 100644 openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md create mode 100644 openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md create mode 100644 openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md create mode 100644 openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md create mode 100644 openspec/changes/add-mesh-swarm-mode/tasks.md diff --git a/openspec/changes/add-mesh-swarm-mode/.openspec.yaml b/openspec/changes/add-mesh-swarm-mode/.openspec.yaml new file mode 100644 index 000000000..2e24cfa4f --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/add-mesh-swarm-mode/design.md b/openspec/changes/add-mesh-swarm-mode/design.md new file mode 100644 index 000000000..d25c7d86c --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/design.md @@ -0,0 +1,149 @@ +## Context + +动机见 proposal.md。这里只记录决定实现走向的现状: + +- **现有蜂群模式是回合级布尔**:Web 把 `swarm_mode: true` 放进消息体,`routes_sessions.cpp` 写入用户消息 metadata,`AgentLoop::run_agent_with_input` 把它读进 `active_turn_swarm_mode_`,`build_swarm_mode_context_prompt`(`src/prompt/system_prompt.cpp`)在按请求变化的上下文段注入引导;回合结束即复位,子会话不知道父处于蜂群模式。 +- **子代理 = SessionRegistry 里的普通会话**:`SessionEntry` 有 `subagent_depth` 与持久化的 `parent_session_id`;`spawn_subagent` 靠 `subagent_depth >= 1` 拒绝嵌套;等待靠 250ms 轮询 `is_busy()`。 +- **AgentLoop 已有的可复用原语**:`steer_input(expected_turn_id, input)` 把输入并入运行中的回合、在下一次模型请求前由 `drain_active_turn_inputs` 消费(`pending_turn_inputs_` 在同一把锁下与回合状态一起判定,`TurnMismatch` / `NoActiveTurn` 作为原子校验结果);`submit(UserInput)` 对空闲会话开新回合;`hidden_goal_context` 是「注入一条不显示为用户气泡、不计入可见回合的消息开回合」的先例;`abort()` 只置标记不销毁;`EventDispatcher` 带 seq、订阅与回放,`ThreadService::wait` 已证明 subscribe + condvar 的事件等待可行。 +- **工具可见性**:`ToolExecutor::is_allowed(name, &ToolCapabilityPolicy)` 是 schema 过滤与执行前拒绝共用的唯一谓词;`ToolCapabilityPolicy` 目前只有可选的 builtin 允许集与 MCP server 集。 +- **专家团**:`ExpertType::Team`、`expert_member`、team lead 提示词已存在,是 Codex `agent_type` 的现成对应物。 +- **会话 fork**:`SessionManager::fork_session_to_new_id(retained_prefix, ...)` 写出新会话文件,Web fork 路由随后用 `resume` 把它装进 registry(不自动开回合)。 +- **Prompt cache 不变量**:最后一条真实用户消息之前的内容必须内容驱动、同回合逐字节不变(见 CLAUDE.md)。 +- **Codex 参照**(`codex-rs/core/src/tools/handlers/multi_agents_v2/`、`agent/control.rs`、`session/input_queue.rs`、`session/multi_agents.rs`):`send_message` = 入箱不唤醒;`followup_task` = 唤醒空闲目标或在消息边界投递,禁止目标 root;完成结果以 `trigger_turn=false` 投父邮箱;`wait_agent` 等自己邮箱,超时 10s/30s/1h;`interrupt_agent` 禁止 root 与自身;`list_agents` 不做祖先校验;驻留默认 4 含 root,LRU 换出空闲 agent;`fork_turns` 的 `all` 只保留 system/developer/user 消息与 assistant 最终回复;邮箱无容量上限;`RolloutBudget` 未开发完成且默认关闭。 + +## Goals / Non-Goals + +**Goals:** + +- 六个 `agent_*` 工具与邮箱语义与 Codex V2 一一对应,差异只在工具名前缀与信封角色。 +- 网状模式对现有星型路径零侵入:星型只改显示名与模式枚举,代码路径不变。 +- 所有跨会话协作都在进程内经 `SessionRegistry` 完成,不经本机 HTTP。 +- 父子 agent 的静态系统提示前缀逐字节一致,让 `fork_turns=all` 的子 agent 能命中 provider 侧的前缀缓存。 + +**Non-Goals:** + +- 不做共享 token 预算、邮箱容量限额、树操作审计(Codex 亦无或未完成)。 +- 不复刻 Codex 的加密 payload、多收件人字段、Guardian 审批、OTel 事件、`/morpheus` 保留路径。 +- 不为子 agent 提供独立 worktree 隔离(所有 agent 共享 cwd,与 Codex 一致)。 +- 不新增 TUI 侧的 agents overview 视图;TUI 首版只加 `/swarm` 与 `/tasks` 的路径/状态显示。 +- 不改变星型模式下 `spawn_subagent(wait=true)` 的阻塞语义与 abort 传播行为。 + +## Decisions + +### 1. 蜂群模式升级为会话级三态,通过消息体、命令与 CLI 三个入口写入 + +`SwarmMode { Off, Star, Mesh }` 存进 `SessionMeta::swarm_mode`(`off` 省略)、`SessionEntry`、`SessionOptions` 与 `AgentLoop`。写入口:消息体 `swarm_mode`(sticky,省略即沿用)、`/swarm` 内置命令(TUI 注册表 + daemon `execute_builtin_command` 白名单 + Web 斜杠目录)、headless `--swarm`。`SessionInfo` 与 `session_status` 事件带出当前值供前端恢复标签态。 + +- 为什么不保留回合级布尔:子 agent 必须知道自己在网里(角色提示、工具集),而它们收到的从来不是带 metadata 的用户消息。 +- 为什么消息体仍是主入口:与现有 Web 数据流零改动地兼容(`chatInputQueue.js` 已经保存并透传 `swarm_mode`),只是取值从布尔扩成枚举,`true` 继续映射为 `star`。 +- 有 live 子 agent 时拒绝退出 mesh:由 `MeshTreeControl::has_running_or_pending_children(root)` 判定,消息路由返回 409,命令返回错误。 + +### 2. 工具可见性用 `ToolCapabilityPolicy` 的模式隐藏集实现互斥 + +`ToolCapabilityPolicy` 增加 `hidden_builtin_tools`(按模式计算的隐藏集);`is_allowed` 在现有判定之后再查它。`off`/`star` 隐藏六个 `agent_*`;`mesh` 隐藏 `spawn_subagent`、`wait_subagent` 与全部 thread 工具。模式切换经 `AgentLoop::enqueue_control` 重设 policy,保证在回合之间生效;执行入口复用同一谓词,被隐藏工具的调用返回带模式说明的拒绝文案。 + +- 替代方案「按模式注册/注销工具」被否决:工具注册在三个启动点一次性完成,`apply_model_to_session` 类的中途切换根本不碰 ToolExecutor(CLAUDE.md 的视觉能力一节记录过同类教训)。 + +### 3. Agent 身份持久化为四个可选元数据字段,路径解析移植 Codex `AgentPath` + +`SessionMeta` 新增 `agent_root_session_id`、`agent_task_name`、`agent_path`、`agent_depth`(空/0 省略);`parent_session_id` 保留,现有 UI 归属逻辑不动。新增纯逻辑模块 `src/session/agent_path.{hpp,cpp}`:`root()`、`is_root()`、`join(name)`、`resolve(reference)`(以 `/` 开头为 canonical,否则拼到自身之后)、任务名校验 `[a-z0-9_]+` 且排除 `root`/`.`/`..`,可单测。旧子会话在读取层合成 `/root/legacy_`。目标解析顺序与 Codex `resolve_agent_target` 一致:先尝试会话 id,再按路径解析;解析结果必须属于调用者的 root 树,否则返回「目标不在当前 agent 树内」且不泄露其它信息。 + +- 为什么保留会话 id 作为底层主键:存储、WebSocket、后台任务面板全部以会话 id 工作,路径只是模型侧的稳定名字。 + +### 4. 邮箱住在 AgentLoop,与 steering 共用同一把锁 + +新增 `AgentLoop` 内部的跨 agent 邮箱:`std::deque`(信封 + `trigger_turn`)、活动类型(`Mailbox` / `Steer`)与一个 condvar。 + +- `deliver(mail)` 在持有回合锁的情况下判定:有活动回合且接受投递 → 入箱,随后由 `drain_active_turn_inputs` 在下一次模型请求前与 steering 输入一起消费(先 steering 再邮箱,保持 FIFO);无活动回合且 `trigger_turn` → 直接 `submit()` 一条以该信封为正文的隐藏内部输入开新回合;无活动回合且不触发 → 留在箱内,`run_agent_with_input` 开始时先 drain。 +- 回合刚结束的竞态:回合关闭接受态(`close_if_empty`)与 `deliver` 的判定在同一把锁下,因此一条后续任务要么在关闭前被排进当前回合,要么在关闭后走 `submit()`,恰好一次。 +- `steer_input` 入队时同时通知邮箱 condvar,这就是 `agent_wait` 的「被新输入打断」。 +- `wait_for_mailbox_activity(deadline, abort_flag)` 供 `agent_wait` 工具阻塞;它只返回活动类型,不出队。 + +为什么不放在 registry 层:只有 AgentLoop 知道回合是否还接受投递;Codex 也是把 mailbox 放进 `InputQueue` 并在 `active_turn` 锁下判定。 + +### 5. 信封是 user 角色的隐藏内部输入(有意偏离 Codex) + +信封正文: + +``` + +Message Type: MESSAGE | NEW_TASK | FINAL_ANSWER +Task name: <收件人路径> +Sender: <发件人路径> +Payload: +<正文> + +``` + +作为 `ChatMessage{role="user"}` 持久化到收件方会话,metadata 带 `inter_agent: {type, sender, recipient}`。`run_agent_with_input` 的 `hidden_goal_context` 泛化为「隐藏内部输入」:不触发 UserPromptSubmit 钩子、不计 `turn_count`、不做技能命令展开、不参与会话摘要;`Message.jsx` / TUI 渲染层按 metadata 画成独立行。 + +- 为什么不用 Codex 的 assistant 角色:Anthropic 要求 user/assistant 严格交替,DeepSeek 需要回显 `reasoning_content`,连续 assistant 会被合并或拒绝;ACECode 必须跨 provider。 +- 追加在对话尾部,不碰任何已缓存前缀。 + +### 6. 完成投递复用回合结束回调,父空闲不唤醒 + +`AgentLoop::on_turn_finished(status)` 之后由 `MeshTreeControl::on_child_turn_finished(child_id, status)` 取最后一条非空 assistant 文本(错误/中断时拼状态说明与部分输出),构造 `FINAL_ANSWER` 信封,以 `trigger_turn=false` 投父邮箱;同时 emit 一条带 `unread_mailbox` 计数的 `session_status`,前端据此显示未读数。父在 `agent_wait` 中会被 condvar 唤醒;父空闲则信封留箱,与 Codex control.rs 的 `trigger_turn=false` 一致。 + +### 7. `MeshTreeControl` 按 root 树串行化目录与驻留 + +新增 `src/session/mesh_tree_control.{hpp,cpp}`,依赖 `SessionRegistry`、`SessionClient`、`SessionStorage`: + +- 每个 `root_session_id` 一个 `TreeState`(互斥锁保护):`path → session id` 目录、每节点 `AgentStatus`、LRU 驻留链、`pending_slots`。 +- `reserve_slot(root)`:循环尝试「预留」→「换出一个 LRU 空闲节点」→「上限错误」,与 Codex `V2Residency::reserve_slot` 同构;换出 = 目标非 root、`!is_busy()`、邮箱无 `trigger_turn` 项 → `registry.destroy(id)`(会话记录本就 canonical 落盘,destroy 只卸载并 join 空闲 worker)→ 状态置 `inactive`。 +- `ensure_loaded(target)`:`inactive` → 预留槽 → `registry.resume(id, opts 带身份/模式/专家)` → 触发 `on_agent_loaded` 回调,让 TUI `SubagentHost` 与 Web 面板重新订阅事件。 +- spawn 的两阶段:持锁校验名称与预留槽 → 锁外 create/fork + submit 首条 `NEW_TASK` → 持锁提交目录项;失败回滚槽并 destroy 半成品会话。 +- daemon 启动时按 `agent_root_session_id` 扫描元数据重建目录,全部 `inactive`。 + +状态枚举 `pending_init / running / idle / interrupted / errored / inactive / not_found` 从 `BusyChanged`/`Done` 事件的 `outcome` 与 `is_aborting()` 推导,`agent_list`、回执、`session_status`、面板共用。 + +### 8. `fork_turns` 走现有 fork 流程加过滤器 + +`all`:取父会话已持久化消息,保留 `role=user` 且非隐藏内部输入、非信封的消息,以及 `role=assistant` 且无 `tool_calls`、非压缩摘要的最终回复;丢弃 tool、system、meta、压缩检查点、信封。正整数 N:从尾部数 N 条用户消息为界。产出经 `fork_session_to_new_id` 写盘后 `resume` 进 registry,与 Web fork 路由同一套路。`none`:直接 `create`。子会话不继承 file checkpoint(既有 fork 决定)。 + +- 与 Codex 的差异:Codex 还保留 developer 消息;ACECode 的项目指令、记忆等由每个会话按 cwd 自行构建,不需要复制。 + +### 9. 提示词移植到独立模块并放在可变上下文段 + +新增 `src/prompt/mesh_swarm_prompts.{hpp,cpp}`:从 `codex-rs/core/src/session/multi_agents.rs` 与 `context/multi_agent_mode_instructions.rs` 移植 root 角色提示、子 agent 角色提示、主动委派文案、等待提示与并发槽提示,把工具名换成 `agent_*`,去掉 `functions.exec` 相关段落(ACECode 无该机制)。`build_mesh_swarm_context_prompt(mode, identity, capacity)` 在 `agent_loop.cpp` 现有 `build_swarm_mode_context_prompt` 的同一位置注入,内容只由(模式、路径、并发上限、工具可用性)决定,同回合逐字节稳定。 + +- 身份不进静态系统提示:这样父子 agent 的 `# Environment` 与工具 schema 完全一致,provider 前缀缓存可跨 agent 复用。 + +### 10. 工具命名与结果形态 + +`agent_spawn` / `agent_list` / `agent_send_message` / `agent_followup_task` / `agent_wait` / `agent_interrupt`,沿用 ACECode `名词_动作` 惯例(`file_read`、`memory_write`),动词保持 Codex 原样以保留模型先验。六个工具 `is_read_only=true`(与 `spawn_subagent` 同理,副作用由子会话自己的权限门把关),结果带 `summary.icon="agent"` 让前端沿用「调用了 N 个智能体」分组。`agent_spawn` 结果与 `metadata.subagent_session_id` 兼容现有面板发现通道,另加 `agent_path`。工具描述文案逐条对照 `multi_agents_spec.rs` 的 V2 描述翻译改写。 + +### 11. Web 与 TUI 的最小改动面 + +- Web:`normalizeComposerPayload` 输出 `swarm_mode: 'star' | 'mesh'`;`ChatView` 的 `composerSwarmMode` 由布尔改枚举并从 `SessionInfo.swarm_mode` 恢复;`InputBar` 两个 `menuitemradio`;`ComposerSessionControls` 芯片文案按模式;`chatInputQueue.js` 原样透传字符串;`subagentTasks.js` 归一化新增 `agentPath`、`agentDepth`、`agentStatus`、`unreadMailbox`,面板按 `agentPath` 缩进与分组;`Message.jsx` 识别 `metadata.inter_agent` 渲染独立行;`sessionTranscript.js` 的摘要与折叠投影排除信封。 +- TUI:`/swarm` 命令;`SubagentHost` 在 `on_agent_loaded` 时重订阅并以路径为标题;`/tasks` 输出路径与状态;确认/提问浮层的来源标签使用路径。 +- headless:`--swarm`,透传到 `SessionOptions::swarm_mode`。 + +### 12. 星型模式保持字面不变 + +`build_swarm_mode_context_prompt` 只在 `mode == Star` 时调用,文案不改;`spawn_subagent` 的深度规则不改;星型子代理的 `SessionOptions::swarm_mode` 为 `off`。`add-interrupt-subagent-tool` 提案不在本变更内实施。 + +## Risks / Trade-offs + +- **[`fork_turns=all` 默认使 token 成本随 agent 数线性增长]** → 静态前缀跨 agent 一致以最大化 provider 缓存命中;提示词中说明 `fork_turns` 的取舍;用户可用 `none` 或整数。 +- **[换出用 `destroy` 实现,事件订阅随之消亡]** → `on_agent_loaded` 回调统一重订阅;Web 面板已对未知 busy 会话的 `session_status` 帧做 refetch;只读查看走磁盘不触发恢复。 +- **[user 角色信封与真实用户输入难以区分]** → metadata 为唯一判据;隐藏内部输入不计回合、不入摘要、不触发钩子;测试覆盖回放渲染。 +- **[busy→idle 竞态导致后续任务丢失或双投]** → 判定与回合关闭在同一把锁下(复用 steering 的 `close_if_empty` 路径),用状态机测试覆盖三种时序。 +- **[`agent_wait` 可长达 1 小时的工具调用]** → 前端工具行已支持长时运行;等待可被用户 steer 与 abort 打断;检查 `AgentLoopDoomGuard` 是否把重复的 `agent_wait` 调用判为循环,需要时按工具名豁免。 +- **[连续 provider 请求中 `FINAL_ANSWER` 与用户消息穿插]** → 信封作为 user 角色与真实用户消息合法相邻;无需合并。 +- **[专家团成员在深层被任意指定]** → 仍只接受团内已选成员,非法 id 直接拒绝。 +- **[thread 工具在 mesh 下不可用影响既有工作流]** → 这是有意的互斥;`/swarm off` 后立即恢复。 + +## Migration Plan + +1. 先落身份字段、模式枚举、`AgentPath` 与 `MeshTreeControl` 的纯逻辑及状态机测试,不注册新工具。 +2. 接入 AgentLoop 邮箱、投递、完成回调、驻留换出/恢复,用并发测试证明恰好一次与不超上限。 +3. 注册六个工具与隐藏集,接入提示词与 `fork_turns`。 +4. Web/TUI/headless 入口与面板、transcript 渲染。 +5. 文档(`docs/subagents.md`、`docs/daemon-api.md`、`docs/help/swarm.html`、`CLAUDE.md`)与 i18n。 + +回滚:不选择网状模式即回到今天的行为;`swarm_mode` 字段向后兼容;新增元数据字段旧版本忽略。`add-agent-tree-collaboration` 目录只存在于主工作树且未纳入 git,应在主工作树里移入 `openspec/changes/archive/` 并注明由本变更取代。 + +## Open Questions + +- 移植的角色提示词具体措辞(在实现时对照 Codex 源文本逐段改写,不影响规格与任务拆分)。 +- `reasoning_effort` 的透传取决于当前模型档案是否暴露该设置;规格已规定不支持时在结果中注明忽略。 diff --git a/openspec/changes/add-mesh-swarm-mode/proposal.md b/openspec/changes/add-mesh-swarm-mode/proposal.md new file mode 100644 index 000000000..4159e5505 --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/proposal.md @@ -0,0 +1,44 @@ +## Why + +ACECode 现有的「蜂群模式」是星型拓扑:只有主会话能派遣子代理(`spawn_subagent` / `wait_subagent`),子代理之间互不可见、无法通信,结果只能回到父会话;子代理也不能再派生。Codex 最新的 Multi-Agent V2 已经换成网状协作:每个 agent 拿到同一套工具,用文件系统式路径(`/root/task1/task_3`)互相寻址,任意 live agent 之间可以列举、发消息、派后续任务、等待与中断,且可以无限嵌套派生。本变更把这套模型复刻进 ACECode,作为第二种蜂群模式提供,让开发成员可以并排测试两种拓扑。 + +## What Changes + +- **现有蜂群模式改名为「蜂群模式(星型)」**,行为一字不改(同一段主动委派引导、同一对 `spawn_subagent` / `wait_subagent` 工具、深度 1 限制)。 +- **新增「蜂群模式(网状)」**,复刻 Codex Multi-Agent V2 的语义: + - 六个协作工具,按 ACECode 的 `名词_动作` 命名惯例加 `agent_` 前缀,动词沿用 Codex:`agent_spawn`、`agent_list`、`agent_send_message`、`agent_followup_task`、`agent_wait`、`agent_interrupt`。 + - 每个 agent 拥有持久化的 canonical 路径(`/root`、`/root/`、`/root//`),相对名相对调用者自身路径解析,任意 live agent 可用 canonical 路径寻址。 + - 每个 agent 一个邮箱:`agent_send_message` 只入箱不唤醒;`agent_followup_task` 对空闲目标开新回合、对运行中目标在下一处消息边界投递;子 agent 完成时把最终答复作为 `FINAL_ANSWER` 投进父邮箱且不唤醒空闲的父;`agent_wait` 等待自己邮箱的活动而不是等待指定目标。 + - 派生深度不限,由每棵树的并发驻留上限(默认 4,含 root)约束;超限时按 LRU 换出空闲 agent(落盘后卸载),再被寻址时透明恢复。 + - `fork_turns` 控制子 agent 继承多少父上下文,默认 `all`(只继承用户消息与 assistant 最终回复,不含工具往返与推理)。 + - `agent_type` 映射到 ACECode 的专家团成员;网状模式下树内任意 agent 都可指定。 + - 根 / 子 agent 的角色提示词与「主动委派」模式文案从 Codex 移植并适配 ACECode 工具名,放在按请求变化的上下文段,不碰可缓存的静态系统提示前缀。 +- **模式成为会话级状态**(`off` / `star` / `mesh`),持久化到会话元数据、恢复时保留、派生时由子 agent 继承;Web/Desktop 输入框 `+` 菜单改为两项互斥单选,TUI 新增 `/swarm [star|mesh|off]`,headless 新增 `--swarm`。 +- **两套工具互斥**:网状回合隐藏 `spawn_subagent` / `wait_subagent` 与全部 thread 工具;非网状回合隐藏六个 `agent_*` 工具。树内仍有 live agent 时不允许关闭网状模式。 +- **跨 agent 消息以 `user` 角色 + `` 标签注入**(有意偏离 Codex 的 `assistant` 角色,因为 Anthropic / DeepSeek 等 provider 不接受连续 assistant 消息),信封四字段(Message Type / Task name / Sender / Payload)照抄 Codex;收件方 transcript 中渲染为独立的「来自 /root/xxx」行,不是用户气泡。 +- **观测面**:后台任务面板按树缩进、按状态分组、显示路径/最后一条消息/被换出状态与 root 的未读消息数;任意层级子 agent 的权限请求与提问冒泡到 root 界面并标注路径;网状子 agent 标题直接使用 `task_name`,不再触发隐藏的自动起标题模型调用。 +- 消息 API 的 `swarm_mode` 字段从布尔扩展为 `"star" | "mesh" | false`,旧的 `true` 继续视为 `star`,不构成破坏性变更。 +- 取代从未实施的 `add-agent-tree-collaboration` 提案(深度 1、祖先专属 follow-up、邮箱限额与共享预算 epoch 均与 Codex 路线冲突);该提案随本变更归档。 + +## Capabilities + +### New Capabilities + +- `swarm-mode-selection`: 两种蜂群模式的会话级选择、持久化与继承,各表面(Web/Desktop、TUI、headless)的入口,消息 API 契约,以及星型 / 网状两套工具的互斥暴露规则。 +- `mesh-agent-collaboration`: 网状模式的 agent 身份与路径、六个协作工具的输入输出与错误条件、邮箱投递语义(普通消息 / 后续任务 / 最终答复)、等待与中断、上下文继承、专家团映射与角色提示词。 +- `mesh-agent-residency`: 每棵树的并发驻留上限、LRU 换出与透明恢复、agent 状态枚举、daemon 重启后的重建行为。 +- `mesh-agent-observability`: 后台任务面板的树形展示与未读提示、跨 agent 消息在 transcript 中的呈现、任意层级的权限 / 提问冒泡、子 agent 标题与通知规则。 + +### Modified Capabilities + +无。星型模式沿用 `add-desktop-swarm-mode` 已交付的行为,仅改显示名称;该变更尚未归档,其 `desktop-swarm-mode` 能力不在主规格中,本变更不对它提出需求变更。 + +## Impact + +- **后端核心**:`src/agent_loop.{hpp,cpp}`(邮箱、投递边界、隐藏内部输入、模式驱动的工具可见性、请求上下文)、`src/session/session_registry.{hpp,cpp}` / `session_client.hpp` / `local_session_client.cpp`(身份字段、驻留控制、恢复重订阅)、`src/session/session_storage.{hpp,cpp}` / `session_manager.{hpp,cpp}`(元数据字段、fork 过滤)、`src/prompt/system_prompt.*` 与新增的网状提示词模块、`src/tool/`(六个新工具、`spawn_subagent` 深度规则不变、thread 工具隐藏)、`src/tool/tool_executor.hpp`(`ToolCapabilityPolicy` 扩展)、`src/config/config.hpp`(`swarm.mesh.*` 配置)。 +- **接口**:`POST /api/sessions/:id/messages` 的 `swarm_mode` 取值扩展;`SessionSummary` / `session_status` 增加模式与 agent 身份、状态字段;`docs/daemon-api.md` 同步。 +- **Web/Desktop**:`InputBar.jsx`、`ComposerSessionControls.jsx`、`ChatView.jsx`、`chatInputQueue.js`、`SubagentPanel.jsx`、`subagentTasks.js`、`useSubagentTasks.js`、`Message.jsx`、i18n 目录与英文覆盖。 +- **TUI / headless**:`builtin_commands.cpp`(`/swarm`、`/tasks` 显示路径)、`subagent_host.{hpp,cpp}`(恢复后重订阅、路径标签)、`headless_options.*` / `headless_runner.cpp`(`--swarm`)、`main.cpp` / `worker.cpp` / `headless_runner.cpp` 三处工具注册。 +- **文档**:`docs/subagents.md`、`docs/help/swarm.html` 文案、`CLAUDE.md` 子代理段落。 +- **依赖**:无新第三方库。 +- **成本提示**:`fork_turns=all` 默认让每个子 agent 起步即携带父对话副本,token 成本随 agent 数线性增长;这是按 Codex 默认值的有意选择,可通过参数改为 `none` 或整数。 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md new file mode 100644 index 000000000..47e305cb2 --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md @@ -0,0 +1,212 @@ +## Purpose + +定义蜂群模式(网状)下 agent 的身份与路径寻址、六个协作工具的输入、输出与错误条件、邮箱投递语义(普通消息、后续任务、最终答复)、上下文继承、专家团映射以及注入给模型的角色与模式提示。 + +## ADDED Requirements + +### Requirement: agent 拥有 canonical 路径并支持相对寻址 +根会话的路径 SHALL 为 `/root`。`agent_spawn` 创建的子 agent 路径 SHALL 为调用者路径加 `/` 加 `task_name`,可无限嵌套。`task_name` MUST 匹配 `[a-z0-9_]+`,MUST NOT 为 `root`、`.` 或 `..`;同一父 agent 下重复的 `task_name` SHALL 返回错误,MUST NOT 静默改名。所有接受目标的工具参数 SHALL 接受 canonical 路径、相对调用者路径的相对名或底层会话 id;相对名 SHALL 相对调用者自身路径解析。只有 `parent_session_id` 的旧子会话 SHALL 呈现为 `/root/legacy_<会话 id 前 8 位>`。 + +#### Scenario: 嵌套路径 +- **WHEN** `/root/task1` 以 `task_name="task_3"` 调用 `agent_spawn` +- **THEN** 新 agent 的 canonical 路径为 `/root/task1/task_3` +- **AND** `/root/task1` 可以用 `task_3` 或 `/root/task1/task_3` 互换地引用它 + +#### Scenario: 兄弟子树间寻址 +- **WHEN** `/root/task2/task_3` 要给 `/root/task1/task_3` 发消息 +- **THEN** 它 MUST 使用 canonical 路径 `/root/task1/task_3` +- **AND** 使用相对名 `task_3` 会解析到 `/root/task2/task_3/task_3` 而找不到目标 + +#### Scenario: 同父重名 +- **WHEN** `/root` 下已存在 `review`,`/root` 再次以 `task_name="review"` 调用 `agent_spawn` +- **THEN** 调用返回名称冲突错误 +- **AND** 不创建新的会话 + +#### Scenario: 非法任务名 +- **WHEN** `agent_spawn` 收到 `task_name="Review-Tests"` 或 `task_name="root"` +- **THEN** 调用返回参数错误并说明合法字符集 +- **AND** 不创建新的会话 + +### Requirement: agent_spawn 异步创建同能力的子 agent +`agent_spawn(task_name, message, agent_type?, fork_turns?, model?, reasoning_effort?)` SHALL 创建一个独立的子会话,把 `message` 以 `NEW_TASK` 信封作为首条任务投递,并在提交成功后立即返回子 agent 的 canonical 路径、会话 id 与初始状态;MUST NOT 阻塞等待子 agent 完成。子 agent SHALL 继承调用者的工作目录、权限模式、蜂群模式与专家绑定,SHALL 拥有与调用者相同的协作工具,并且 SHALL 可以继续派生自己的子 agent;派生深度 MUST NOT 被固定上限拒绝,只受驻留上限约束。`model` SHALL 接受 saved_models 中的名称,未知名称 SHALL 返回错误;`reasoning_effort` 仅在所选模型档案支持推理力度设置时生效,否则 SHALL 在结果中注明被忽略。 + +#### Scenario: 派生立即返回 +- **WHEN** `/root` 调用 `agent_spawn(task_name="explore", message="...")` +- **THEN** 工具在子会话创建并接收首条任务后立即返回路径 `/root/explore`、会话 id 与状态 `running` 或 `pending_init` +- **AND** `/root` 的回合继续执行,不等待 `/root/explore` 完成 + +#### Scenario: 二级派生 +- **WHEN** `/root/explore` 调用 `agent_spawn(task_name="tests", message="...")` +- **THEN** 创建路径为 `/root/explore/tests` 的子 agent +- **AND** 它拥有与 `/root/explore` 相同的六个协作工具 + +#### Scenario: 未知模型名 +- **WHEN** `agent_spawn` 的 `model` 不在 saved_models 中 +- **THEN** 调用返回错误并列出问题 +- **AND** 不创建新的会话 + +### Requirement: fork_turns 决定继承多少父上下文 +`fork_turns` SHALL 接受 `none`、`all`(默认)或正整数字符串。`none` 时子 agent 从空对话开始;`all` 时子 agent 继承父会话到派生时刻的全部用户消息与 assistant 最终回复;正整数 N 时只继承最近 N 个用户回合。任何取值下 MUST NOT 继承工具调用与工具结果、推理内容、压缩记录以及跨 agent 信封。非法取值 SHALL 返回参数错误。 + +#### Scenario: 默认继承全部 +- **WHEN** 父会话已有 3 个用户回合且含多次工具往返,`agent_spawn` 未提供 `fork_turns` +- **THEN** 子 agent 的初始上下文包含这 3 条用户消息与对应的 assistant 最终回复 +- **AND** 不包含任何工具调用、工具结果或推理内容 + +#### Scenario: 只继承最近两轮 +- **WHEN** `agent_spawn` 提供 `fork_turns="2"` +- **THEN** 子 agent 的初始上下文只包含最近 2 个用户回合 +- **AND** 更早的回合不出现在子 agent 上下文中 + +#### Scenario: 不继承 +- **WHEN** `agent_spawn` 提供 `fork_turns="none"` +- **THEN** 子 agent 的上下文只有系统提示与首条 `NEW_TASK` 信封 + +### Requirement: agent_list 列出整棵树 +`agent_list(path_prefix?)` SHALL 返回调用者所属 root 树内全部已知 agent(含 root 与已被换出的 agent)的 canonical 路径、会话 id、状态、模型、任务名或标题以及最近更新时间,按路径排序;提供 `path_prefix` 时只返回路径以其开头的 agent。结果 MUST NOT 包含任何 agent 的对话内容。 + +#### Scenario: 兄弟发现彼此 +- **WHEN** `/root/a` 调用 `agent_list()` +- **THEN** 结果包含 `/root`、`/root/a` 与 `/root/b` 及各自状态 +- **AND** 不包含任何对话内容 + +#### Scenario: 前缀过滤 +- **WHEN** `/root` 调用 `agent_list(path_prefix="/root/a")` +- **THEN** 结果只包含 `/root/a` 及其后代 + +### Requirement: agent_send_message 只入箱不唤醒 +`agent_send_message(target, message)` SHALL 把 `MESSAGE` 信封写入目标邮箱并返回投递回执;目标可以是树内任意 agent 包括 root。目标运行中时,信封 SHALL 在目标下一次模型请求之前进入其上下文;目标空闲时,信封 SHALL 保持待投递直到目标下一次回合开始,MUST NOT 为此启动新回合。目标已被换出时 SHALL 先恢复目标再入箱。 + +#### Scenario: 发给运行中的兄弟 +- **WHEN** `/root/a` 对运行中的 `/root/b` 调用 `agent_send_message` +- **THEN** `/root/b` 在下一次模型请求前看到该 `MESSAGE` 信封 +- **AND** `/root/b` 没有被启动第二个并发回合 + +#### Scenario: 发给空闲的 root +- **WHEN** `/root/a` 对空闲的 `/root` 调用 `agent_send_message` +- **THEN** `/root` 保持空闲 +- **AND** 用户下一次向 `/root` 发送消息时,该信封出现在 `/root` 的上下文中 + +#### Scenario: 发给已被换出的 agent +- **WHEN** 目标 agent 处于 `inactive`(已换出)状态 +- **THEN** 系统先恢复该 agent,再把信封写入其邮箱 +- **AND** 回执说明目标已被恢复且消息待投递 + +### Requirement: agent_followup_task 恰好一次地触发目标 +`agent_followup_task(target, message)` SHALL 把 `NEW_TASK` 信封写入目标邮箱并保证目标处理它:目标空闲、已完成或已被换出时 SHALL(必要时恢复后)启动一个新回合;目标运行中时 SHALL 在当前回合的下一处消息边界投递,MUST NOT 并发启动第二个回合。目标从运行到空闲的竞态窗口内,同一条后续任务 MUST 恰好被投递一次,不得丢失或重复。目标 MUST NOT 为 root,否则 SHALL 返回错误 "Follow-up tasks can't target the root agent"。 + +#### Scenario: 对空闲子 agent 派后续任务 +- **WHEN** `/root` 对已完成的 `/root/a` 调用 `agent_followup_task` +- **THEN** `/root/a` 启动一个新回合,首条输入为该 `NEW_TASK` 信封 +- **AND** `/root/a` 的路径与既有上下文保持不变 + +#### Scenario: 对运行中的兄弟派后续任务 +- **WHEN** `/root/a` 对运行中的 `/root/b` 调用 `agent_followup_task` +- **THEN** 信封在 `/root/b` 当前回合的下一处消息边界进入其上下文 +- **AND** 不启动第二个并发回合 + +#### Scenario: 目标恰在完成时收到后续任务 +- **WHEN** 后续任务入箱与目标回合结束同时发生 +- **THEN** 该信封要么被当前回合在边界处消费,要么成为新回合的首条输入 +- **AND** 两者恰好发生一个 + +#### Scenario: 对 root 派后续任务 +- **WHEN** `/root/a` 对 `/root` 调用 `agent_followup_task` +- **THEN** 调用返回错误 "Follow-up tasks can't target the root agent" +- **AND** `/root` 的邮箱与状态不变 + +### Requirement: 完成结果投递给父 agent 且不唤醒空闲父 +子 agent 回合结束时,系统 SHALL 向其父 agent 邮箱投递一条 `FINAL_ANSWER` 信封:正常结束时 Payload 为最终 assistant 回复;因错误或中断结束时 Payload 为状态说明与可用的部分输出。该投递 MUST NOT 唤醒空闲的父;父正在 `agent_wait` 中 SHALL 被唤醒;父正在运行中 SHALL 在下一处消息边界收到。 + +#### Scenario: 父正在等待 +- **WHEN** `/root` 阻塞在 `agent_wait`,`/root/a` 完成回合 +- **THEN** `/root` 的 `agent_wait` 返回 "Wait completed." +- **AND** `/root` 的下一次模型请求包含来自 `/root/a` 的 `FINAL_ANSWER` 信封 + +#### Scenario: 父已空闲 +- **WHEN** `/root` 已结束回合并空闲,`/root/a` 完成回合 +- **THEN** `/root` 保持空闲,不自动开始新回合 +- **AND** `FINAL_ANSWER` 信封留在 `/root` 邮箱等待其下一回合 + +#### Scenario: 子 agent 出错结束 +- **WHEN** `/root/a` 因 provider 错误结束回合 +- **THEN** `/root` 收到的 `FINAL_ANSWER` 信封说明 `/root/a` 以错误结束及错误类别 +- **AND** `/root/a` 仍可被寻址与派后续任务 + +### Requirement: agent_wait 等待自身邮箱 +`agent_wait(timeout_ms?)` SHALL 阻塞直到调用者自身邮箱出现新活动、用户向调用者提交新输入或超时,并只返回 "Wait completed."、"Wait interrupted by new input." 或 "Wait timed out." 之一;MUST NOT 返回消息正文(正文以信封形式出现在调用者的下一次模型请求中)。`timeout_ms` SHALL 被钳制到最短 10 秒、最长 1 小时,缺省 30 秒;三者 SHALL 可通过配置调整。调用者被用户中止时等待 SHALL 立即结束,且 MUST NOT 影响任何其它 agent。 + +#### Scenario: 邮箱活动唤醒 +- **WHEN** `/root` 调用 `agent_wait(timeout_ms=600000)`,30 秒后 `/root/a` 发来消息 +- **THEN** `agent_wait` 在消息到达时返回 "Wait completed." +- **AND** 该消息在 `/root` 的下一次模型请求中以信封出现 + +#### Scenario: 用户新输入打断等待 +- **WHEN** `/root` 在 `agent_wait` 中,用户向 `/root` 的当前回合追加引导输入 +- **THEN** `agent_wait` 返回 "Wait interrupted by new input." + +#### Scenario: 超时钳制 +- **WHEN** `agent_wait(timeout_ms=1000)` 被调用 +- **THEN** 实际等待下限为 10 秒 +- **AND** 超时后返回 "Wait timed out." + +#### Scenario: 调用者被中止 +- **WHEN** 用户停止正在 `agent_wait` 的 `/root` +- **THEN** 等待立即结束 +- **AND** 所有子 agent 继续运行不受影响 + +### Requirement: agent_interrupt 中止目标当前回合但保留目标 +`agent_interrupt(target)` SHALL 中止目标 agent 当前回合(若有)并返回中止前的状态;目标 SHALL 保持可寻址、可接收消息与后续任务。目标为 root 或调用者自身时 SHALL 返回错误。目标空闲时 SHALL 返回其状态而不报错。 + +#### Scenario: 中止运行中的子 agent +- **WHEN** `/root` 对运行中的 `/root/a` 调用 `agent_interrupt` +- **THEN** `/root/a` 的当前回合停止,返回值报告中止前状态为 `running` +- **AND** `/root/a` 随后仍能接收 `agent_followup_task` + +#### Scenario: 中止空闲的 agent +- **WHEN** 目标没有正在运行的回合 +- **THEN** 调用成功返回目标当前状态 +- **AND** 目标状态不变 + +#### Scenario: 中止 root 或自身 +- **WHEN** `/root/a` 对 `/root` 或对 `/root/a` 自身调用 `agent_interrupt` +- **THEN** 调用返回错误说明不能中断 root 或自身 + +### Requirement: 跨 agent 消息使用统一信封并以 user 角色进入上下文 +跨 agent 消息 SHALL 以 `user` 角色消息进入收件方上下文,正文为 `` 包裹的四个字段:`Message Type`(`MESSAGE`、`NEW_TASK` 或 `FINAL_ANSWER`)、`Task name`(收件人 canonical 路径)、`Sender`(发件人 canonical 路径)与 `Payload`。该消息 SHALL 带有可被界面识别的跨 agent 元数据并持久化到收件方会话记录中;它 MUST NOT 计入可见用户回合数,MUST NOT 触发用户提示提交类钩子。 + +#### Scenario: 信封内容 +- **WHEN** `/root` 对 `/root/a` 调用 `agent_send_message(message="请先跑测试")` +- **THEN** `/root/a` 上下文中出现一条 user 角色消息,正文包含 `Message Type: MESSAGE`、`Task name: /root/a`、`Sender: /root` 与 `Payload:` 后的 "请先跑测试" +- **AND** 该消息的元数据标识它为跨 agent 消息 + +#### Scenario: 信封不计入用户回合 +- **WHEN** 一个子 agent 只收到过 `NEW_TASK` 与 `MESSAGE` 信封 +- **THEN** 其会话元数据的可见用户回合数为 0 +- **AND** 用户提示提交钩子未被触发 + +### Requirement: 网状模式注入角色与主动委派提示 +模式为 `mesh` 的回合 SHALL 向模型注入:root 的角色提示(声明自身为 `/root`、团队成员同等能力、六个工具的用途、`fork_turns` 的选择、当前并发槽数)或子 agent 的角色提示(自身路径、最终回复自动回父、信封三种类型的含义),以及适用于任何层级的主动委派模式文案。这些内容 MUST 位于随请求变化的上下文段而不是静态系统提示前缀,且在同一回合内逐次模型请求 MUST 逐字节相同。 + +#### Scenario: root 的提示 +- **WHEN** `mesh` 模式的 root 发起回合 +- **THEN** 模型请求包含以 `/root` 身份撰写的角色提示与主动委派文案 +- **AND** 静态系统提示前缀与 `off` 模式下逐字节相同 + +#### Scenario: 子 agent 的提示 +- **WHEN** `/root/a` 发起回合 +- **THEN** 模型请求包含声明其身份为 `/root/a`、说明最终回复将回送父 agent 的角色提示 +- **AND** 同一回合内两次相邻模型请求的该提示逐字节相同 + +### Requirement: agent_type 映射到专家团成员 +当会话绑定专家团时,`agent_spawn` 的 `agent_type` SHALL 接受该团已选成员的 id,子 agent SHALL 以该成员身份运行(成员指令、技能与能力范围);网状模式下树内任意层级的 agent 均可指定 `agent_type`。无效成员 id SHALL 返回错误并列出可选成员;会话未绑定专家团而提供 `agent_type` SHALL 返回错误。 + +#### Scenario: 子 agent 指定成员 +- **WHEN** 绑定专家团的 `/root/plan` 调用 `agent_spawn(task_name="impl", agent_type="backend_dev", ...)` +- **THEN** `/root/plan/impl` 以成员 `backend_dev` 的身份运行 +- **AND** 其上下文包含该成员的指令 + +#### Scenario: 无效成员 +- **WHEN** `agent_type` 不是团内已选成员 +- **THEN** 调用返回错误并列出可选成员 id +- **AND** 不创建新的会话 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md new file mode 100644 index 000000000..045ffd913 --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md @@ -0,0 +1,81 @@ +## Purpose + +定义用户在 Web/Desktop 与 TUI 上如何看到网状 agent 树、跨 agent 消息以及来自任意层级子 agent 的权限请求与提问,并规定子 agent 的标题、隐藏与通知规则。 + +## ADDED Requirements + +### Requirement: 后台任务面板按树展示网状 agent +root 会话的后台任务面板 SHALL 按 canonical 路径深度缩进展示全部子 agent,按状态分组(运行中、空闲、被换出、出错/中断),每项 SHALL 显示路径或任务名、状态、最后一条消息摘要、token 用量与耗时。被换出的 agent SHALL 可打开只读对话记录,且打开 MUST NOT 触发恢复。 + +#### Scenario: 二级子 agent 的缩进 +- **WHEN** 面板展示 `/root/explore` 与 `/root/explore/tests` +- **THEN** `/root/explore/tests` 显示在 `/root/explore` 之下并多一级缩进 +- **AND** 两者各自显示状态与最后一条消息摘要 + +#### Scenario: 查看被换出的 agent +- **WHEN** 用户点开状态为 `inactive` 的子 agent +- **THEN** 面板显示其对话记录 +- **AND** 该 agent 的状态保持 `inactive` + +### Requirement: root 空闲时显示未读的 agent 消息数 +root 空闲且邮箱中存在尚未进入上下文的 `FINAL_ANSWER` 或 `MESSAGE` 信封时,后台任务入口与面板 SHALL 显示未读数;root 下一回合消费邮箱后 SHALL 清零。 + +#### Scenario: 子 agent 在 root 空闲后完成 +- **WHEN** `/root` 已空闲,随后 `/root/a` 与 `/root/b` 相继完成 +- **THEN** 后台任务入口显示未读数 2 +- **AND** 用户向 `/root` 发送下一条消息后未读数归零 + +### Requirement: 跨 agent 消息在 transcript 中以独立行呈现 +收件方的 Web/Desktop 与 TUI transcript(含恢复回放)SHALL 把跨 agent 信封渲染为独立的消息行,显示消息类型与发件人路径,MUST NOT 渲染为用户气泡。会话摘要(侧栏与列表使用的最后一条用户消息截断)MUST NOT 取自跨 agent 信封。 + +#### Scenario: 收件方看到来源 +- **WHEN** `/root/a` 收到来自 `/root` 的 `MESSAGE` 信封 +- **THEN** `/root/a` 的 transcript 出现一条标注「来自 /root · 消息」的独立行 +- **AND** 该行不使用用户消息的气泡样式 + +#### Scenario: 恢复后的呈现一致 +- **WHEN** 用户恢复一个曾收到信封的子 agent 会话 +- **THEN** 信封行以相同的独立样式回放 +- **AND** 该会话的摘要仍为其最后一条真实用户消息 + +### Requirement: 任意层级的权限请求与提问冒泡到 root 界面 +任意深度子 agent 的权限请求与 AskUserQuestion SHALL 出现在 root 会话所在的界面(Web/Desktop 全局弹窗、TUI 确认/提问浮层),来源标签 SHALL 为该 agent 的 canonical 路径;用户的回答 SHALL 路由回发起请求的 agent。 + +#### Scenario: 二级子 agent 请求权限 +- **WHEN** `/root/explore/tests` 请求执行写文件工具的权限 +- **THEN** root 界面弹出权限确认并标注来源 `/root/explore/tests` +- **AND** 用户允许后该工具在 `/root/explore/tests` 中执行 + +#### Scenario: 子 agent 向用户提问 +- **WHEN** `/root/a` 调用 AskUserQuestion +- **THEN** 提问出现在 root 界面并标注来源 `/root/a` +- **AND** 用户的回答返回给 `/root/a` + +### Requirement: 网状子 agent 的标题与隐藏规则 +网状子 agent 的会话标题 SHALL 为其 `task_name`,MUST NOT 为其触发自动起标题的模型调用;用户仍可手动改名。任意层级的子 agent 会话 MUST 继续从常规会话列表、侧栏与全局搜索中隐藏,只在其 root 会话的后台任务面板中出现。 + +#### Scenario: 派生不触发起标题 +- **WHEN** `/root` 派生 `task_name="review_tests"` 的子 agent +- **THEN** 该子会话标题为 `review_tests` +- **AND** 没有发起隐藏的标题生成请求 + +#### Scenario: 二级子 agent 不出现在侧栏 +- **WHEN** `/root/explore/tests` 存在 +- **THEN** 侧栏与全局搜索结果均不包含它 +- **AND** 它出现在 root 会话的后台任务面板中 + +### Requirement: 子 agent 完成不触发桌面通知 +子 agent 回合完成 MUST NOT 触发桌面完成通知;root 的回合完成与提问通知按现有规则处理。 + +#### Scenario: 子 agent 完成 +- **WHEN** `/root/a` 完成回合而 root 仍在运行 +- **THEN** 不产生桌面通知 +- **AND** root 回合完成时按现有规则通知 + +### Requirement: TUI /tasks 展示网状 agent +TUI 的 `/tasks` 与 `/tasks list` SHALL 为网状 agent 显示 canonical 路径与统一状态;`/tasks abort ` 对网状 agent SHALL 等价于中止其当前回合并保留该 agent。 + +#### Scenario: 列出网状 agent +- **WHEN** 用户在 `mesh` 模式的 TUI 会话中输入 `/tasks` +- **THEN** 每个子 agent 一行,显示路径与状态 +- **AND** 被换出的 agent 标注为 `inactive` diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md new file mode 100644 index 000000000..1c2310ade --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md @@ -0,0 +1,73 @@ +## Purpose + +定义蜂群模式(网状)下每棵 agent 树同时驻留在内存中的 agent 数量上限、超限时的换出与透明恢复行为、agent 状态的统一枚举,以及 daemon 重启后树状态如何重建。 + +## ADDED Requirements + +### Requirement: 每棵树的驻留上限只约束已加载的 agent +每个 root 会话树 SHALL 最多同时驻留 `swarm.mesh.max_concurrent_agents` 个已加载的 agent(含 root),默认 4,配置值 MUST 不小于 2。root MUST NOT 被换出。逻辑树的规模 MUST NOT 受该上限限制:已被换出的 agent 不占驻留槽,仍保留在树目录中。系统 MUST NOT 施加跨会话树的 daemon 全局上限。 + +#### Scenario: 超限时换出空闲 agent +- **WHEN** 上限为 4,root 与三个空闲子 agent 已驻留,root 调用 `agent_spawn` +- **THEN** 最久未活动的空闲子 agent 被换出 +- **AND** 新 agent 创建成功,驻留数保持不超过 4 + +#### Scenario: 全部驻留 agent 都在运行 +- **WHEN** 上限为 4,root 与三个子 agent 均在运行,root 调用 `agent_spawn` +- **THEN** 调用返回上限错误,说明当前没有可换出的空闲 agent,建议先等待 +- **AND** 不创建新的会话 + +#### Scenario: 多棵树互不影响 +- **WHEN** 同一 daemon 内两个不同 root 会话各自驻留 4 个 agent +- **THEN** 两棵树都不被拒绝 +- **AND** 任一树的驻留数不影响另一树的上限判定 + +### Requirement: 换出只针对空闲 agent 且不丢失记录 +换出 SHALL 只选择没有运行中回合、且邮箱中没有待触发后续任务的非 root agent,按最久未活动优先。换出 SHALL 先确保该 agent 的会话记录与身份已落盘,再卸载其运行实例;换出后该 agent 的状态 SHALL 为 `inactive`,其历史、路径与身份 MUST 保持可读。 + +#### Scenario: 换出后仍可查看 +- **WHEN** `/root/a` 被换出 +- **THEN** `agent_list` 仍列出 `/root/a` 且状态为 `inactive` +- **AND** 用户仍能在后台任务面板打开其只读对话记录 + +#### Scenario: 有待触发任务的 agent 不被换出 +- **WHEN** `/root/a` 空闲但邮箱中有一条尚未开始处理的 `NEW_TASK` +- **THEN** 换出选择跳过 `/root/a` +- **AND** 若无其它可换出对象则返回上限错误 + +### Requirement: 被换出的 agent 在被寻址时透明恢复 +对 `inactive` agent 的 `agent_send_message` 与 `agent_followup_task` SHALL 先为其取得驻留槽并从会话记录恢复运行实例,再执行投递;恢复后的 agent SHALL 保有原路径、历史、蜂群模式与专家身份。被中断后换出的 agent 同样 SHALL 可恢复。`agent_interrupt` 与 `agent_list` MUST NOT 为了执行而恢复 `inactive` agent。 + +#### Scenario: 后续任务恢复被换出的 agent +- **WHEN** `/root` 对 `inactive` 的 `/root/a` 调用 `agent_followup_task` +- **THEN** `/root/a` 被恢复并以该任务启动新回合 +- **AND** 其上下文包含换出前的全部历史 + +#### Scenario: 恢复受上限约束 +- **WHEN** 驻留已满且无可换出对象,`/root` 对 `inactive` 的 `/root/a` 调用 `agent_followup_task` +- **THEN** 调用返回上限错误 +- **AND** `/root/a` 保持 `inactive`,任务不丢失地留在回执中要求稍后重试 + +### Requirement: 统一的 agent 状态枚举 +所有报告 agent 状态的接口 SHALL 使用同一枚举:`pending_init`(已创建尚未开始首个回合)、`running`、`idle`(回合正常结束)、`interrupted`、`errored`、`inactive`(已换出或 daemon 重启后未加载)、`not_found`。`agent_list`、协作工具回执、会话状态接口与后台任务面板 MUST 使用相同取值。 + +#### Scenario: 状态一致 +- **WHEN** `/root/a` 因用户中止而结束回合 +- **THEN** `agent_list` 报告 `interrupted` +- **AND** 后台任务面板与会话状态接口报告同一取值 + +### Requirement: 驻留槽的并发占用不得超限 +驻留槽 SHALL 在创建会话或启动恢复之前原子预留,成功后提交、失败时释放;并发的 `agent_spawn` 与恢复请求 MUST NOT 使已加载 agent 数超过上限。 + +#### Scenario: 争夺最后一个槽 +- **WHEN** 两个子 agent 同时调用 `agent_spawn` 且只剩一个可用槽、无可换出对象 +- **THEN** 恰好一个调用成功 +- **AND** 另一个返回上限错误且不创建会话 + +### Requirement: daemon 重启后重建树状态 +daemon 重启后 SHALL 从会话元数据重建每棵树的目录(路径、父子关系、蜂群模式),所有节点初始为 `inactive`;邮箱内容为运行时状态,重启后 MAY 丢失。`agent_followup_task` SHALL 能恢复重建的节点;`agent_list` 与 `agent_wait` MUST NOT 为此自动恢复任何节点。 + +#### Scenario: 重启后列出树 +- **WHEN** daemon 重启后用户恢复 `mesh` 模式的 root 会话并调用 `agent_list` +- **THEN** 结果按原路径列出全部子 agent 且状态为 `inactive` +- **AND** 没有子 agent 因该调用被恢复 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md new file mode 100644 index 000000000..04baaa6e8 --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md @@ -0,0 +1,119 @@ +## Purpose + +定义用户如何为一个会话选择「蜂群模式(星型)」或「蜂群模式(网状)」,模式如何持久化、恢复并被子 agent 继承,以及星型与网状两套协作工具如何按模式互斥地暴露给模型。 + +## ADDED Requirements + +### Requirement: 会话级蜂群模式三态且互斥 +每个会话 SHALL 持有且只持有一个蜂群模式值:`off`、`star`(蜂群模式(星型))或 `mesh`(蜂群模式(网状))。选择其中一种 MUST 替换当前值;两种蜂群模式 MUST NOT 同时生效。 + +#### Scenario: 从星型切换到网状 +- **WHEN** 会话当前模式为 `star`,用户选择「蜂群模式(网状)」 +- **THEN** 会话模式变为 `mesh` +- **AND** 星型模式的引导与工具在后续回合中不再生效 + +#### Scenario: 关闭蜂群模式 +- **WHEN** 用户移除已选中的蜂群模式标签或执行 `/swarm off` +- **THEN** 会话模式变为 `off` +- **AND** 后续回合不注入任何蜂群引导 + +### Requirement: 模式随会话持久化并可恢复 +蜂群模式 SHALL 写入会话元数据;恢复会话 SHALL 还原该值;会话列表与状态接口 SHALL 暴露当前模式。缺失该字段的旧元数据 SHALL 读取为 `off`。 + +#### Scenario: daemon 重启后恢复网状会话 +- **WHEN** 一个模式为 `mesh` 的会话在 daemon 重启后被恢复 +- **THEN** 该会话的模式仍为 `mesh` +- **AND** 恢复后的第一个回合即可使用网状协作工具 + +#### Scenario: 读取旧元数据 +- **WHEN** 加载一个没有蜂群模式字段的旧会话元数据 +- **THEN** 会话模式读取为 `off` +- **AND** 序列化时 `off` 省略该字段 + +### Requirement: 子 agent 继承派生时的模式 +网状模式派生的子 agent SHALL 继承 `mesh` 并获得同一套网状协作工具。星型模式派生的子代理 SHALL 保持现有行为:不注入蜂群引导,不能再派生子代理。 + +#### Scenario: 网状 root 派生子 agent +- **WHEN** 模式为 `mesh` 的会话调用 `agent_spawn` +- **THEN** 新子 agent 的模式为 `mesh` +- **AND** 子 agent 可以调用 `agent_spawn`、`agent_list`、`agent_send_message`、`agent_followup_task`、`agent_wait`、`agent_interrupt` + +#### Scenario: 星型 root 派生子代理 +- **WHEN** 模式为 `star` 的会话调用 `spawn_subagent` +- **THEN** 子代理的模式为 `off` +- **AND** 子代理调用 `spawn_subagent` 被以「子代理不能再派生」拒绝 + +### Requirement: 星型模式行为保持不变 +模式为 `star` 的回合 SHALL 注入现有的主动委派引导(早期有界扇出、`wait=false` 配合 `wait_subagent`、不重叠的范围、主 agent 负责整合验证),协作工具 SHALL 仅为 `spawn_subagent` 与 `wait_subagent`,MUST NOT 暴露任何 `agent_*` 工具。除显示名称外,星型模式 MUST NOT 与本变更之前的「蜂群模式」有任何行为差异。 + +#### Scenario: 星型回合的工具与引导 +- **WHEN** 模式为 `star` 的会话发起一个回合 +- **THEN** 模型请求包含现有的 Swarm Mode 引导段 +- **AND** 工具列表包含 `spawn_subagent` 与 `wait_subagent`,不包含任何 `agent_*` 工具 + +### Requirement: 网状回合只暴露网状工具 +模式为 `mesh` 的回合 SHALL 暴露 `agent_spawn`、`agent_list`、`agent_send_message`、`agent_followup_task`、`agent_wait`、`agent_interrupt` 六个工具,MUST NOT 暴露 `spawn_subagent`、`wait_subagent` 以及全部 thread 工具(`create_thread`、`fork_thread`、`list_threads`、`read_thread`、`send_message_to_thread`、`wait_threads`、`set_thread_title`、`set_thread_pinned`、`set_thread_archived`、`delete_thread`、`repair_thread`)。非网状回合 MUST NOT 暴露六个 `agent_*` 工具。对当前模式下被隐藏工具的调用 SHALL 被拒绝,并在结果中说明该工具在当前蜂群模式下不可用。 + +#### Scenario: 网状回合的工具列表 +- **WHEN** 模式为 `mesh` 的会话发起一个回合 +- **THEN** 模型请求的工具列表包含六个 `agent_*` 工具 +- **AND** 不包含 `spawn_subagent`、`wait_subagent` 与任何 thread 工具 + +#### Scenario: 网状回合调用星型工具 +- **WHEN** 模型在 `mesh` 回合中调用 `spawn_subagent` +- **THEN** 调用被拒绝 +- **AND** 结果说明该工具在蜂群模式(网状)下不可用,应改用 `agent_spawn` + +#### Scenario: 非网状回合调用网状工具 +- **WHEN** 模型在 `off` 或 `star` 回合中调用 `agent_send_message` +- **THEN** 调用被拒绝 +- **AND** 结果说明该工具仅在蜂群模式(网状)下可用 + +### Requirement: 树内仍有工作时禁止关闭网状模式 +当会话树内仍有运行中的子 agent,或仍有未投递的后续任务时,把模式从 `mesh` 改为其它值的请求 SHALL 被拒绝并说明原因;会话模式保持 `mesh`。Web 消息接口 SHALL 以 HTTP 409 拒绝,`/swarm` 命令 SHALL 以命令错误拒绝。 + +#### Scenario: 子 agent 运行中关闭网状 +- **WHEN** 会话树内有一个子 agent 正在运行,用户提交把模式改为 `off` 或 `star` 的请求 +- **THEN** 请求被拒绝并说明仍有子 agent 在运行 +- **AND** 会话模式保持 `mesh` + +#### Scenario: 子 agent 全部结束后关闭网状 +- **WHEN** 会话树内没有运行中的子 agent 且没有待投递的后续任务,用户把模式改为 `off` +- **THEN** 请求被接受 +- **AND** 会话模式变为 `off` + +### Requirement: 各表面提供模式入口 +Web/Desktop 输入框的 `+` 菜单 SHALL 提供「蜂群模式(星型)」与「蜂群模式(网状)」两个互斥单选项,选中后输入区 SHALL 显示对应的可移除标签,选择 SHALL 随下一条消息提交并成为会话模式。TUI SHALL 提供 `/swarm` 显示当前模式,`/swarm star|mesh|off` 切换模式,无效参数 SHALL 报错并列出合法值。headless 打印模式 SHALL 提供 `--swarm star|mesh`,缺省为 `off`,无效值 SHALL 以 usage 错误退出。 + +#### Scenario: 菜单单选 +- **WHEN** 用户在 `+` 菜单中已选中「蜂群模式(星型)」,再点击「蜂群模式(网状)」 +- **THEN** 菜单中只有「蜂群模式(网状)」处于选中态 +- **AND** 输入区标签文案变为「蜂群模式(网状)」 + +#### Scenario: TUI 切换模式 +- **WHEN** 用户在 TUI 输入 `/swarm mesh` +- **THEN** 会话模式变为 `mesh` 并回显当前模式 +- **AND** 输入 `/swarm foo` 时报错并列出 `star`、`mesh`、`off` + +#### Scenario: headless 指定模式 +- **WHEN** 用户运行 `acecode -p --swarm mesh ""` +- **THEN** 新会话以 `mesh` 模式运行 +- **AND** 运行 `acecode -p --swarm foo ""` 时以 usage 错误退出且不执行 prompt + +### Requirement: 消息接口的 swarm_mode 契约 +`POST /api/sessions/:id/messages` 的可选字段 `swarm_mode` SHALL 接受 `"star"`、`"mesh"`、`false`,并为兼容旧客户端接受 `true`(等价于 `"star"`)。其它取值 SHALL 以 HTTP 400 拒绝。接受后 SHALL 更新会话模式,并把生效的模式记录到该用户消息的元数据中。省略该字段的消息 SHALL 沿用会话当前模式。 + +#### Scenario: 旧客户端发送布尔 true +- **WHEN** 请求体包含 `"swarm_mode": true` +- **THEN** 会话模式变为 `star` +- **AND** 该用户消息的元数据记录 `swarm_mode` 为 `"star"` + +#### Scenario: 非法取值 +- **WHEN** 请求体包含 `"swarm_mode": "ring"` +- **THEN** 接口返回 HTTP 400 +- **AND** 错误说明合法取值为 `"star"`、`"mesh"` 或 `false` + +#### Scenario: 后续消息省略字段 +- **WHEN** 会话已处于 `mesh`,下一条消息不包含 `swarm_mode` +- **THEN** 该回合仍以 `mesh` 模式运行 +- **AND** 会话模式保持 `mesh` diff --git a/openspec/changes/add-mesh-swarm-mode/tasks.md b/openspec/changes/add-mesh-swarm-mode/tasks.md new file mode 100644 index 000000000..70c059f7f --- /dev/null +++ b/openspec/changes/add-mesh-swarm-mode/tasks.md @@ -0,0 +1,74 @@ +## 1. 模式枚举、身份字段与路径 + +- [ ] 1.1 新增 `SwarmMode { Off, Star, Mesh }` 及字符串互转,扩展 `SessionMeta`(`swarm_mode`、`agent_root_session_id`、`agent_task_name`、`agent_path`、`agent_depth`,空值省略)、`SessionOptions`、`SessionEntry`、`SessionInfo`;验证 `tests/session/session_storage_test.cpp` 新增的往返用例通过且旧 meta 读取为 `off` +- [ ] 1.2 新增 `src/session/agent_path.{hpp,cpp}`:`root`/`is_root`/`join`/`resolve`/任务名校验/legacy 路径合成;验证 `tests/session/agent_path_test.cpp` 覆盖嵌套、相对解析、非法名、`root`/`.`/`..` 拒绝 +- [ ] 1.3 `SessionRegistry::make_entry_locked` 从 `SessionOptions` 与恢复的 meta 写入模式与身份并透传到 `SessionManager`/`AgentLoop`;验证 `tests/tool/spawn_subagent_tool_test.cpp` 风格的 resume 用例恢复出相同路径与模式 +- [ ] 1.4 消息路由 `routes_sessions.cpp` 把 `swarm_mode` 解析为 `"star"|"mesh"|false|true(=star)`,非法值 400,接受后更新会话模式并写入用户消息 metadata,省略时沿用会话模式;验证 `web_server_smoke_test.cpp` 覆盖四种取值与 sticky 行为 + +## 2. 工具互斥与模式切换 + +- [ ] 2.1 `ToolCapabilityPolicy` 增加 `hidden_builtin_tools` 并在 `ToolExecutor::is_allowed` 中生效;验证 `tests/tool/` 新增用例:隐藏集内工具从 schema 消失且执行被拒 +- [ ] 2.2 按模式计算隐藏集(`off`/`star` 隐藏 `agent_*`;`mesh` 隐藏 `spawn_subagent`、`wait_subagent` 与全部 thread 工具),模式切换经 `enqueue_control` 重设 policy;验证 AgentLoop 测试中切换后下一回合的工具列表变化、当前回合不变 +- [ ] 2.3 被隐藏工具的调用返回带模式说明的拒绝文案(星型工具指向 `agent_spawn`,网状工具说明仅在网状可用);验证单测断言文案 +- [ ] 2.4 `MeshTreeControl::has_running_or_pending_children` 作为退出 mesh 的门:消息路由返回 409,`/swarm` 返回命令错误;验证 smoke test 与命令测试 + +## 3. AgentLoop 邮箱与投递 + +- [ ] 3.1 在 `AgentLoop` 增加跨 agent 邮箱(信封队列、`Mailbox`/`Steer` 活动、condvar),`deliver(mail)` 在回合锁下判定「排入当前回合 / 开新回合 / 留箱」;验证 `tests/agent_loop_*` 新增用例覆盖运行中、空闲触发、空闲不触发三种路径 +- [ ] 3.2 `drain_active_turn_inputs` 在 steering 之后消费邮箱,`run_agent_with_input` 开始时先 drain;验证信封出现在下一次模型请求且顺序为 steering 先、邮箱后 +- [ ] 3.3 busy→idle 竞态:`close_if_empty` 与 `deliver` 同锁,后续任务恰好一次;验证状态机测试三种时序(关闭前入箱、关闭后入箱、同时)均恰好投递一次 +- [ ] 3.4 `steer_input` 入队时通知邮箱 condvar;`wait_for_mailbox_activity(deadline, abort_flag)` 返回活动类型不出队;验证等待被邮箱、steer、abort、超时四种方式结束 +- [ ] 3.5 把 `hidden_goal_context` 泛化为隐藏内部输入(不触发 UserPromptSubmit、不计 `turn_count`、不做技能展开、不入摘要),信封以 `role=user` + `metadata.inter_agent` 持久化;验证 `RequestPrefixIsByteStableAcrossIterationsInATurn` 仍通过且新增用例断言 turn_count 不变 +- [ ] 3.6 检查 `AgentLoopDoomGuard` 对重复 `agent_wait` 调用的判定,必要时按工具名豁免;验证连续 5 次同参 `agent_wait` 不触发守卫 + +## 4. MeshTreeControl 与驻留 + +- [ ] 4.1 新增 `src/session/mesh_tree_control.{hpp,cpp}`:按 root 的 `TreeState`(目录、状态、LRU、pending 槽)、目标解析(会话 id 优先、再路径、必须同根)、名称冲突检测;验证纯逻辑单测覆盖解析、冲突、跨树拒绝且不泄露信息 +- [ ] 4.2 `reserve_slot`:预留 → 换出 LRU 空闲(非 root、`!is_busy()`、邮箱无触发项)→ 上限错误;换出走 `registry.destroy` 并置 `inactive`;验证测试:3 空闲 + spawn 换出最久未用者、全部运行时报错且不创建会话 +- [ ] 4.3 `ensure_loaded`:`inactive` → 预留槽 → `registry.resume`(带身份、模式、专家)→ `on_agent_loaded` 回调;验证恢复后路径/历史/模式一致,槽满时返回上限错误且目标保持 `inactive` +- [ ] 4.4 spawn 两阶段(持锁校验与预留 → 锁外 create/fork + 首条 `NEW_TASK` → 持锁提交;失败回滚并清理半成品会话);验证并发测试:两方争最后一个槽恰好一个成功、失败路径不留幽灵节点 +- [ ] 4.5 状态推导(`pending_init/running/idle/interrupted/errored/inactive/not_found`)来自 `BusyChanged`/`Done` 的 outcome 与 `is_aborting()`;验证每种终态映射用例 +- [ ] 4.6 完成回调:`on_child_turn_finished` 构造 `FINAL_ANSWER`(正常 = 最后一条 assistant 文本;错误/中断 = 状态说明 + 部分输出)以 `trigger_turn=false` 投父邮箱并 emit 带 `unread_mailbox` 的 `session_status`;验证父空闲不被唤醒、父等待被唤醒、错误结束时信封含状态 +- [ ] 4.7 daemon 启动扫描 `agent_root_session_id` 重建目录为 `inactive`;验证重启测试中 `agent_list` 按原路径列出且无自动恢复 +- [ ] 4.8 配置 `swarm.mesh.max_concurrent_agents`(默认 4,最小 2)与 `swarm.mesh.{min,default,max}_wait_timeout_ms`(10000/30000/3600000);验证 `tests/config/` 读取、默认值与越界校验 + +## 5. 六个协作工具 + +- [ ] 5.1 新增 `src/tool/mesh_agent_tools.{hpp,cpp}` 与 `MeshAgentToolDeps`(延迟回填,与 `SubagentToolDeps` 同法),三处注册(`worker.cpp`、`main.cpp`、`headless_runner.cpp`);验证 `--list-tools` 与工具注册测试列出六个工具 +- [ ] 5.2 `agent_spawn(task_name, message, agent_type?, fork_turns?, model?, reasoning_effort?)`:校验、预留、创建/fork、首条 `NEW_TASK`、立即返回路径/会话 id/状态,结果带 `summary.icon="agent"` 与 `metadata.subagent_session_id`/`agent_path`;验证二级派生成功、重名与非法名拒绝、未知模型拒绝、不阻塞 +- [ ] 5.3 `agent_list(path_prefix?)` 返回含 root 与 `inactive` 节点的有序目录;验证兄弟可见与前缀过滤 +- [ ] 5.4 `agent_send_message(target, message)`:入箱不唤醒,可发 root,`inactive` 先恢复;验证运行中目标下一请求可见、空闲 root 保持空闲、恢复路径 +- [ ] 5.5 `agent_followup_task(target, message)`:禁止 root,空闲/完成/`inactive` 开新回合,运行中边界投递;验证三种目标状态与 root 拒绝文案 +- [ ] 5.6 `agent_wait(timeout_ms?)`:钳制 [10s, 1h] 默认 30s,返回三种固定文案;验证邮箱唤醒、steer 打断、超时、abort 结束且不影响其它 agent +- [ ] 5.7 `agent_interrupt(target)`:禁止 root 与自身,返回中止前状态,空闲目标不报错;验证运行中中止后仍可接后续任务 +- [ ] 5.8 `agent_type` → 专家团成员映射(任意层级可用,非法 id 列出可选成员,未绑定专家团报错);验证以现有 team expert fixture 的用例 +- [ ] 5.9 六个工具的描述文案对照 Codex `multi_agents_spec.rs` V2 描述改写为 `agent_*` 名;验证单测断言关键句(相对/canonical 路径说明、不触发回合、禁止 root) + +## 6. fork_turns 与提示词 + +- [ ] 6.1 实现 `fork_turns` 过滤器(保留真实用户消息与无 tool_calls 的 assistant 最终回复;丢弃 tool/system/meta/压缩/信封;N = 最近 N 个用户回合)并接 `fork_session_to_new_id` + `resume`;验证 `none`/`all`/`"2"`/非法值四个用例 +- [ ] 6.2 新增 `src/prompt/mesh_swarm_prompts.{hpp,cpp}`:移植 root/子 agent 角色提示、主动委派文案、等待与并发槽提示并改用 `agent_*` 名;`build_mesh_swarm_context_prompt` 在现有 swarm 引导同位置注入,`star` 仍走 `build_swarm_mode_context_prompt`;验证 `tests/prompt/system_prompt_test.cpp`:root/子提示内容、静态前缀逐字节不变、同回合两次请求逐字节相同 +- [ ] 6.3 网状子会话标题 = `task_name`,跳过 `maybe_start_auto_title`;验证 registry 测试中派生后无标题生成尝试且标题为任务名 + +## 7. Web/Desktop 入口与面板 + +- [ ] 7.1 `InputBar.jsx` `+` 菜单改为「蜂群模式(星型)」「蜂群模式(网状)」两个 `menuitemradio`,`ComposerSessionControls.jsx` 芯片按模式显示文案与关闭;`ChatView.jsx` 的 `composerSwarmMode` 改枚举并从 `SessionInfo.swarm_mode` 恢复,`normalizeComposerPayload` 输出字符串;验证 `composerSessionControlsArchitecture.test.js`、`chatInputQueue.test.js` 更新用例通过 +- [ ] 7.2 `chatInputQueue.js` 透传字符串取值并保留旧布尔兼容;验证队列重建/重试/引导保留模式 +- [ ] 7.3 `subagentTasks.js` 归一化 `agentPath`、`agentDepth`、`agentStatus`、`unreadMailbox`,分组按统一状态、排序按路径;`SubagentPanel.jsx` 按深度缩进、显示路径/状态/最后一条消息/tokens/耗时,`inactive` 可只读查看;`useSubagentTasks.js` 对恢复后的会话重新 retain;验证 `subagentTasks.test.js` 新增缩进/分组/未读用例 +- [ ] 7.4 ChatView 标题栏后台任务入口显示 root 未读数并在 root 回合开始后清零;验证前端测试 +- [ ] 7.5 `Message.jsx` 识别 `metadata.inter_agent` 渲染独立行(类型 + 发件人路径),`sessionTranscript.js` 摘要与折叠投影排除信封;验证渲染测试与 resume 回放一致性用例 +- [ ] 7.6 `web/scripts/i18n-en-overrides.mjs` 增加 `蜂群模式(星型)`/`蜂群模式(网状)`及提示文案的英文(`Swarm mode (Star)` / `Swarm mode (Mesh)`),运行 `pnpm i18n:catalog`;验证 `pnpm i18n:audit` 无缺失 + +## 8. TUI 与 headless + +- [ ] 8.1 注册 `/swarm [star|mesh|off]`(TUI 命令注册表、daemon `execute_builtin_command`、Web 斜杠目录 `commands_handler.cpp`),无参显示当前模式,非法参数列出合法值;验证命令测试三端 +- [ ] 8.2 `SubagentHost` 处理 `on_agent_loaded` 重订阅,任务标题优先路径,`/tasks` 显示路径与统一状态,`/tasks abort` 对网状 agent 等价于中止回合;验证 `tests/tui/subagent_host_test.cpp` 新用例 +- [ ] 8.3 确认/提问浮层与 Web 全局弹窗的来源标签使用 canonical 路径,任意深度冒泡到 root;验证二级子 agent 的权限与提问路由测试 +- [ ] 8.4 headless `--swarm star|mesh`(缺省 off,非法值 exit 64)透传到 `SessionOptions::swarm_mode`;验证 `tests/headless/` 参数解析与帮助文本用例 +- [ ] 8.5 星型子代理的 `SessionOptions::swarm_mode` 为 `off`,`spawn_subagent` 深度规则与 `build_swarm_mode_context_prompt` 文案不变;验证现有 `SwarmModeContextIsRequestLocalAndPolicyGated` 与 spawn 深度用例原样通过 + +## 9. 文档与收尾 + +- [ ] 9.1 更新 `docs/subagents.md`(两种模式、六个工具、邮箱与驻留语义)、`docs/daemon-api.md`(`swarm_mode` 取值、`SessionSummary` 新字段、`session_status` 未读字段)、`docs/help/swarm.html` 文案与 `CLAUDE.md` 子代理段落;验证文档中的工具名与代码注册名一致 +- [ ] 9.2 单元测试全部使用中文注释并写明触发场景与期望行为;验证 `cmake --build` 与 `acecode_unit_tests` 全绿,`pnpm test`、`pnpm build` 通过 +- [ ] 9.3 运行 `openspec validate add-mesh-swarm-mode --strict` 与 `scripts/code_quality_check.bat`;验证无报错,并用 `git add -f openspec/changes/add-mesh-swarm-mode` 把变更文档随分支提交(`openspec/` 在 `.gitignore` 中) From 4d465972cae6758ced0c3174662e14c3746ec005 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sat, 3 Oct 2026 00:55:18 +0800 Subject: [PATCH 2/3] feat(mesh): add session swarm context, agent paths and inter-agent mailbox - domain/session: AgentPath (Codex agent_path.rs rules), SwarmMode with the mutually exclusive tool families, inter-agent envelope rendering, swarm_mode and mesh_agent.path session metadata - engine/agent: session-scoped AgentMailbox drained at model boundaries, deduplicated mailbox-wake turns, agent_wait activity, envelope transcript rows - swarm mode moves from a per-turn message flag to the captured request source; ToolCapabilityPolicy hides the other tool family with mode-specific refusals - engine/prompt: mesh role/mode instructions ported from Codex Multi-Agent V2 --- src/adapters/tool/tool_executor.cpp | 20 +- src/adapters/tool/tool_executor.hpp | 8 + src/domain/llm/message_predicates.cpp | 7 + src/domain/session/agent_path.cpp | 131 ++++++++++++ src/domain/session/agent_path.hpp | 52 +++++ src/domain/session/inter_agent_message.cpp | 67 +++++++ src/domain/session/inter_agent_message.hpp | 47 +++++ src/domain/session/session_manager.cpp | 9 +- src/domain/session/session_manager.hpp | 10 + src/domain/session/session_manager_agent.cpp | 42 ++++ src/domain/session/session_storage.cpp | 10 +- src/domain/session/session_storage.hpp | 7 + src/domain/session/swarm_mode.cpp | 60 ++++++ src/domain/session/swarm_mode.hpp | 30 +++ src/engine/agent/agent_loop.cpp | 4 + src/engine/agent/agent_loop.hpp | 22 ++- src/engine/agent/agent_loop_services.hpp | 2 + .../agent/compaction/agent_compaction.cpp | 2 +- src/engine/agent/mailbox/agent_mailbox.cpp | 89 +++++++++ src/engine/agent/mailbox/agent_mailbox.hpp | 60 ++++++ .../agent/mailbox/agent_mailbox_control.cpp | 51 +++++ src/engine/agent/request/agent_request.cpp | 6 +- .../agent/request/api_request_builder.cpp | 14 +- .../agent/request/api_request_builder.hpp | 3 +- .../agent/request/request_context_factory.cpp | 8 +- .../agent/request/request_context_factory.hpp | 5 +- .../agent/request/request_context_source.hpp | 2 + src/engine/agent/request/swarm_context.hpp | 20 ++ src/engine/agent/tool_exec/tool_invoker.cpp | 3 +- .../agent/transcript/transcript_writer.cpp | 36 +++- .../agent/transcript/transcript_writer.hpp | 3 + src/engine/agent/turn/active_turn_gate.cpp | 5 + src/engine/agent/turn/active_turn_gate.hpp | 1 + src/engine/agent/turn/agent_turn.cpp | 3 +- src/engine/agent/turn/turn_context.hpp | 1 - src/engine/agent/turn/turn_finalizer.cpp | 1 - src/engine/agent/turn/turn_lifecycle.cpp | 4 +- src/engine/agent/turn/turn_runner.cpp | 19 +- src/engine/agent/turn/turn_runner.hpp | 3 + src/engine/agent/turn/turn_runner_support.cpp | 18 +- src/engine/agent/turn/turn_steering.cpp | 11 +- src/engine/agent/turn/turn_types.hpp | 3 + src/engine/agent/worker/agent_task_queue.cpp | 18 +- src/engine/agent/worker/agent_task_queue.hpp | 2 + src/engine/agent/worker/agent_worker.cpp | 9 + src/engine/prompt/mesh_swarm_prompts.cpp | 97 +++++++++ src/engine/prompt/mesh_swarm_prompts.hpp | 28 +++ .../agent/characterization_lifecycle_test.cpp | 23 ++- tests/session/agent_path_test.cpp | 128 ++++++++++++ tests/session/inter_agent_message_test.cpp | 187 ++++++++++++++++++ 50 files changed, 1332 insertions(+), 59 deletions(-) create mode 100644 src/domain/session/agent_path.cpp create mode 100644 src/domain/session/agent_path.hpp create mode 100644 src/domain/session/inter_agent_message.cpp create mode 100644 src/domain/session/inter_agent_message.hpp create mode 100644 src/domain/session/session_manager_agent.cpp create mode 100644 src/domain/session/swarm_mode.cpp create mode 100644 src/domain/session/swarm_mode.hpp create mode 100644 src/engine/agent/mailbox/agent_mailbox.cpp create mode 100644 src/engine/agent/mailbox/agent_mailbox.hpp create mode 100644 src/engine/agent/mailbox/agent_mailbox_control.cpp create mode 100644 src/engine/agent/request/swarm_context.hpp create mode 100644 src/engine/prompt/mesh_swarm_prompts.cpp create mode 100644 src/engine/prompt/mesh_swarm_prompts.hpp create mode 100644 tests/session/agent_path_test.cpp create mode 100644 tests/session/inter_agent_message_test.cpp diff --git a/src/adapters/tool/tool_executor.cpp b/src/adapters/tool/tool_executor.cpp index 7d41962bf..d250f2ebd 100644 --- a/src/adapters/tool/tool_executor.cpp +++ b/src/adapters/tool/tool_executor.cpp @@ -109,6 +109,7 @@ bool tool_allowed_by_policy(const ToolImpl& impl, const ToolCapabilityPolicy* policy) { if (!policy) return true; if (impl.source == ToolSource::Builtin) { + if (policy->hidden_builtin_tools.count(impl.definition.name) != 0) return false; return !policy->builtin_tools || policy->builtin_tools->count(impl.definition.name) != 0; } @@ -117,11 +118,9 @@ bool tool_allowed_by_policy(const ToolImpl& impl, policy->mcp_servers->count(impl.source_owner) != 0); } -ToolResult expert_policy_denied_result(const std::string& tool_name) { - return ToolResult{ - "[Error] Tool denied by the active expert capability policy: " + - tool_name, - false}; +ToolResult expert_policy_denied_result(const std::string& tool_name, + const ToolCapabilityPolicy* policy) { + return ToolResult{ToolExecutor::policy_denial_text(tool_name, policy), false}; } } // namespace @@ -396,6 +395,15 @@ bool ToolExecutor::is_allowed(const std::string& name, return it != tools_.end() && tool_allowed_by_policy(it->second, policy); } +std::string ToolExecutor::policy_denial_text(const std::string& name, + const ToolCapabilityPolicy* policy) { + if (policy) { + const auto hidden = policy->hidden_builtin_tools.find(name); + if (hidden != policy->hidden_builtin_tools.end()) return "[Error] " + hidden->second; + } + return "[Error] Tool denied by the active expert capability policy: " + name; +} + bool ToolExecutor::is_denied_by_policy( const std::string& name, const ToolCapabilityPolicy* policy) const { @@ -426,7 +434,7 @@ ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string if (!tool_allowed_by_policy(it->second, policy)) { LOG_WARN("execute: expert capability policy denied tool '" + tool_name + "'"); - auto result = expert_policy_denied_result(tool_name); + auto result = expert_policy_denied_result(tool_name, policy); ensure_tool_summary(tool_name, arguments_json, result); return result; } diff --git a/src/adapters/tool/tool_executor.hpp b/src/adapters/tool/tool_executor.hpp index d3325d54c..4e3fbaf4f 100644 --- a/src/adapters/tool/tool_executor.hpp +++ b/src/adapters/tool/tool_executor.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace acecode { @@ -38,6 +39,9 @@ enum class ToolSource { struct ToolCapabilityPolicy { std::optional> builtin_tools; std::optional> mcp_servers; + // Built-in tools hidden by the session's swarm mode, mapped to the refusal + // text returned when the model calls one anyway (session/swarm_mode.hpp). + std::unordered_map hidden_builtin_tools; }; // Build and attach the shared fallback used by built-in, MCP, unknown, denied, @@ -280,6 +284,10 @@ class ToolExecutor { // tool names remain unknown instead of being mislabeled as policy denials. bool is_denied_by_policy(const std::string& name, const ToolCapabilityPolicy* policy) const; + // Model-facing refusal text for a policy-denied tool (expert policy or a + // swarm-mode hidden tool). + static std::string policy_denial_text(const std::string& name, + const ToolCapabilityPolicy* policy); // Execute a tool call and return the result. Legacy overload — no streaming, // no abort flag. Delegates to the ctx overload with a default context. diff --git a/src/domain/llm/message_predicates.cpp b/src/domain/llm/message_predicates.cpp index 4672ce261..407661ecb 100644 --- a/src/domain/llm/message_predicates.cpp +++ b/src/domain/llm/message_predicates.cpp @@ -21,6 +21,13 @@ bool has_internal_user_context_metadata(const ChatMessage& msg) { for (const char* key : kInternalContextKeys) { if (msg.metadata.value(key, false)) return true; } + // 蜂群模式(网状)的跨 agent 信封(session/inter_agent_message.hpp):MESSAGE / + // FINAL_ANSWER 属内部上下文;NEW_TASK 是子 agent 收到的任务,等同真实用户请求, + // 可变上下文锚在它前面,回合中途到达的其它信封不会挪动锚点打穿前缀缓存。 + const auto envelope = msg.metadata.find("inter_agent"); + if (envelope != msg.metadata.end() && envelope->is_object()) { + return envelope->value("type", std::string{}) != "NEW_TASK"; + } return false; } diff --git a/src/domain/session/agent_path.cpp b/src/domain/session/agent_path.cpp new file mode 100644 index 000000000..25b0014c7 --- /dev/null +++ b/src/domain/session/agent_path.cpp @@ -0,0 +1,131 @@ +#include "agent_path.hpp" + +#include + +namespace acecode::mesh { + +namespace { + +constexpr const char* kRootSegment = "root"; + +bool fail(std::string* error, std::string message) { + if (error) *error = std::move(message); + return false; +} + +bool validate_name(const std::string& name, std::string* error) { + const std::string message = validate_agent_name(name); + if (message.empty()) return true; + return fail(error, message); +} + +bool validate_absolute(const std::string& path, std::string* error) { + const std::string prefix_error = + "absolute agent paths must start with `/root` or be `/morpheus`"; + if (path.empty() || path.front() != '/') return fail(error, prefix_error); + const std::string stripped = path.substr(1); + const auto first_slash = stripped.find('/'); + const std::string head = stripped.substr(0, first_slash); + if (head != kRootSegment) return fail(error, prefix_error); + if (!stripped.empty() && stripped.back() == '/') { + return fail(error, "absolute agent path must not end with `/`"); + } + if (first_slash == std::string::npos) return true; + std::size_t start = first_slash + 1; + while (true) { + const auto next = stripped.find('/', start); + if (!validate_name(stripped.substr(start, next - start), error)) return false; + if (next == std::string::npos) return true; + start = next + 1; + } +} + +bool validate_relative(const std::string& reference, std::string* error) { + if (!reference.empty() && reference.back() == '/') { + return fail(error, "relative agent path must not end with `/`"); + } + std::size_t start = 0; + while (true) { + const auto next = reference.find('/', start); + if (!validate_name(reference.substr(start, next - start), error)) return false; + if (next == std::string::npos) return true; + start = next + 1; + } +} + +} // namespace + +std::string validate_agent_name(const std::string& agent_name) { + if (agent_name.empty()) return "agent_name must not be empty"; + if (agent_name == kRootSegment) return "agent_name `root` is reserved"; + if (agent_name == "." || agent_name == "..") { + return "agent_name `" + agent_name + "` is reserved"; + } + if (agent_name.find('/') != std::string::npos) { + return "agent_name must not contain `/`"; + } + for (const char ch : agent_name) { + const bool ok = (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_'; + if (!ok) { + return "agent_name must use only lowercase letters, digits, and underscores"; + } + } + return {}; +} + +AgentPath AgentPath::root() { + return AgentPath(kRoot); +} + +std::optional AgentPath::parse(const std::string& path, std::string* error) { + if (!validate_absolute(path, error)) return std::nullopt; + return AgentPath(path); +} + +std::string AgentPath::name() const { + if (is_root()) return kRootSegment; + const auto slash = path_.rfind('/'); + const std::string segment = slash == std::string::npos ? path_ : path_.substr(slash + 1); + return segment.empty() ? std::string(kRootSegment) : segment; +} + +int AgentPath::depth() const { + int depth = 0; + for (std::size_t i = 1; i < path_.size(); ++i) { + if (path_[i] == '/') ++depth; + } + return depth; +} + +std::optional AgentPath::parent() const { + if (is_root()) return std::nullopt; + const auto slash = path_.rfind('/'); + if (slash == std::string::npos || slash == 0) return std::nullopt; + return AgentPath(path_.substr(0, slash)); +} + +bool AgentPath::has_prefix(const std::string& prefix) const { + if (prefix.empty()) return true; + if (path_.compare(0, prefix.size(), prefix) != 0) return false; + return path_.size() == prefix.size() || path_[prefix.size()] == '/'; +} + +std::optional AgentPath::join(const std::string& agent_name, + std::string* error) const { + if (!validate_name(agent_name, error)) return std::nullopt; + return parse(path_ + "/" + agent_name, error); +} + +std::optional AgentPath::resolve(const std::string& reference, + std::string* error) const { + if (reference.empty()) { + fail(error, "agent path must not be empty"); + return std::nullopt; + } + if (reference == kRoot) return root(); + if (reference.front() == '/') return parse(reference, error); + if (!validate_relative(reference, error)) return std::nullopt; + return parse(path_ + "/" + reference, error); +} + +} // namespace acecode::mesh diff --git a/src/domain/session/agent_path.hpp b/src/domain/session/agent_path.hpp new file mode 100644 index 000000000..7a86d2b43 --- /dev/null +++ b/src/domain/session/agent_path.hpp @@ -0,0 +1,52 @@ +#pragma once + +// Canonical agent paths for the mesh swarm mode, ported from Codex +// codex-rs/protocol/src/agent_path.rs. `/root` is the tree root and every +// child appends one task name. Codex's reserved `/morpheus` path is not +// supported. Error texts match Codex so model-facing errors stay familiar. + +#include +#include +#include + +namespace acecode::mesh { + +class AgentPath { +public: + static constexpr const char* kRoot = "/root"; + + static AgentPath root(); + // Absolute paths only; nullopt with *error on rejection. + static std::optional parse(const std::string& path, + std::string* error = nullptr); + + const std::string& str() const { return path_; } + bool is_root() const { return path_ == kRoot; } + // Last segment; "root" for the root path. + std::string name() const; + // Number of segments below root (root = 0). + int depth() const; + // Parent path; nullopt for the root. + std::optional parent() const; + // True when this path equals prefix or lies below it. + bool has_prefix(const std::string& prefix) const; + + std::optional join(const std::string& agent_name, + std::string* error = nullptr) const; + // `/root...` is canonical; other references resolve below this path. + std::optional resolve(const std::string& reference, + std::string* error = nullptr) const; + + bool operator==(const AgentPath& other) const { return path_ == other.path_; } + bool operator!=(const AgentPath& other) const { return path_ != other.path_; } + bool operator<(const AgentPath& other) const { return path_ < other.path_; } + +private: + explicit AgentPath(std::string path) : path_(std::move(path)) {} + std::string path_; +}; + +// Empty when valid; otherwise the Codex error text. +std::string validate_agent_name(const std::string& agent_name); + +} // namespace acecode::mesh diff --git a/src/domain/session/inter_agent_message.cpp b/src/domain/session/inter_agent_message.cpp new file mode 100644 index 000000000..bf76a914c --- /dev/null +++ b/src/domain/session/inter_agent_message.cpp @@ -0,0 +1,67 @@ +#include "inter_agent_message.hpp" + +namespace acecode::mesh { + +const char* inter_agent_message_type_name(InterAgentMessageType type) { + switch (type) { + case InterAgentMessageType::NewTask: return "NEW_TASK"; + case InterAgentMessageType::FinalAnswer: return "FINAL_ANSWER"; + case InterAgentMessageType::Message: break; + } + return "MESSAGE"; +} + +std::optional parse_inter_agent_message_type(const std::string& name) { + if (name == "MESSAGE") return InterAgentMessageType::Message; + if (name == "NEW_TASK") return InterAgentMessageType::NewTask; + if (name == "FINAL_ANSWER") return InterAgentMessageType::FinalAnswer; + return std::nullopt; +} + +std::string render_inter_agent_body(const InterAgentEnvelope& envelope) { + return std::string("Message Type: ") + inter_agent_message_type_name(envelope.type) + + "\nTask name: " + envelope.recipient + + "\nSender: " + envelope.sender + + "\nPayload:\n" + envelope.payload; +} + +std::string render_inter_agent_message(const InterAgentEnvelope& envelope) { + return "\n" + render_inter_agent_body(envelope) + + "\n"; +} + +nlohmann::json inter_agent_metadata(const InterAgentEnvelope& envelope) { + nlohmann::json value = { + {"type", inter_agent_message_type_name(envelope.type)}, + {"sender", envelope.sender}, + {"recipient", envelope.recipient}, + }; + if (!envelope.sender_session_id.empty()) { + value["sender_session_id"] = envelope.sender_session_id; + } + if (!envelope.final_status.empty()) value["status"] = envelope.final_status; + return value; +} + +std::optional inter_agent_envelope_from_metadata( + const nlohmann::json& metadata) { + if (!metadata.is_object()) return std::nullopt; + const auto it = metadata.find(kInterAgentMetadataKey); + if (it == metadata.end() || !it->is_object()) return std::nullopt; + const auto type = parse_inter_agent_message_type(it->value("type", std::string{})); + if (!type) return std::nullopt; + InterAgentEnvelope envelope; + envelope.type = *type; + envelope.sender = it->value("sender", std::string{}); + envelope.recipient = it->value("recipient", std::string{}); + envelope.sender_session_id = it->value("sender_session_id", std::string{}); + envelope.final_status = it->value("status", std::string{}); + return envelope; +} + +bool is_inter_agent_message(const ChatMessage& message) { + return message.role == "user" && + inter_agent_envelope_from_metadata(message.metadata).has_value(); +} + +} // namespace acecode::mesh diff --git a/src/domain/session/inter_agent_message.hpp b/src/domain/session/inter_agent_message.hpp new file mode 100644 index 000000000..f04fa6b22 --- /dev/null +++ b/src/domain/session/inter_agent_message.hpp @@ -0,0 +1,47 @@ +#pragma once + +// Inter-agent envelopes for the mesh swarm mode. The body is Codex's +// InterAgentMessage / InterAgentCompletionMessage text; ACECode deliberately +// wraps it in and persists it as a user-role message +// (Codex uses the assistant role, which Anthropic/DeepSeek-style providers +// reject when two assistant messages become adjacent). + +#include "llm/llm_provider.hpp" + +#include + +#include +#include + +namespace acecode::mesh { + +enum class InterAgentMessageType { Message, NewTask, FinalAnswer }; + +const char* inter_agent_message_type_name(InterAgentMessageType type); +std::optional parse_inter_agent_message_type(const std::string& name); + +// Metadata key holding {type, sender, recipient, sender_session_id}. +inline constexpr const char* kInterAgentMetadataKey = "inter_agent"; + +struct InterAgentEnvelope { + InterAgentMessageType type = InterAgentMessageType::Message; + std::string recipient; // canonical path ("Task name") + std::string sender; // canonical path + std::string sender_session_id; // UI routing only, never model-facing + std::string payload; + // FINAL_ANSWER only: completed | errored | interrupted. + std::string final_status; +}; + +// "Message Type: ...\nTask name: ...\nSender: ...\nPayload:\n..." (Codex text). +std::string render_inter_agent_body(const InterAgentEnvelope& envelope); +// The body wrapped in tags; this is the model content. +std::string render_inter_agent_message(const InterAgentEnvelope& envelope); +nlohmann::json inter_agent_metadata(const InterAgentEnvelope& envelope); + +// Reads metadata.inter_agent; nullopt for ordinary messages. +std::optional inter_agent_envelope_from_metadata( + const nlohmann::json& metadata); +bool is_inter_agent_message(const ChatMessage& message); + +} // namespace acecode::mesh diff --git a/src/domain/session/session_manager.cpp b/src/domain/session/session_manager.cpp index acfc8dcdf..e580cd02b 100644 --- a/src/domain/session/session_manager.cpp +++ b/src/domain/session/session_manager.cpp @@ -1,4 +1,5 @@ #include "session_manager.hpp" +#include "inter_agent_message.hpp" #include "session_history_page.hpp" #include "session_load_metrics.hpp" #include "session_file_reader.hpp" @@ -68,7 +69,8 @@ bool is_hidden_goal_context_message(const acecode::ChatMessage& msg) { bool is_visible_user_turn_message(const acecode::ChatMessage& msg) { return msg.role == "user" && !msg.is_meta && - !is_hidden_goal_context_message(msg); + !is_hidden_goal_context_message(msg) && + !acecode::mesh::is_inter_agent_message(msg); } std::string first_visible_user_message_text( @@ -261,6 +263,7 @@ void SessionManager::start_session(const std::string& cwd, loop_id_.clear(); loop_run_id_.clear(); worktree_ = {}; + adopt_swarm_identity_locked(SessionMeta{}); file_checkpoints_loaded_ = true; checkpoint_store_.reset(); checkpoint_store_.set_session(project_dir_, session_id_); @@ -326,6 +329,7 @@ bool SessionManager::ensure_created() { meta.loop_id = loop_id_; meta.loop_run_id = loop_run_id_; meta.worktree = worktree_; + write_swarm_identity_locked(meta); if (SessionStorage::write_meta(meta_path_str_, meta)) { local_user_title_write_pending_ = false; } @@ -643,6 +647,7 @@ std::vector SessionManager::resume_session(const std::string& sessi loop_id_ = meta.loop_id; loop_run_id_ = meta.loop_run_id; worktree_ = meta.worktree; + adopt_swarm_identity_locked(meta); reasoning_effort_ = meta.reasoning_effort; if (model_preset_.empty()) { model_preset_ = meta.model_preset; @@ -1208,6 +1213,7 @@ bool SessionManager::update_meta( meta.loop_id = loop_id_; meta.loop_run_id = loop_run_id_; meta.worktree = worktree_; + write_swarm_identity_locked(meta); const bool written = SessionStorage::write_meta(meta_path_str_, meta); if (written) { local_user_title_write_pending_ = false; @@ -1540,6 +1546,7 @@ void SessionManager::set_input_draft(std::string draft, nlohmann::json composer_ meta.expert_member_id = expert_member_id_; meta.loop_id = loop_id_; meta.loop_run_id = loop_run_id_; + write_swarm_identity_locked(meta); } meta.input_draft = input_draft_; meta.input_draft_content = input_draft_content_; diff --git a/src/domain/session/session_manager.hpp b/src/domain/session/session_manager.hpp index a34c111de..7d2386b60 100644 --- a/src/domain/session/session_manager.hpp +++ b/src/domain/session/session_manager.hpp @@ -210,6 +210,12 @@ class SessionManager { // otherwise on lazy creation). Pass empty string to clear. void set_parent_session_id(std::string parent_id); std::string current_parent_session_id() const; + // 蜂群模式与网状 agent 路径(session_manager_agent.cpp)。模式取 + // "" / "off" / "star" / "mesh";根会话与非网状会话的路径为空。 + void set_swarm_mode(std::string swarm_mode); + void set_mesh_agent_path(std::string agent_path); + std::string current_swarm_mode() const; + std::string current_agent_path() const; // Persist the current expert identity selected for this session. void set_expert_binding(std::string expert_id, std::string member_id = {}); @@ -355,6 +361,10 @@ class SessionManager { std::string loop_id_; std::string loop_run_id_; WorktreeSessionInfo worktree_; + std::string swarm_mode_; + std::string agent_path_; + void write_swarm_identity_locked(SessionMeta& meta) const; + void adopt_swarm_identity_locked(const SessionMeta& meta); void ensure_file_checkpoints_loaded_locked() const; mutable bool file_checkpoints_loaded_ = true; mutable FileCheckpointStore checkpoint_store_; diff --git a/src/domain/session/session_manager_agent.cpp b/src/domain/session/session_manager_agent.cpp new file mode 100644 index 000000000..cc0d54c39 --- /dev/null +++ b/src/domain/session/session_manager_agent.cpp @@ -0,0 +1,42 @@ +// SessionManager 的蜂群模式与网状 agent 路径。只改字段并在会话已落盘时写 +// meta;单独成文件让 session_manager.cpp 保持在行数基线内。 +#include "session_manager.hpp" + +namespace acecode { + +void SessionManager::set_swarm_mode(std::string swarm_mode) { + std::lock_guard lk(mu_); + if (swarm_mode == "off") swarm_mode.clear(); + if (swarm_mode_ == swarm_mode) return; + swarm_mode_ = std::move(swarm_mode); + if (created_) update_meta(); +} + +void SessionManager::set_mesh_agent_path(std::string agent_path) { + std::lock_guard lk(mu_); + if (agent_path_ == agent_path) return; + agent_path_ = std::move(agent_path); + if (created_) update_meta(); +} + +std::string SessionManager::current_swarm_mode() const { + std::lock_guard lk(mu_); + return swarm_mode_.empty() ? std::string("off") : swarm_mode_; +} + +std::string SessionManager::current_agent_path() const { + std::lock_guard lk(mu_); + return agent_path_; +} + +void SessionManager::write_swarm_identity_locked(SessionMeta& meta) const { + meta.swarm_mode = swarm_mode_; + meta.agent_path = agent_path_; +} + +void SessionManager::adopt_swarm_identity_locked(const SessionMeta& meta) { + swarm_mode_ = meta.swarm_mode == "off" ? std::string{} : meta.swarm_mode; + agent_path_ = meta.agent_path; +} + +} // namespace acecode diff --git a/src/domain/session/session_storage.cpp b/src/domain/session/session_storage.cpp index 5998c2054..201e56583 100644 --- a/src/domain/session/session_storage.cpp +++ b/src/domain/session/session_storage.cpp @@ -1,4 +1,5 @@ #include "session_storage.hpp" +#include "inter_agent_message.hpp" #include "session_load_metrics.hpp" #include "session_file_reader.hpp" #include "session_purge_listeners.hpp" @@ -99,7 +100,8 @@ bool is_hidden_goal_context_message_storage(const ChatMessage& msg) { bool is_visible_user_turn(const ChatMessage& msg) { return msg.role == "user" && !msg.is_meta && - !is_hidden_goal_context_message_storage(msg); + !is_hidden_goal_context_message_storage(msg) && + !mesh::is_inter_agent_message(msg); } bool try_deserialize_session_record(const std::string& line, @@ -372,6 +374,8 @@ bool SessionStorage::write_meta(const std::string& meta_path, const SessionMeta& } if (!meta.memory_mode.empty()) j["memory_mode"] = meta.memory_mode; if (!meta.surface.empty()) j["surface"] = meta.surface; + if (!meta.swarm_mode.empty() && meta.swarm_mode != "off") j["swarm_mode"] = meta.swarm_mode; + if (!meta.agent_path.empty()) j["mesh_agent"] = {{"path", meta.agent_path}}; std::error_code ec; fs::create_directories(path_from_utf8(meta_path).parent_path(), ec); @@ -453,6 +457,10 @@ SessionMeta SessionStorage::read_meta(const std::string& meta_path) { meta.no_workspace = j.value("no_workspace", false); meta.memory_mode = j.value("memory_mode", std::string{}); meta.surface = j.value("surface", std::string{}); + meta.swarm_mode = j.value("swarm_mode", std::string{}); + if (j.contains("mesh_agent") && j["mesh_agent"].is_object()) { + meta.agent_path = j["mesh_agent"].value("path", std::string{}); + } } catch (...) { // Return empty meta on parse failure } diff --git a/src/domain/session/session_storage.hpp b/src/domain/session/session_storage.hpp index fe2f95c5d..86e50c474 100644 --- a/src/domain/session/session_storage.hpp +++ b/src/domain/session/session_storage.hpp @@ -113,6 +113,13 @@ struct SessionMeta { // 会话当前的 worktree 状态。inactive(worktree_path 为空)时序列化省略。 WorktreeSessionInfo worktree; + + // 会话级蜂群模式:"" = off,"star" / "mesh"。off 时序列化省略。 + std::string swarm_mode; + // 蜂群模式(网状)子 agent 的 canonical 路径(/root/a/b)。网状子 agent 的 + // parent_session_id 恒为树根会话 id(界面归属、冒泡与级联删除都按它走), + // 真实父子关系由路径表达。根会话与非网状会话为空,序列化省略。 + std::string agent_path; }; class SessionStorage { diff --git a/src/domain/session/swarm_mode.cpp b/src/domain/session/swarm_mode.cpp new file mode 100644 index 000000000..9b50184f4 --- /dev/null +++ b/src/domain/session/swarm_mode.cpp @@ -0,0 +1,60 @@ +#include "swarm_mode.hpp" + +#include + +namespace acecode { + +const char* swarm_mode_name(SwarmMode mode) { + switch (mode) { + case SwarmMode::Star: return "star"; + case SwarmMode::Mesh: return "mesh"; + case SwarmMode::Off: break; + } + return "off"; +} + +std::optional parse_swarm_mode(const std::string& text) { + std::string lowered; + lowered.reserve(text.size()); + for (const char ch : text) { + lowered.push_back(static_cast(std::tolower(static_cast(ch)))); + } + if (lowered == "off" || lowered == "false") return SwarmMode::Off; + if (lowered == "star" || lowered == "true") return SwarmMode::Star; + if (lowered == "mesh") return SwarmMode::Mesh; + return std::nullopt; +} + +const std::vector& mesh_agent_tool_names() { + static const std::vector names = { + "agent_spawn", "agent_list", "agent_send_message", + "agent_followup_task", "agent_wait", "agent_interrupt", + }; + return names; +} + +std::unordered_map swarm_mode_hidden_tools(SwarmMode mode) { + std::unordered_map hidden; + if (mode != SwarmMode::Mesh) { + for (const auto& name : mesh_agent_tool_names()) { + hidden[name] = name + " is only available in mesh swarm mode."; + } + return hidden; + } + hidden["spawn_subagent"] = + "spawn_subagent is not available in mesh swarm mode; use agent_spawn instead."; + hidden["wait_subagent"] = + "wait_subagent is not available in mesh swarm mode; use agent_wait instead."; + static const char* const kThreadTools[] = { + "create_thread", "fork_thread", "list_threads", "read_thread", + "send_message_to_thread", "wait_threads", "set_thread_title", + "set_thread_pinned", "set_thread_archived", "delete_thread", "repair_thread", + }; + for (const char* name : kThreadTools) { + hidden[name] = std::string(name) + + " is not available in mesh swarm mode; use the agent_* collaboration tools instead."; + } + return hidden; +} + +} // namespace acecode diff --git a/src/domain/session/swarm_mode.hpp b/src/domain/session/swarm_mode.hpp new file mode 100644 index 000000000..0a7134f40 --- /dev/null +++ b/src/domain/session/swarm_mode.hpp @@ -0,0 +1,30 @@ +#pragma once + +// Session-level swarm mode. Star is the original spawn_subagent fan-out; +// mesh is the Codex Multi-Agent V2 clone with agent_* tools. The two tool +// families are mutually exclusive: each mode hides the other family. + +#include +#include +#include +#include + +namespace acecode { + +enum class SwarmMode { Off, Star, Mesh }; + +// Canonical names: "off", "star", "mesh". +const char* swarm_mode_name(SwarmMode mode); +// Accepts canonical names (case-insensitive) and the legacy boolean words +// "true" (= star) and "false" (= off). Empty or unknown input -> nullopt. +std::optional parse_swarm_mode(const std::string& text); + +// agent_spawn, agent_list, agent_send_message, agent_followup_task, +// agent_wait, agent_interrupt. +const std::vector& mesh_agent_tool_names(); +// Built-in tools hidden in `mode`, mapped to the model-facing refusal text. +// Off/star hide the mesh tools; mesh hides spawn/wait_subagent and the thread +// tools (they would offer a second, conflicting way to message sessions). +std::unordered_map swarm_mode_hidden_tools(SwarmMode mode); + +} // namespace acecode diff --git a/src/engine/agent/agent_loop.cpp b/src/engine/agent/agent_loop.cpp index 5cad75ce4..22c492ed1 100644 --- a/src/engine/agent/agent_loop.cpp +++ b/src/engine/agent/agent_loop.cpp @@ -22,6 +22,7 @@ #include "agent/turn/turn_outcome.hpp" #include "agent/control/task_handoff.hpp" #include "agent/turn/active_turn_gate.hpp" +#include "agent/mailbox/agent_mailbox.hpp" #include "agent/worker/agent_task_queue.hpp" #include "agent/model_step/active_provider_slot.hpp" #include "agent/detail/agent_payloads.hpp" @@ -73,6 +74,7 @@ AgentLoop::AgentLoop(AgentLoopServices services, AgentLoopOptions options) , task_queue_(std::make_unique(busy_)) , active_turn_gate_(std::make_unique( busy_, abort_signal_, turn_interrupt_requested_)) + , mailbox_(std::make_unique()) , task_handoff_(std::make_unique(*task_queue_)) , active_provider_slot_(std::make_unique()) , boundary_(std::make_unique(options.cwd, permissions_)) @@ -107,6 +109,7 @@ AgentLoop::AgentLoop(AgentLoopServices services, AgentLoopOptions options) request_source_.skill_usage = services.skill_usage; request_source_.skill_idle_days = options.skill_idle_days; request_source_.tool_policy = std::move(options.tool_policy); + request_source_.swarm = std::move(options.swarm); set_loop_execution_policy(std::move(options.loop_policy)); if (session_manager_) trajectory_ = std::make_unique(events_, *history_, *session_manager_); @@ -190,6 +193,7 @@ void AgentLoop::shutdown() { stopped_ = true; } side_questions_->stop_requests(); + mailbox_->close(); task_queue_->request_shutdown(); abort_signal_.shutdown(); wake_active_provider_retry(); diff --git a/src/engine/agent/agent_loop.hpp b/src/engine/agent/agent_loop.hpp index 8934c0002..b054ce721 100644 --- a/src/engine/agent/agent_loop.hpp +++ b/src/engine/agent/agent_loop.hpp @@ -61,7 +61,7 @@ struct SystemPromptModelState; struct SystemPromptWorkspaceFolders; class AgentLoopDoomGuard; -namespace agent { class TurnFinalizer; struct TurnContext; struct ToolCallOutcome; struct ToolBatchOutcome; struct ToolBatchState; struct DeferredTaskCompleteEnd; class ContextOverflowRecovery; struct RequestRecoveryState; class CompactionController; struct CompactionInputs; class ProviderStreamCollector; struct TurnUsageRecord; class TurnUsageAccountant; class ModelStepRecorder; struct RequestContextOptions; class ApiRequestBuilder; class PromptContextCache; class ActivityNarrator; class RetryProgressReporter; class SideQuestionService; class ActiveProviderSlot; class SynchronizedDoomGuard; class AgentTaskQueue; class ActiveTurnGate; class TaskHandoff; class GoalRuntime; class AgentHookBridge; class ToolHookBridge; class WorkspaceBoundary; class SessionExecSecurity; class ConversationHistory; class TranscriptWriter; class TrajectoryRecorder; class TurnOutcomeRecord; } +namespace agent { class AgentMailbox; class TurnFinalizer; struct TurnContext; struct ToolCallOutcome; struct ToolBatchOutcome; struct ToolBatchState; struct DeferredTaskCompleteEnd; class ContextOverflowRecovery; struct RequestRecoveryState; class CompactionController; struct CompactionInputs; class ProviderStreamCollector; struct TurnUsageRecord; class TurnUsageAccountant; class ModelStepRecorder; struct RequestContextOptions; class ApiRequestBuilder; class PromptContextCache; class ActivityNarrator; class RetryProgressReporter; class SideQuestionService; class ActiveProviderSlot; class SynchronizedDoomGuard; class AgentTaskQueue; class ActiveTurnGate; class TaskHandoff; class GoalRuntime; class AgentHookBridge; class ToolHookBridge; class WorkspaceBoundary; class SessionExecSecurity; class ConversationHistory; class TranscriptWriter; class TrajectoryRecorder; class TurnOutcomeRecord; } class AgentLoop { public: @@ -170,6 +170,23 @@ class AgentLoop { const std::string& expected_turn_id = {}); std::string active_turn_id() const; + // ---- 蜂群模式(网状)---- + // Session swarm mode and mesh identity for the next captured turn. + void set_swarm_context(agent::SwarmContext context); + agent::SwarmContext swarm_context() const; + // Queue an inter-agent envelope (user-role input with metadata.inter_agent). + // Mail always goes through the session mailbox: a running turn takes it at + // its next model boundary; trigger_turn also wakes an idle agent with a + // mailbox turn, otherwise the mail waits for the agent's next turn. + void deliver_inter_agent_message(UserInput envelope, bool trigger_turn); + enum class MailboxWaitOutcome { Mailbox, Steered, TimedOut, Aborted }; + // agent_wait: already-pending steering / mail returns immediately, then + // blocks for new mail or steered user input until the timeout or abort. + MailboxWaitOutcome wait_for_mailbox_activity(std::chrono::milliseconds timeout, + const std::atomic* abort_flag); + std::size_t pending_mailbox_count() const; + bool has_pending_trigger_mail() const; + // Legacy cancel alias void cancel() { abort(); } @@ -352,7 +369,7 @@ class AgentLoop { agent::CompactionInputs compaction_inputs() const; std::vector build_compaction_initial_context() const; agent::RequestContextOptions request_context_options( - const std::shared_ptr& provider, bool swarm_mode = false) const; + const std::shared_ptr& provider) const; void publish_side_question_context(const std::vector& messages); SideChatToolset side_chat_toolset(); void require_before_start(const char* operation) const; @@ -400,6 +417,7 @@ class AgentLoop { std::unique_ptr trajectory_; std::unique_ptr task_queue_; std::unique_ptr active_turn_gate_; + std::unique_ptr mailbox_; std::unique_ptr task_handoff_; std::unique_ptr active_provider_slot_; std::unique_ptr boundary_; diff --git a/src/engine/agent/agent_loop_services.hpp b/src/engine/agent/agent_loop_services.hpp index ae94aadb6..57aaf83b4 100644 --- a/src/engine/agent/agent_loop_services.hpp +++ b/src/engine/agent/agent_loop_services.hpp @@ -43,5 +43,7 @@ struct AgentLoopOptions { int skill_idle_days = 30; std::optional sandbox; // Absent preserves the unconfigured runtime. std::string exec_rules_dir_override; + // Session swarm mode and mesh identity; published later via set_swarm_context. + agent::SwarmContext swarm; }; } // namespace acecode diff --git a/src/engine/agent/compaction/agent_compaction.cpp b/src/engine/agent/compaction/agent_compaction.cpp index 34a4ed3e2..ce9749d92 100644 --- a/src/engine/agent/compaction/agent_compaction.cpp +++ b/src/engine/agent/compaction/agent_compaction.cpp @@ -9,7 +9,7 @@ agent::CompactionInputs AgentLoop::compaction_inputs() const { inputs.session = session_manager_; inputs.hooks = hook_manager_; inputs.provider = provider_accessor_ ? provider_accessor_() : nullptr; - inputs.request = request_context_options(inputs.provider, turn_context_ && turn_context_->swarm_mode); + inputs.request = request_context_options(inputs.provider); inputs.suggestion_threshold = task_suggestion_compact_threshold_.load(std::memory_order_relaxed); if (trajectory_) inputs.terminal = trajectory_->ref(); return inputs; diff --git a/src/engine/agent/mailbox/agent_mailbox.cpp b/src/engine/agent/mailbox/agent_mailbox.cpp new file mode 100644 index 000000000..171abdea4 --- /dev/null +++ b/src/engine/agent/mailbox/agent_mailbox.cpp @@ -0,0 +1,89 @@ +#include "agent_mailbox.hpp" + +#include +#include + +namespace acecode::agent { + +namespace { +// Abort flags are plain atomics without a notifier, so waiters re-check them on +// this cadence (same as ThreadService::wait). +constexpr auto kAbortPollInterval = std::chrono::milliseconds(100); +} // namespace + +void AgentMailbox::push(Mail mail) { + { + std::lock_guard lock(mu_); + mail_.push_back(std::move(mail)); + bump_locked(MailboxActivity::Mailbox); + } + cv_.notify_all(); +} + +bool AgentMailbox::pop_front(Mail& out) { + std::lock_guard lock(mu_); + if (mail_.empty()) return false; + out = std::move(mail_.front()); + mail_.pop_front(); + return true; +} + +std::deque AgentMailbox::take_all() { + std::deque taken; + std::lock_guard lock(mu_); + taken.swap(mail_); + return taken; +} + +std::size_t AgentMailbox::size() const { + std::lock_guard lock(mu_); + return mail_.size(); +} + +bool AgentMailbox::has_trigger_turn() const { + std::lock_guard lock(mu_); + return std::any_of(mail_.begin(), mail_.end(), + [](const Mail& mail) { return mail.trigger_turn; }); +} + +void AgentMailbox::notify_steer() { + { + std::lock_guard lock(mu_); + bump_locked(MailboxActivity::Steer); + } + cv_.notify_all(); +} + +std::uint64_t AgentMailbox::activity_sequence() const { + std::lock_guard lock(mu_); + return sequence_; +} + +MailboxActivity AgentMailbox::wait(std::uint64_t since, + std::chrono::steady_clock::time_point deadline, + const std::atomic* abort_flag) { + std::unique_lock lock(mu_); + while (true) { + if (sequence_ > since) return last_; + if (closed_) return MailboxActivity::None; + if (abort_flag && abort_flag->load()) return MailboxActivity::None; + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) return MailboxActivity::None; + cv_.wait_until(lock, std::min(deadline, now + kAbortPollInterval)); + } +} + +void AgentMailbox::close() { + { + std::lock_guard lock(mu_); + closed_ = true; + } + cv_.notify_all(); +} + +void AgentMailbox::bump_locked(MailboxActivity kind) { + ++sequence_; + last_ = kind; +} + +} // namespace acecode::agent diff --git a/src/engine/agent/mailbox/agent_mailbox.hpp b/src/engine/agent/mailbox/agent_mailbox.hpp new file mode 100644 index 000000000..900fb7a3e --- /dev/null +++ b/src/engine/agent/mailbox/agent_mailbox.hpp @@ -0,0 +1,60 @@ +#pragma once + +// Session-scoped inter-agent mailbox (Codex InputQueue::mailbox_pending_mails). +// Every inbound envelope lands here, never in the steering gate, so aborting or +// erroring a turn cannot drop mail. A running turn drains it at each model +// boundary; after a final answer, queue-only mail waits for the next turn. +// The mutex is a leaf: no method calls out while holding it. + +#include "llm/llm_provider.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace acecode::agent { + +enum class MailboxActivity { None, Mailbox, Steer }; + +class AgentMailbox { +public: + struct Mail { + UserInput input; + bool trigger_turn = false; + }; + + void push(Mail mail); + // Pops the oldest mail; false when empty. + bool pop_front(Mail& out); + std::deque take_all(); + std::size_t size() const; + bool has_trigger_turn() const; + + // A user input was steered into the active turn; ends agent_wait early. + void notify_steer(); + // Monotonic activity counter; agent_wait waits for it to move past a mark. + std::uint64_t activity_sequence() const; + // Waits until the counter exceeds `since`, abort is observed, close() is + // called or the deadline passes. Returns the kind of the newest activity, + // or None on timeout / abort / close. + MailboxActivity wait(std::uint64_t since, + std::chrono::steady_clock::time_point deadline, + const std::atomic* abort_flag); + void close(); + +private: + void bump_locked(MailboxActivity kind); + + mutable std::mutex mu_; + std::condition_variable cv_; + std::deque mail_; + std::uint64_t sequence_ = 0; + MailboxActivity last_ = MailboxActivity::None; + bool closed_ = false; +}; + +} // namespace acecode::agent diff --git a/src/engine/agent/mailbox/agent_mailbox_control.cpp b/src/engine/agent/mailbox/agent_mailbox_control.cpp new file mode 100644 index 000000000..2b75a1223 --- /dev/null +++ b/src/engine/agent/mailbox/agent_mailbox_control.cpp @@ -0,0 +1,51 @@ +// AgentLoop 的网状蜂群接口:会话级蜂群上下文发布、跨 agent 投递与 agent_wait。 +#include "agent/agent_loop.hpp" +#include "agent/mailbox/agent_mailbox.hpp" +#include "agent/turn/active_turn_gate.hpp" +#include "agent/worker/agent_task_queue.hpp" + +#include + +namespace acecode { + +void AgentLoop::set_swarm_context(agent::SwarmContext context) { + std::lock_guard lock(request_source_mu_); + request_source_.swarm = std::move(context); +} + +agent::SwarmContext AgentLoop::swarm_context() const { + std::lock_guard lock(request_source_mu_); + return request_source_.swarm; +} + +void AgentLoop::deliver_inter_agent_message(UserInput envelope, bool trigger_turn) { + mailbox_->push({std::move(envelope), trigger_turn}); + if (trigger_turn) task_queue_->enqueue_mailbox_wake(); +} + +AgentLoop::MailboxWaitOutcome AgentLoop::wait_for_mailbox_activity( + std::chrono::milliseconds timeout, const std::atomic* abort_flag) { + // Mark first: activity racing with the pending checks below still moves the + // counter past the mark, so the wait cannot miss it. + const auto mark = mailbox_->activity_sequence(); + if (active_turn_gate_->has_pending()) return MailboxWaitOutcome::Steered; + if (mailbox_->size() > 0) return MailboxWaitOutcome::Mailbox; + const auto deadline = std::chrono::steady_clock::now() + timeout; + switch (mailbox_->wait(mark, deadline, abort_flag)) { + case agent::MailboxActivity::Mailbox: return MailboxWaitOutcome::Mailbox; + case agent::MailboxActivity::Steer: return MailboxWaitOutcome::Steered; + case agent::MailboxActivity::None: break; + } + if (abort_flag && abort_flag->load()) return MailboxWaitOutcome::Aborted; + return MailboxWaitOutcome::TimedOut; +} + +std::size_t AgentLoop::pending_mailbox_count() const { + return mailbox_->size(); +} + +bool AgentLoop::has_pending_trigger_mail() const { + return mailbox_->has_trigger_turn(); +} + +} // namespace acecode diff --git a/src/engine/agent/request/agent_request.cpp b/src/engine/agent/request/agent_request.cpp index 419dc0a1d..f11a63aa5 100644 --- a/src/engine/agent/request/agent_request.cpp +++ b/src/engine/agent/request/agent_request.cpp @@ -17,12 +17,12 @@ std::set AgentLoop::dormant_skill_names() const { } agent::RequestContextOptions AgentLoop::request_context_options( - const std::shared_ptr& provider, bool swarm_mode) const { + const std::shared_ptr& provider) const { const auto source = capture_request_source(); agent::RequestContextFactory context( *boundary_, *exec_security_, source, context_window_, session_manager_, tools_, permissions_, *history_, *request_builder_, *hooks_); - return context.options(provider, swarm_mode); + return context.options(provider); } agent::RequestContextSource AgentLoop::capture_request_source() const { @@ -34,6 +34,8 @@ agent::RequestContextSource AgentLoop::capture_request_source() const { // The provider may acquire the host AppConfig lock. Never call it while // holding this loop's publication mutex. if (prompt_config_provider_) snapshot.prompt_config = prompt_config_provider_(); + // 蜂群模式两套协作工具互斥:按本回合捕获的模式隐藏另一套(schema 与执行同一谓词)。 + snapshot.tool_policy.hidden_builtin_tools = swarm_mode_hidden_tools(snapshot.swarm.mode); return snapshot; } diff --git a/src/engine/agent/request/api_request_builder.cpp b/src/engine/agent/request/api_request_builder.cpp index 1c4439273..54b5320aa 100644 --- a/src/engine/agent/request/api_request_builder.cpp +++ b/src/engine/agent/request/api_request_builder.cpp @@ -5,6 +5,7 @@ #include "request_context.hpp" #include "llm/tool_protocol_names.hpp" #include "prompt/memory_prompt.hpp" +#include "prompt/mesh_swarm_prompts.hpp" #include "skills/skill_registry.hpp" #include "skills/skill_usage_store.hpp" #include "utils/logger.hpp" @@ -225,8 +226,17 @@ RequestBuildInputs ApiRequestBuilder::capture( ptr(options.expert), options.expert_member, &inputs.category_bytes, skill_view_available, skills_list_available, spawn_subagent_available, /*include_skill_index=*/false); - inputs.swarm_context = build_swarm_mode_context_prompt( - options.swarm_mode, spawn_subagent_available); + if (options.swarm.mode == SwarmMode::Mesh) { + // 网状:六个 agent_* 工具被专家策略裁掉时不注入,避免提示与工具表矛盾。 + if (tools_.is_allowed("agent_spawn", &options.tool_policy)) { + MeshSwarmPromptOptions mesh = options.swarm.mesh; + mesh.wait_agent_enabled = tools_.is_allowed("agent_wait", &options.tool_policy); + inputs.swarm_context = build_mesh_swarm_context_prompt(mesh); + } + } else { + inputs.swarm_context = build_swarm_mode_context_prompt( + options.swarm.mode == SwarmMode::Star, spawn_subagent_available); + } } return inputs; } diff --git a/src/engine/agent/request/api_request_builder.hpp b/src/engine/agent/request/api_request_builder.hpp index 8628a5a5f..d1bf37cbb 100644 --- a/src/engine/agent/request/api_request_builder.hpp +++ b/src/engine/agent/request/api_request_builder.hpp @@ -1,5 +1,6 @@ #pragma once +#include "agent/request/swarm_context.hpp" #include "agent/turn/turn_types.hpp" #include "config/config.hpp" #include "experts/expert_registry.hpp" @@ -32,7 +33,7 @@ struct RequestContextOptions { int context_window = 128000; bool can_read_images = true; bool loop_active = false; - bool swarm_mode = false; + SwarmContext swarm; std::string loop_context; SystemPromptWorktreeState worktree; SystemPromptEnvironment environment; diff --git a/src/engine/agent/request/request_context_factory.cpp b/src/engine/agent/request/request_context_factory.cpp index d82dab0ae..93a91c60c 100644 --- a/src/engine/agent/request/request_context_factory.cpp +++ b/src/engine/agent/request/request_context_factory.cpp @@ -14,7 +14,7 @@ namespace acecode::agent { RequestContextOptions RequestContextFactory::options( - const std::shared_ptr& provider, bool swarm_mode) const { + const std::shared_ptr& provider) const { agent::RequestContextOptions options; options.cwd = boundary_.cwd(); options.skills = source_.skills; @@ -45,7 +45,7 @@ RequestContextOptions RequestContextFactory::options( options.can_read_images = model.can_read_images(); options.loop_active = source_.loop.active; options.loop_context = source_.loop.system_context; - options.swarm_mode = swarm_mode; + options.swarm = source_.swarm; options.environment = source_.runtime.prompt_environment(); options.sandbox.description = security_.sandbox_prompt_description(session_manager_); options.folders = boundary_.system_prompt_workspace_folders(session_manager_); @@ -60,8 +60,8 @@ RequestContextOptions RequestContextFactory::options( } ApiRequestBundle RequestContextFactory::build( - const std::shared_ptr& provider, bool emergency_profile, bool swarm_mode) { - auto inputs = builder_.capture(options(provider, swarm_mode), + const std::shared_ptr& provider, bool emergency_profile) { + auto inputs = builder_.capture(options(provider), history_.view(), emergency_profile); if (!emergency_profile) { inputs.hook_context = hooks_.drain_context(); diff --git a/src/engine/agent/request/request_context_factory.hpp b/src/engine/agent/request/request_context_factory.hpp index 97737c605..55506f668 100644 --- a/src/engine/agent/request/request_context_factory.hpp +++ b/src/engine/agent/request/request_context_factory.hpp @@ -21,10 +21,9 @@ class RequestContextFactory { : boundary_(boundary), security_(security), source_(source), context_window_(context_window), session_manager_(session), tools_(tools), permissions_(permissions), history_(history), builder_(builder), hooks_(hooks) {} - RequestContextOptions options(const std::shared_ptr& provider, - bool swarm_mode = false) const; + RequestContextOptions options(const std::shared_ptr& provider) const; ApiRequestBundle build(const std::shared_ptr& provider, - bool emergency_profile = false, bool swarm_mode = false); + bool emergency_profile = false); private: WorkspaceBoundary& boundary_; SessionExecSecurity& security_; diff --git a/src/engine/agent/request/request_context_source.hpp b/src/engine/agent/request/request_context_source.hpp index bca250b1d..729a8ef0f 100644 --- a/src/engine/agent/request/request_context_source.hpp +++ b/src/engine/agent/request/request_context_source.hpp @@ -2,6 +2,7 @@ #include "tool/tool_executor.hpp" #include "agent/agent_runtime_env.hpp" #include "session_prompt_config.hpp" +#include "swarm_context.hpp" #include #include @@ -34,5 +35,6 @@ struct RequestContextSource { std::string expert_member; ToolCapabilityPolicy tool_policy; LoopExecutionPolicy loop; + SwarmContext swarm; }; } // namespace acecode::agent diff --git a/src/engine/agent/request/swarm_context.hpp b/src/engine/agent/request/swarm_context.hpp new file mode 100644 index 000000000..cc38e31ea --- /dev/null +++ b/src/engine/agent/request/swarm_context.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "prompt/mesh_swarm_prompts.hpp" +#include "session/swarm_mode.hpp" + +#include + +namespace acecode::agent { + +// Session swarm mode plus the mesh identity the request builder needs. The +// session host publishes it; each turn captures one value with the rest of +// RequestContextSource, so a mode switch never changes a running turn. +struct SwarmContext { + SwarmMode mode = SwarmMode::Off; + // Canonical mesh path; "/root" for a mesh root, empty when not in mesh. + std::string agent_path; + MeshSwarmPromptOptions mesh; +}; + +} // namespace acecode::agent diff --git a/src/engine/agent/tool_exec/tool_invoker.cpp b/src/engine/agent/tool_exec/tool_invoker.cpp index 0a258215b..bb9f40312 100644 --- a/src/engine/agent/tool_exec/tool_invoker.cpp +++ b/src/engine/agent/tool_exec/tool_invoker.cpp @@ -72,8 +72,7 @@ ToolResult ToolInvoker::invoke(ToolBatchState& batch, const ToolCall& call, std::size_t index, bool needs_approval) { const auto* policy = context.capability_policy ? &*context.capability_policy : nullptr; if (tools_.is_denied_by_policy(call.function_name, policy)) { - return {"[Error] Tool denied by the active expert capability policy: " + - call.function_name, false}; + return {ToolExecutor::policy_denial_text(call.function_name, policy), false}; } if (auto denied = batch.doom_guard.maybe_guard(call)) return *denied; if (!needs_approval) { diff --git a/src/engine/agent/transcript/transcript_writer.cpp b/src/engine/agent/transcript/transcript_writer.cpp index 19695b9ff..5760ad829 100644 --- a/src/engine/agent/transcript/transcript_writer.cpp +++ b/src/engine/agent/transcript/transcript_writer.cpp @@ -4,6 +4,7 @@ #include "agent/turn/turn_outcome.hpp" #include "agent/event_payload/message_payload.hpp" #include "session/event_dispatcher.hpp" +#include "session/inter_agent_message.hpp" #include "session/session_manager.hpp" #include "session/session_rewind.hpp" #include "session/session_storage.hpp" @@ -174,21 +175,28 @@ void TranscriptWriter::emit_session_summary_updated(SessionManager* session) { void TranscriptWriter::append_user_turn_message(SessionManager* session, UserTurnInfo& info, bool hidden_goal_context) { auto& user_msg = info.user_msg; ensure_user_message_identity(user_msg); + // 网状蜂群的邮箱唤醒回合以信封开头:不是可见用户回合(不计时、不建检查点、 + // 不更新摘要),但照常发 Message 事件让界面渲染「来自 /root/... 」行。 + const bool inter_agent = mesh::is_inter_agent_message(user_msg); info.active_turn_id = user_msg.uuid; info.visible_timed_turn = - !hidden_goal_context && + !hidden_goal_context && !inter_agent && !(user_msg.metadata.is_object() && user_msg.metadata.value("hidden_goal_context", false)); info.turn_user_uuid = info.visible_timed_turn ? user_msg.uuid : std::string{}; history_.append(user_msg); if (session) { session->on_message(user_msg); - if (!hidden_goal_context) { + if (!hidden_goal_context && !inter_agent) { session->begin_user_turn_checkpoint(user_msg.uuid); } } + if (inter_agent) { + const auto callbacks = callbacks_.snapshot(); + if (callbacks.on_transcript_message) callbacks.on_transcript_message(user_msg); + } if (!hidden_goal_context) { - emit_session_summary_updated(session); + if (!inter_agent) emit_session_summary_updated(session); nlohmann::json msg_event = { {"role", "user"}, {"content", user_msg.content}, {"is_tool", false}, {"id", user_msg.uuid}, @@ -224,6 +232,28 @@ void TranscriptWriter::append_interrupted_turn_context(SessionManager* session, LOG_INFO("[turn/interrupt] recorded interrupted-turn context for " + turn_id); } +void TranscriptWriter::commit_inter_agent_message(SessionManager* session, UserInput input) { + const auto callbacks = callbacks_.snapshot(); + ChatMessage message; + message.role = "user"; + message.content = std::move(input.text); + message.content_parts = std::move(input.content_parts); + message.metadata = input.metadata.is_object() ? std::move(input.metadata) + : nlohmann::json::object(); + ensure_user_message_identity(message); + history_.append(message); + if (session) session->on_message(message); + if (callbacks.on_transcript_message) callbacks.on_transcript_message(message); + nlohmann::json event = { + {"role", "user"}, + {"content", message.content}, + {"is_tool", false}, + {"id", message.uuid}, + {"metadata", message.metadata}, + }; + events_.emit(SessionEventKind::Message, std::move(event)); +} + void TranscriptWriter::commit_turn_steering_input(SessionManager* session, UserInput input, const std::string& turn_id) { diff --git a/src/engine/agent/transcript/transcript_writer.hpp b/src/engine/agent/transcript/transcript_writer.hpp index 1aef9c1ad..bb775bd7d 100644 --- a/src/engine/agent/transcript/transcript_writer.hpp +++ b/src/engine/agent/transcript/transcript_writer.hpp @@ -37,6 +37,9 @@ class TranscriptWriter { void append_user_turn_message(SessionManager* session, UserTurnInfo& info, bool hidden_goal_context); void append_interrupted_turn_context(SessionManager* session, const std::string& turn_id); void commit_turn_steering_input(SessionManager* session, UserInput input, const std::string& turn_id); + // Mesh swarm envelope drained from the mailbox: persisted user-role message + // with metadata.inter_agent; emitted so the UI renders a sender row. + void commit_inter_agent_message(SessionManager* session, UserInput input); private: ConversationHistory& history_; EventDispatcher& events_; diff --git a/src/engine/agent/turn/active_turn_gate.cpp b/src/engine/agent/turn/active_turn_gate.cpp index af082c550..50dcefe73 100644 --- a/src/engine/agent/turn/active_turn_gate.cpp +++ b/src/engine/agent/turn/active_turn_gate.cpp @@ -251,6 +251,11 @@ std::string ActiveTurnGate::id() const { return accepting_ ? id_ : std::string{}; } +bool ActiveTurnGate::has_pending() const { + std::lock_guard lk(mu_); + return accepting_ && !pending_.empty(); +} + void ActiveTurnGate::begin(const std::string& turn_id) { std::lock_guard lk(mu_); pending_.clear(); diff --git a/src/engine/agent/turn/active_turn_gate.hpp b/src/engine/agent/turn/active_turn_gate.hpp index 2955c6d3b..fb69d6c32 100644 --- a/src/engine/agent/turn/active_turn_gate.hpp +++ b/src/engine/agent/turn/active_turn_gate.hpp @@ -37,6 +37,7 @@ class ActiveTurnGate { TurnSteerResult interrupt(const std::string& expected, const UserInput& input, AgentTaskQueue& queue, std::size_t& promised_inputs); std::string id() const; + bool has_pending() const; void begin(const std::string& id); DrainedInputs drain(bool close_if_empty); std::size_t close_and_discard(); diff --git a/src/engine/agent/turn/agent_turn.cpp b/src/engine/agent/turn/agent_turn.cpp index f63549a61..6e101333c 100644 --- a/src/engine/agent/turn/agent_turn.cpp +++ b/src/engine/agent/turn/agent_turn.cpp @@ -13,7 +13,8 @@ void AgentLoop::run_agent_with_input(const UserInput& input, bool hidden_goal_co *transcript_, *hooks_, *tool_hooks_, *boundary_, *exec_security_, *prompt_cache_, *goal_, events_, abort_signal_, loop_cfg_, source.tool_policy, session_manager_, hook_manager_, source.skills.get(), prompter_.get(), ask_prompter_.get()}, - *turn_outcome_, *active_turn_gate_, *activity_, *side_questions_, *usage_accountant_, + *turn_outcome_, *active_turn_gate_, *mailbox_, *activity_, *side_questions_, + *usage_accountant_, *model_steps_, *stream_collector_, *compaction_, *recovery_, *request_builder_, source, busy_, turn_interrupt_requested_, context_window_, task_suggestion_compact_threshold_}, diff --git a/src/engine/agent/turn/turn_context.hpp b/src/engine/agent/turn/turn_context.hpp index e47f5817d..63b3b34f7 100644 --- a/src/engine/agent/turn/turn_context.hpp +++ b/src/engine/agent/turn/turn_context.hpp @@ -23,7 +23,6 @@ struct TurnContext { AgentCallbacks callbacks; RequestContextSource request_source; UserTurnInfo info; - bool swarm_mode = false; int total_iterations = 0; int model_step_index = 0; bool preturn_compaction_failed = false; diff --git a/src/engine/agent/turn/turn_finalizer.cpp b/src/engine/agent/turn/turn_finalizer.cpp index cdcdfaf9a..93eb9e272 100644 --- a/src/engine/agent/turn/turn_finalizer.cpp +++ b/src/engine/agent/turn/turn_finalizer.cpp @@ -119,7 +119,6 @@ void TurnFinalizer::step(Step selected, Frame& frame) { frame.turn_id = gate_.id(); gate_.close_and_discard(); turn_interrupt_requested_ = false; - if (frame.turn) frame.turn->swarm_mode = false; hooks_.clear_context(); outcome_.set_error(frame.error); outcome_.record("error"); diff --git a/src/engine/agent/turn/turn_lifecycle.cpp b/src/engine/agent/turn/turn_lifecycle.cpp index c5976c36b..3003f13f2 100644 --- a/src/engine/agent/turn/turn_lifecycle.cpp +++ b/src/engine/agent/turn/turn_lifecycle.cpp @@ -14,6 +14,7 @@ #include "session/session_manager.hpp" #include "session/session_rewind.hpp" #include "session/session_storage.hpp" +#include "session/inter_agent_message.hpp" #include "session/thread_goal_store.hpp" #include "session/thread_repair.hpp" #include "session/token_tracker.hpp" @@ -52,7 +53,8 @@ UserTurnInfo TurnLifecycle::prepare_user_turn(const UserInput& input, ExplicitSkillPromptExpansion skill_expansion; skill_expansion.prompt = user_message; - if (!hidden_goal_context && skill_registry_ && !user_message.empty()) { + const bool inter_agent = mesh::inter_agent_envelope_from_metadata(input.metadata).has_value(); + if (!hidden_goal_context && !inter_agent && skill_registry_ && !user_message.empty()) { skill_expansion = inject_explicit_skill_instructions( user_message, *skill_registry_); if (!skill_expansion.injected_skill_names.empty()) { diff --git a/src/engine/agent/turn/turn_runner.cpp b/src/engine/agent/turn/turn_runner.cpp index fa9ec6fcf..b73df6da7 100644 --- a/src/engine/agent/turn/turn_runner.cpp +++ b/src/engine/agent/turn/turn_runner.cpp @@ -22,6 +22,7 @@ #include "agent/transcript/transcript_queries.hpp" #include "provider/text_tool_call_recovery.hpp" #include "session/event_dispatcher.hpp" +#include "session/inter_agent_message.hpp" #include "session/session_manager.hpp" #include "session/system_notice.hpp" #include "config/config.hpp" @@ -87,9 +88,11 @@ void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal // 上一回合没来得及消费的 steering 标记直接丢弃(等价 Codex // inject_if_running 在无活动回合时静默跳过)。 goal_.begin_turn(); - turn.swarm_mode = false; - if (!hidden_goal_context && hook_manager_) { + // 跨 agent 信封不是用户输入:不触发 UserPromptSubmit 钩子(与隐藏 goal 上下文同理)。 + const bool inter_agent_input = + mesh::inter_agent_envelope_from_metadata(input.metadata).has_value(); + if (!hidden_goal_context && !inter_agent_input && hook_manager_) { auto fields = hooks_.common_fields(kCodexHookEventUserPromptSubmit, session_); auto payload = build_user_prompt_submit_hook_payload(fields, input.text); auto outcome = hooks_.dispatch(hook_manager_, @@ -104,16 +107,6 @@ void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal } } - turn.swarm_mode = - input.metadata.is_object() && - input.metadata.contains("swarm_mode") && - input.metadata["swarm_mode"].is_boolean() && - input.metadata["swarm_mode"].get(); - struct ActiveTurnSwarmModeReset { - bool& active; - ~ActiveTurnSwarmModeReset() { active = false; } - } swarm_mode_reset{turn.swarm_mode}; - // Codex pre-turn compaction estimates the pending input but summarizes only // already-recorded history. Persisting first would put the new request into // the summary and append the checkpoint after the input, breaking replay. @@ -223,7 +216,7 @@ void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal std::shared_ptr provider_snapshot; if (options_.provider) provider_snapshot = options_.provider(); // Phase 2: Build API request messages - auto bundle = requests_.build(provider_snapshot, emergency_request_profile, turn.swarm_mode); + auto bundle = requests_.build(provider_snapshot, emergency_request_profile); side_questions_.publish(bundle.messages_with_system); turn.model_tool_names.clear(); turn.model_tool_names.reserve(bundle.tool_defs.size()); diff --git a/src/engine/agent/turn/turn_runner.hpp b/src/engine/agent/turn/turn_runner.hpp index c21cf3cb9..d7cf340c6 100644 --- a/src/engine/agent/turn/turn_runner.hpp +++ b/src/engine/agent/turn/turn_runner.hpp @@ -12,12 +12,14 @@ class ProviderStreamCollector; class SideQuestionService; class TurnUsageAccountant; class ModelStepRecorder; +class AgentMailbox; // Constructor input only; the runner keeps fixed, typed collaborator references. struct TurnRunnerServices { ToolExecutionServices tools; TurnOutcomeRecord& outcome; ActiveTurnGate& gate; + AgentMailbox& mailbox; ActivityNarrator& activity; SideQuestionService& side_questions; TurnUsageAccountant& usage; @@ -76,6 +78,7 @@ class TurnRunner { AskUserQuestionPrompter* question_prompter_; TurnOutcomeRecord& outcome_; ActiveTurnGate& gate_; + AgentMailbox& mailbox_; ActivityNarrator& activity_; SideQuestionService& side_questions_; TurnUsageAccountant& usage_; diff --git a/src/engine/agent/turn/turn_runner_support.cpp b/src/engine/agent/turn/turn_runner_support.cpp index a85a701ec..743d0b53b 100644 --- a/src/engine/agent/turn/turn_runner_support.cpp +++ b/src/engine/agent/turn/turn_runner_support.cpp @@ -1,6 +1,7 @@ #include "turn_runner.hpp" #include "turn_context.hpp" #include "active_turn_gate.hpp" +#include "agent/mailbox/agent_mailbox.hpp" #include "agent/compaction/compaction_controller.hpp" #include "agent/request/request_context_source.hpp" #include "agent/transcript/transcript_writer.hpp" @@ -15,7 +16,8 @@ TurnRunner::TurnRunner(TurnRunnerServices s, TurnRunnerOptions options) abort_(s.tools.abort), config_(s.tools.config), source_(s.request_source), session_(s.tools.session), hook_manager_(s.tools.hook_manager), permission_prompter_(s.tools.permission_prompter), question_prompter_(s.tools.question_prompter), - outcome_(s.outcome), gate_(s.gate), activity_(s.activity), side_questions_(s.side_questions), + outcome_(s.outcome), gate_(s.gate), mailbox_(s.mailbox), activity_(s.activity), + side_questions_(s.side_questions), usage_(s.usage), steps_(s.steps), stream_(s.stream), compaction_(s.compaction), recovery_(s.recovery), busy_(s.busy), interrupt_(s.interrupt), context_window_(s.context_window), suggestion_threshold_(s.suggestion_threshold), options_(std::move(options)), @@ -30,7 +32,7 @@ CompactionInputs TurnRunner::compaction_inputs( inputs.session = session_; inputs.hooks = hook_manager_; inputs.provider = options_.provider ? options_.provider() : nullptr; - inputs.request = requests_.options(inputs.provider, turn.swarm_mode); + inputs.request = requests_.options(inputs.provider); inputs.suggestion_threshold = suggestion_threshold_.load(std::memory_order_relaxed); inputs.terminal = terminal; return inputs; @@ -41,7 +43,17 @@ bool TurnRunner::drain_inputs(bool close_if_empty) { for (auto& input : drained.inputs) { transcript_.commit_turn_steering_input(session_, std::move(input), drained.turn_id); } - return !drained.inputs.empty(); + bool any = !drained.inputs.empty(); + // Codex get_pending_input: steering first, then the session mailbox. After + // a final answer queue-only mail waits for the next turn unless same-turn + // user input keeps this turn open; trigger mail already queued a wake turn. + if (!close_if_empty || any) { + for (auto& mail : mailbox_.take_all()) { + transcript_.commit_inter_agent_message(session_, std::move(mail.input)); + any = true; + } + } + return any; } ToolBatchOutcome TurnRunner::execute_tools(TurnContext& turn, const ChatResponse& response, diff --git a/src/engine/agent/turn/turn_steering.cpp b/src/engine/agent/turn/turn_steering.cpp index b175945b9..4ebdca0f8 100644 --- a/src/engine/agent/turn/turn_steering.cpp +++ b/src/engine/agent/turn/turn_steering.cpp @@ -1,5 +1,6 @@ #include "agent/agent_loop.hpp" #include "agent/transcript/transcript_writer.hpp" +#include "agent/mailbox/agent_mailbox.hpp" #include "agent/turn/active_turn_gate.hpp" #include "agent/worker/agent_task_queue.hpp" #include "session/session_manager.hpp" @@ -10,12 +11,18 @@ namespace acecode { TurnSteerResult AgentLoop::steer_input( const std::string& expected_turn_id, const UserInput& input) { - return active_turn_gate_->steer(expected_turn_id, input); + auto result = active_turn_gate_->steer(expected_turn_id, input); + // Codex InputQueueActivity::Steer: steered user input ends agent_wait early. + if (result.accepted()) mailbox_->notify_steer(); + return result; } TurnSteerResult AgentLoop::interject_question( const std::string& request_id, const UserInput& input, const std::string& expected_turn_id) { - return active_turn_gate_->interject(request_id, input, expected_turn_id, ask_prompter_.get()); + auto result = active_turn_gate_->interject( + request_id, input, expected_turn_id, ask_prompter_.get()); + if (result.accepted()) mailbox_->notify_steer(); + return result; } TurnSteerResult AgentLoop::interrupt_turn( diff --git a/src/engine/agent/turn/turn_types.hpp b/src/engine/agent/turn/turn_types.hpp index 7c7c4a520..96d3dd856 100644 --- a/src/engine/agent/turn/turn_types.hpp +++ b/src/engine/agent/turn/turn_types.hpp @@ -60,6 +60,9 @@ struct WorkerTask { // LLM 看到同一份(payload)。 std::string display_text; bool hidden_goal_context = false; + // Mesh swarm: start a turn from the inter-agent mailbox (input taken at run + // time; a no-op when a running turn already consumed the trigger mail). + bool mailbox_wake = false; std::function control; std::string retry_user_message_id; // Shared by the task and its receipt; cancellation does not execute control. diff --git a/src/engine/agent/worker/agent_task_queue.cpp b/src/engine/agent/worker/agent_task_queue.cpp index 63b777022..a1667d075 100644 --- a/src/engine/agent/worker/agent_task_queue.cpp +++ b/src/engine/agent/worker/agent_task_queue.cpp @@ -31,7 +31,8 @@ bool AgentTaskQueue::is_turn(WorkerTask::Kind kind) { } bool AgentTaskQueue::is_user_work(const WorkerTask& task) { - return (task.kind == WorkerTask::Kind::Chat && !task.hidden_goal_context) || + return (task.kind == WorkerTask::Kind::Chat && !task.hidden_goal_context && + !task.mailbox_wake) || task.kind == WorkerTask::Kind::Shell || task.kind == WorkerTask::Kind::Compact; } @@ -86,6 +87,21 @@ void AgentTaskQueue::enqueue(WorkerTask task) { notify(); } +void AgentTaskQueue::enqueue_mailbox_wake() { + const bool queued = with_locked([](Locked& state) { + if (state.stopped()) return false; + const auto is_wake = [](const WorkerTask& task) { return task.mailbox_wake; }; + auto& tasks = state.queue_; + if (std::any_of(tasks.ordinary_.begin(), tasks.ordinary_.end(), is_wake)) return false; + WorkerTask task; + task.kind = WorkerTask::Kind::Chat; + task.mailbox_wake = true; + state.push(std::move(task)); + return true; + }); + if (queued) notify(); +} + bool AgentTaskQueue::has_pending_work() { return with_locked([&](Locked& state) { return busy_.load() || state.worker_active() || !state.empty(); diff --git a/src/engine/agent/worker/agent_task_queue.hpp b/src/engine/agent/worker/agent_task_queue.hpp index 179dc26fd..f96babea0 100644 --- a/src/engine/agent/worker/agent_task_queue.hpp +++ b/src/engine/agent/worker/agent_task_queue.hpp @@ -60,6 +60,8 @@ class AgentTaskQueue { void enqueue(WorkerTask task); ControlEnqueueReceipt enqueue_control(std::function control); bool enqueue_suggestion(const UserInput& input, const std::string& id, AbortSignal& abort); + // Queues one mailbox-wake Chat task unless one is already waiting. + void enqueue_mailbox_wake(); bool has_pending_work(); bool has_user_work(); bool has_suggestion(const std::string& id); diff --git a/src/engine/agent/worker/agent_worker.cpp b/src/engine/agent/worker/agent_worker.cpp index 72bb53537..2309b6869 100644 --- a/src/engine/agent/worker/agent_worker.cpp +++ b/src/engine/agent/worker/agent_worker.cpp @@ -6,6 +6,7 @@ #include "agent/hook_bridge/agent_hook_bridge.hpp" #include "agent/turn/turn_outcome.hpp" #include "agent/worker/agent_task_queue.hpp" +#include "agent/mailbox/agent_mailbox.hpp" #include "agent/detail/agent_payloads.hpp" #include "utils/encoding.hpp" #include "utils/logger.hpp" @@ -27,6 +28,14 @@ void AgentLoop::worker_main() { } switch (task.kind) { case WorkerTask::Kind::Chat: + if (task.mailbox_wake) { + // Codex maybe_start_turn_for_pending_work: only trigger mail + // starts a turn; a running turn may already have taken it. + agent::AgentMailbox::Mail mail; + if (!mailbox_->has_trigger_turn() || !mailbox_->pop_front(mail)) break; + run_agent_with_input(mail.input, false); + break; + } if (!task.retry_user_message_id.empty()) { const auto message = retryable_user_message(task.retry_user_message_id); if (!message) { diff --git a/src/engine/prompt/mesh_swarm_prompts.cpp b/src/engine/prompt/mesh_swarm_prompts.cpp new file mode 100644 index 000000000..07f7a1521 --- /dev/null +++ b/src/engine/prompt/mesh_swarm_prompts.cpp @@ -0,0 +1,97 @@ +#include "mesh_swarm_prompts.hpp" + +namespace acecode { + +namespace { + +// Codex DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT. Tool names follow +// ACECode's agent_* prefix; Codex's analysis-channel / to=/root addressing is +// replaced by the user-role envelope ACECode delivers. +constexpr const char* kRootRoleText = + "You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.\n" + "\n" + "At the start of your turn, you are the active agent.\n" + "You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.\n" + "All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.\n" + "\n" + "You can use `agent_spawn` to create a new agent, `agent_followup_task` to give an existing agent a new task and trigger a turn, and `agent_send_message` to pass a message to a running agent without triggering a turn.\n" + "Child agents can also spawn their own sub-agents.\n" + "You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter.\n" + "\n" + "You will receive messages from other agents as user-role messages wrapped in `` tags, in the form:\n" + "```\n" + "Message Type: MESSAGE | FINAL_ANSWER\n" + "Task name: \n" + "Sender: \n" + "Payload:\n" + "\n" + "```\n" + "They are addressed to you with Task name: /root. They are not messages from the user.\n"; + +// Codex DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, adapted the same way; +// "final channel" becomes ACECode's final reply (a response without tool calls). +constexpr const char* kSubagentRoleText = + "You are an agent in a team of agents collaborating to complete a task.\n" + "\n" + "You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.\n" + "\n" + "You can use `agent_spawn` to create a new agent, `agent_followup_task` to give an existing agent a new task and trigger a turn, and `agent_send_message` to pass a message to a running agent.\n" + "Child agents can also spawn their own sub-agents.\n" + "\n" + "When you end your turn with a final reply (a response without tool calls), that content is immediately delivered back to your parent agent.\n" + "\n" + "You will receive messages as user-role messages wrapped in `` tags, in the form:\n" + "```\n" + "Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER\n" + "Task name: \n" + "Sender: \n" + "Payload:\n" + "\n" + "```\n" + "The Task name of a message addressed to you, such as /root/..., is your identity. These messages come from other agents, not from the user.\n"; + +// Codex DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT without the +// functions.exec paragraph (ACECode has no code-mode exec namespace). +constexpr const char* kSharedText = + "All agents share the same directory. In detail:\n" + "- All agents have access to the same machine and filesystem as you.\n" + "- All agents use the same current working directory.\n" + "- As a result, edits made by one agent are immediately visible to all other agents.\n"; + +constexpr const char* kWaitText = + "When calling `agent_wait`, prefer longer waits (minutes) to avoid busy polling."; + +constexpr const char* kModelOverrideText = + "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; + +// Codex PROACTIVE_MULTI_AGENT_MODE_TEXT. Selecting the mesh swarm mode is the +// user's request for proactive delegation, so ACECode always uses this text. +constexpr const char* kProactiveModeText = + "Proactive multi-agent delegation is active. Any earlier developer instruction requiring an explicit user request before spawning sub-agents no longer applies. This mode remains active until a later multi-agent mode developer message changes it. User requests override this hint.\n" + "\n" + "If at any point you can parallelize work by delegating tasks to another agent (no matter if you are root or subagent), you should do so using collaboration tools if it could save time or improve quality."; + +} // namespace + +std::string build_mesh_swarm_context_prompt(const MeshSwarmPromptOptions& options) { + const int slots = options.max_concurrency > 0 ? options.max_concurrency : 1; + const std::string slot_text = std::to_string(slots); + std::string role = options.is_root ? kRootRoleText : kSubagentRoleText; + role += "\n"; + role += kSharedText; + role += "\n"; + if (options.wait_agent_enabled) { + role += kWaitText; + role += "\n\n"; + } + role += "There are " + slot_text + " available concurrency slots, meaning that up to " + + slot_text + " agents can be active at once, including you."; + if (options.expose_model_overrides) { + role += "\n\n"; + role += kModelOverrideText; + } + return "\n" + role + "\n\n\n\n" + + kProactiveModeText + "\n"; +} + +} // namespace acecode diff --git a/src/engine/prompt/mesh_swarm_prompts.hpp b/src/engine/prompt/mesh_swarm_prompts.hpp new file mode 100644 index 000000000..b1da3d7ae --- /dev/null +++ b/src/engine/prompt/mesh_swarm_prompts.hpp @@ -0,0 +1,28 @@ +#pragma once + +// Mesh swarm (Codex Multi-Agent V2) role and mode instructions, ported from +// codex-rs/prompts/src/multi_agent_instructions.rs and +// model_messages/multi_agent.rs with ACECode tool names. The output depends +// only on these options, so it is byte-stable within a turn and identical for +// every child agent of a tree (the agent path itself arrives in NEW_TASK). + +#include + +namespace acecode { + +struct MeshSwarmPromptOptions { + bool is_root = true; + int max_concurrency = 4; + // agent_wait is visible to the model. + bool wait_agent_enabled = true; + // agent_spawn exposes model / reasoning_effort overrides. + bool expose_model_overrides = true; +}; + +// Role text (root or child) + shared guidance + concurrency slots, then the +// proactive multi-agent mode text. Wrapped in and +// tags because ACECode injects request context as a +// user-role message. +std::string build_mesh_swarm_context_prompt(const MeshSwarmPromptOptions& options); + +} // namespace acecode diff --git a/tests/agent/characterization_lifecycle_test.cpp b/tests/agent/characterization_lifecycle_test.cpp index 1c5ad8225..5bdbe937d 100644 --- a/tests/agent/characterization_lifecycle_test.cpp +++ b/tests/agent/characterization_lifecycle_test.cpp @@ -193,19 +193,26 @@ TEST(AgentLoopLifecycleGolden, StopHookActiveSurvivesCappedTurnUntilNextStopDeci EXPECT_EQ(h.observed->done, 3); } -// 场景:swarm 用户回合后提交普通回合。期望:仅首轮有 swarm 上下文,静态前缀不变; -// 回归会让后续普通聊天继续错误地启动群体协作。 -TEST(AgentLoopLifecycleGolden, SwarmModeResetsBetweenVisibleTurns) { +// 场景:会话级蜂群模式(星型)下跑一个回合,随后把模式切回 off 再跑普通回合;另外 +// 用户消息 metadata 里残留旧的 swarm_mode=true 布尔。 +// 期望:仅首轮有 swarm 上下文,模式切换只影响下一回合,静态前缀不变;消息 metadata +// 不再驱动模式(add-mesh-swarm-mode 把蜂群模式升级为会话级,由 set_swarm_context +// 发布)。回归会让关掉蜂群后的普通聊天继续错误地启动群体协作。 +TEST(AgentLoopLifecycleGolden, SwarmModeFollowsSessionContextBetweenTurns) { Isolation isolation; Harness h(isolation); h.tools.register_tool(h.probe("spawn_subagent", true)); + agent::SwarmContext star; + star.mode = SwarmMode::Star; + h.loop->set_swarm_context(star); h.provider->push_text("swarm finished"); - UserInput swarm; - swarm.text = "coordinate this turn"; - swarm.metadata = {{"swarm_mode", true}}; - ASSERT_TRUE(h.perform([loop = h.loop.get(), swarm] { loop->submit(swarm); })); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("coordinate this turn"); })); + h.loop->set_swarm_context(agent::SwarmContext{}); h.provider->push_text("ordinary finished"); - ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("ordinary next turn"); })); + UserInput stale; + stale.text = "ordinary next turn"; + stale.metadata = {{"swarm_mode", true}}; + ASSERT_TRUE(h.perform([loop = h.loop.get(), stale] { loop->submit(stale); })); const auto first = h.provider->messages_for_turn(0); const auto second = h.provider->messages_for_turn(1); const auto count_swarm = [](const std::vector& messages) { diff --git a/tests/session/agent_path_test.cpp b/tests/session/agent_path_test.cpp new file mode 100644 index 000000000..8b7c66c72 --- /dev/null +++ b/tests/session/agent_path_test.cpp @@ -0,0 +1,128 @@ +// 覆盖蜂群模式(网状)的 agent 路径纯逻辑(src/domain/session/agent_path.*), +// 规则逐条对照 Codex codex-rs/protocol/src/agent_path.rs: +// 1. 根路径 /root 的名字、深度、父路径 +// 2. join 拼出子路径,任务名非法时返回 Codex 原文错误 +// 3. resolve 区分 canonical(/root 开头)与相对引用 +// 4. 兄弟子树之间只能用 canonical 路径互相寻址 +// 5. 绝对路径校验:不以 /root 开头、以 / 结尾、段名非法都拒绝 +// 6. has_prefix 按段匹配,不把 /root/ab 当成 /root/a 的后代 + +#include + +#include "session/agent_path.hpp" + +using acecode::mesh::AgentPath; +using acecode::mesh::validate_agent_name; + +// 场景:取根路径的基本属性。 +// 期望:字符串为 "/root",名字为 "root",深度 0,没有父路径。 +TEST(MeshAgentPath, RootHasNameRootAndNoParent) { + const AgentPath root = AgentPath::root(); + EXPECT_EQ(root.str(), "/root"); + EXPECT_TRUE(root.is_root()); + EXPECT_EQ(root.name(), "root"); + EXPECT_EQ(root.depth(), 0); + EXPECT_FALSE(root.parent().has_value()); +} + +// 场景:/root 依次 join "researcher" 与 "worker"(二级嵌套)。 +// 期望:得到 /root/researcher/worker,名字为最后一段,深度 2,父路径逐级回退。 +TEST(MeshAgentPath, JoinBuildsNestedChildPaths) { + std::string error; + const auto child = AgentPath::root().join("researcher", &error); + ASSERT_TRUE(child.has_value()) << error; + const auto grandchild = child->join("worker", &error); + ASSERT_TRUE(grandchild.has_value()) << error; + EXPECT_EQ(grandchild->str(), "/root/researcher/worker"); + EXPECT_EQ(grandchild->name(), "worker"); + EXPECT_EQ(grandchild->depth(), 2); + ASSERT_TRUE(grandchild->parent().has_value()); + EXPECT_EQ(grandchild->parent()->str(), "/root/researcher"); +} + +// 场景:join 的任务名含大写/连字符、为保留名 root/./..、含斜杠或为空。 +// 期望:全部拒绝,错误文案与 Codex validate_agent_name 逐字一致(模型看到的 +// 提示与 Codex 训练分布一致)。 +TEST(MeshAgentPath, JoinRejectsInvalidTaskNamesWithCodexMessages) { + std::string error; + EXPECT_FALSE(AgentPath::root().join("BadName", &error).has_value()); + EXPECT_EQ(error, "agent_name must use only lowercase letters, digits, and underscores"); + EXPECT_FALSE(AgentPath::root().join("review-tests", &error).has_value()); + EXPECT_EQ(error, "agent_name must use only lowercase letters, digits, and underscores"); + EXPECT_FALSE(AgentPath::root().join("root", &error).has_value()); + EXPECT_EQ(error, "agent_name `root` is reserved"); + EXPECT_FALSE(AgentPath::root().join("..", &error).has_value()); + EXPECT_EQ(error, "agent_name `..` is reserved"); + EXPECT_FALSE(AgentPath::root().join("a/b", &error).has_value()); + EXPECT_EQ(error, "agent_name must not contain `/`"); + EXPECT_EQ(validate_agent_name(""), "agent_name must not be empty"); + EXPECT_EQ(validate_agent_name("task_3"), ""); +} + +// 场景:当前 agent 为 /root/task1,分别解析相对名 "task_3"、canonical +// "/root/task1/task_3"、"/root"。 +// 期望:前两者都指向 /root/task1/task_3(可互换引用),"/root" 解析为根。 +TEST(MeshAgentPath, ResolveTreatsRelativeAndCanonicalInterchangeably) { + const auto current = AgentPath::parse("/root/task1"); + ASSERT_TRUE(current.has_value()); + const auto relative = current->resolve("task_3"); + const auto canonical = current->resolve("/root/task1/task_3"); + ASSERT_TRUE(relative.has_value()); + ASSERT_TRUE(canonical.has_value()); + EXPECT_EQ(relative->str(), "/root/task1/task_3"); + EXPECT_EQ(*relative, *canonical); + const auto root = current->resolve("/root"); + ASSERT_TRUE(root.has_value()); + EXPECT_TRUE(root->is_root()); +} + +// 场景:兄弟子树 /root/task2/task_3 想引用 /root/task1/task_3,却用了相对名 +// "task_3";以及用 "../sibling" 试图向上跳。 +// 期望:相对名解析到自己名下的 /root/task2/task_3/task_3(与 Codex 一致,只有 +// canonical 路径能跨子树);".." 作为段名被拒绝,不存在向上相对寻址。 +TEST(MeshAgentPath, SiblingSubtreesNeedCanonicalPaths) { + const auto current = AgentPath::parse("/root/task2/task_3"); + ASSERT_TRUE(current.has_value()); + const auto relative = current->resolve("task_3"); + ASSERT_TRUE(relative.has_value()); + EXPECT_EQ(relative->str(), "/root/task2/task_3/task_3"); + std::string error; + EXPECT_FALSE(current->resolve("../sibling", &error).has_value()); + EXPECT_EQ(error, "agent_name `..` is reserved"); + EXPECT_FALSE(current->resolve("", &error).has_value()); + EXPECT_EQ(error, "agent path must not be empty"); + EXPECT_FALSE(current->resolve("task_3/", &error).has_value()); + EXPECT_EQ(error, "relative agent path must not end with `/`"); +} + +// 场景:解析不以 /root 开头、以 / 结尾、段名非法的绝对路径,以及 Codex 保留的 +// /morpheus(ACECode 不支持)。 +// 期望:全部拒绝并给出对应的 Codex 文案;合法的多级路径被接受。 +TEST(MeshAgentPath, ParseValidatesAbsolutePaths) { + std::string error; + EXPECT_FALSE(AgentPath::parse("/not-root", &error).has_value()); + EXPECT_EQ(error, "absolute agent paths must start with `/root` or be `/morpheus`"); + EXPECT_FALSE(AgentPath::parse("root/a", &error).has_value()); + EXPECT_EQ(error, "absolute agent paths must start with `/root` or be `/morpheus`"); + EXPECT_FALSE(AgentPath::parse("/morpheus", &error).has_value()); + EXPECT_FALSE(AgentPath::parse("/root/a/", &error).has_value()); + EXPECT_EQ(error, "absolute agent path must not end with `/`"); + EXPECT_FALSE(AgentPath::parse("/root/A", &error).has_value()); + EXPECT_EQ(error, "agent_name must use only lowercase letters, digits, and underscores"); + EXPECT_TRUE(AgentPath::parse("/root/a_1/b2").has_value()); +} + +// 场景:agent_list(path_prefix) 的过滤判定,前缀 "/root/a"。 +// 期望:/root/a 与 /root/a/x 命中,/root/ab(只是字符串前缀相同)不命中; +// 空前缀命中一切。 +TEST(MeshAgentPath, HasPrefixMatchesWholeSegments) { + const auto a = AgentPath::parse("/root/a"); + const auto ax = AgentPath::parse("/root/a/x"); + const auto ab = AgentPath::parse("/root/ab"); + ASSERT_TRUE(a && ax && ab); + EXPECT_TRUE(a->has_prefix("/root/a")); + EXPECT_TRUE(ax->has_prefix("/root/a")); + EXPECT_FALSE(ab->has_prefix("/root/a")); + EXPECT_TRUE(ab->has_prefix("")); + EXPECT_TRUE(ab->has_prefix("/root")); +} diff --git a/tests/session/inter_agent_message_test.cpp b/tests/session/inter_agent_message_test.cpp new file mode 100644 index 000000000..643ee2e89 --- /dev/null +++ b/tests/session/inter_agent_message_test.cpp @@ -0,0 +1,187 @@ +// 覆盖蜂群模式(网状)的跨 agent 信封与会话元数据身份字段: +// 1. 信封正文逐字等于 Codex InterAgentMessage 的 body,外层包 +// 2. metadata.inter_agent 往返:类型、发件人、收件人、发件会话 id、完成状态 +// 3. 消息谓词:NEW_TASK 视同真实用户请求,MESSAGE / FINAL_ANSWER 属内部上下文 +// 4. SessionMeta 的 swarm_mode / mesh_agent 字段往返,off 与空身份序列化省略 +// 5. 蜂群模式名解析:canonical 名、大小写、旧布尔 true/false、非法值 +// 6. 信封不计入可见用户回合、不进入会话摘要 + +#include + +#include "llm/message_predicates.hpp" +#include "session/inter_agent_message.hpp" +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" +#include "session/swarm_mode.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::ChatMessage; +using acecode::SessionMeta; +using acecode::SessionStorage; +using acecode::SwarmMode; +using acecode::mesh::InterAgentEnvelope; +using acecode::mesh::InterAgentMessageType; + +namespace { + +fs::path make_tmp_dir(const std::string& hint) { + auto dir = fs::temp_directory_path() / + ("acecode_inter_agent_" + hint + "_" + std::to_string(std::random_device{}())); + fs::remove_all(dir); + fs::create_directories(dir); + return dir; +} + +ChatMessage envelope_message(InterAgentMessageType type) { + InterAgentEnvelope envelope; + envelope.type = type; + envelope.recipient = "/root/a"; + envelope.sender = "/root"; + envelope.payload = "run the tests"; + ChatMessage message; + message.role = "user"; + message.content = acecode::mesh::render_inter_agent_message(envelope); + message.metadata = nlohmann::json::object(); + message.metadata[acecode::mesh::kInterAgentMetadataKey] = + acecode::mesh::inter_agent_metadata(envelope); + return message; +} + +} // namespace + +// 场景:/root 给 /root/a 发一条 MESSAGE,正文 "请先跑测试"。 +// 期望:body 四行字段与 Codex 的 format!("Message Type: {}\nTask name: {}\nSender: {}\nPayload:\n{}") +// 逐字一致;完整消息外层包 标签(有意偏离:Codex 用 assistant +// 角色且无标签,ACECode 用 user 角色需要标签与真实用户输入区分)。 +TEST(MeshInterAgentMessage, RendersCodexBodyInsideTags) { + InterAgentEnvelope envelope; + envelope.type = InterAgentMessageType::Message; + envelope.recipient = "/root/a"; + envelope.sender = "/root"; + envelope.payload = u8"请先跑测试"; + EXPECT_EQ(acecode::mesh::render_inter_agent_body(envelope), + std::string("Message Type: MESSAGE\nTask name: /root/a\nSender: /root\nPayload:\n") + + u8"请先跑测试"); + EXPECT_EQ(acecode::mesh::render_inter_agent_message(envelope), + std::string("\nMessage Type: MESSAGE\nTask name: /root/a\n" + "Sender: /root\nPayload:\n") + u8"请先跑测试" + "\n"); + envelope.type = InterAgentMessageType::FinalAnswer; + EXPECT_NE(acecode::mesh::render_inter_agent_body(envelope).find("Message Type: FINAL_ANSWER"), + std::string::npos); +} + +// 场景:带发件会话 id 与完成状态的 FINAL_ANSWER 元数据写入后再读回。 +// 期望:类型、路径、会话 id、status 全部还原;非对象或缺类型的元数据读不出信封。 +TEST(MeshInterAgentMessage, MetadataRoundTrips) { + InterAgentEnvelope envelope; + envelope.type = InterAgentMessageType::FinalAnswer; + envelope.recipient = "/root"; + envelope.sender = "/root/a"; + envelope.sender_session_id = "20261003-010203-abcd"; + envelope.final_status = "errored"; + nlohmann::json metadata = {{"inter_agent", acecode::mesh::inter_agent_metadata(envelope)}}; + const auto parsed = acecode::mesh::inter_agent_envelope_from_metadata(metadata); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->type, InterAgentMessageType::FinalAnswer); + EXPECT_EQ(parsed->recipient, "/root"); + EXPECT_EQ(parsed->sender, "/root/a"); + EXPECT_EQ(parsed->sender_session_id, "20261003-010203-abcd"); + EXPECT_EQ(parsed->final_status, "errored"); + EXPECT_FALSE(acecode::mesh::inter_agent_envelope_from_metadata(nlohmann::json::array())); + EXPECT_FALSE(acecode::mesh::inter_agent_envelope_from_metadata( + nlohmann::json{{"inter_agent", {{"type", "BOGUS"}}}})); +} + +// 场景:三种信封分别交给消息谓词判断。 +// 期望:NEW_TASK 是真实用户消息(子 agent 的任务就是它的「用户请求」,可变上下文 +// 要锚在它前面);MESSAGE 与 FINAL_ANSWER 不是 —— 否则回合中途每到一封信, +// insert_context_before_last_real_user_or_summary 的锚点就后移一次,同回合的 +// 前缀缓存从锚点处被打穿。普通用户消息不受影响。 +TEST(MeshInterAgentMessage, OnlyNewTaskCountsAsRealUserMessage) { + EXPECT_TRUE(acecode::is_real_user_message(envelope_message(InterAgentMessageType::NewTask))); + EXPECT_FALSE(acecode::is_real_user_message(envelope_message(InterAgentMessageType::Message))); + EXPECT_FALSE(acecode::is_real_user_message(envelope_message(InterAgentMessageType::FinalAnswer))); + EXPECT_TRUE(acecode::mesh::is_inter_agent_message(envelope_message(InterAgentMessageType::Message))); + ChatMessage plain; + plain.role = "user"; + plain.content = "hello"; + EXPECT_TRUE(acecode::is_real_user_message(plain)); + EXPECT_FALSE(acecode::mesh::is_inter_agent_message(plain)); +} + +// 场景:子 agent 的 meta 写入 swarm_mode=mesh、父会话(=树根)与 mesh_agent 路径后读回;另一个 +// 普通会话 swarm_mode 为空。 +// 期望:身份字段原样还原;普通会话的 JSON 里不出现 swarm_mode / mesh_agent 键 +// (老 meta 逐字节不变,旧版本读新 meta 时也只是忽略多出的键)。 +TEST(MeshInterAgentMessage, SessionMetaSwarmFieldsRoundTripAndAreOmittedWhenOff) { + const auto dir = make_tmp_dir("meta"); + const auto mesh_path = (dir / "20261003-000000-aaaa.meta.json").string(); + SessionMeta mesh; + mesh.id = "20261003-000000-aaaa"; + mesh.swarm_mode = "mesh"; + mesh.parent_session_id = "20261003-000000-root"; + mesh.agent_path = "/root/explore/tests"; + ASSERT_TRUE(SessionStorage::write_meta(mesh_path, mesh)); + const SessionMeta read = SessionStorage::read_meta(mesh_path); + EXPECT_EQ(read.swarm_mode, "mesh"); + EXPECT_EQ(read.parent_session_id, "20261003-000000-root"); + EXPECT_EQ(read.agent_path, "/root/explore/tests"); + + const auto plain_path = (dir / "20261003-000000-bbbb.meta.json").string(); + SessionMeta plain; + plain.id = "20261003-000000-bbbb"; + plain.swarm_mode = "off"; + ASSERT_TRUE(SessionStorage::write_meta(plain_path, plain)); + std::ifstream in(plain_path); + const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + EXPECT_EQ(text.find("swarm_mode"), std::string::npos) << text; + EXPECT_EQ(text.find("mesh_agent"), std::string::npos) << text; + in.close(); + fs::remove_all(dir); +} + +// 场景:解析蜂群模式名,含旧客户端的布尔字面量。 +// 期望:off/star/mesh 不分大小写;"true" 兼容为 star、"false" 为 off;空串与 +// 未知值返回 nullopt(由调用方报 400 / 命令错误)。 +TEST(MeshInterAgentMessage, ParsesSwarmModeNames) { + EXPECT_EQ(acecode::parse_swarm_mode("mesh"), SwarmMode::Mesh); + EXPECT_EQ(acecode::parse_swarm_mode("STAR"), SwarmMode::Star); + EXPECT_EQ(acecode::parse_swarm_mode("off"), SwarmMode::Off); + EXPECT_EQ(acecode::parse_swarm_mode("true"), SwarmMode::Star); + EXPECT_EQ(acecode::parse_swarm_mode("false"), SwarmMode::Off); + EXPECT_FALSE(acecode::parse_swarm_mode("ring").has_value()); + EXPECT_FALSE(acecode::parse_swarm_mode("").has_value()); + EXPECT_STREQ(acecode::swarm_mode_name(SwarmMode::Mesh), "mesh"); +} + +// 场景:一个只收到过 NEW_TASK 与 MESSAGE 信封的子 agent 会话落盘;然后设置 +// 蜂群模式与网状身份。 +// 期望:可见用户回合数为 0、摘要为空(侧栏 / 列表不会把信封当成用户输入); +// 模式与身份经 SessionManager 写进 meta,重新读取一致。 +TEST(MeshInterAgentMessage, EnvelopesDoNotCountAsVisibleUserTurns) { + const auto dir = make_tmp_dir("manager"); + const std::string cwd = acecode::path_to_utf8(dir); + acecode::SessionManager manager; + manager.start_session(cwd, "openai", "gpt-test", "20261003-111111-cccc", "", "daemon"); + manager.set_swarm_mode("mesh"); + manager.set_mesh_agent_path("/root/a"); + manager.on_message(envelope_message(InterAgentMessageType::NewTask)); + manager.on_message(envelope_message(InterAgentMessageType::Message)); + const std::string project_dir = SessionStorage::get_project_dir(cwd); + const SessionMeta meta = SessionStorage::read_meta( + SessionStorage::meta_path(project_dir, "20261003-111111-cccc")); + EXPECT_EQ(meta.turn_count, 0); + EXPECT_TRUE(meta.summary.empty()) << meta.summary; + EXPECT_EQ(meta.swarm_mode, "mesh"); + EXPECT_EQ(meta.agent_path, "/root/a"); + EXPECT_EQ(manager.current_swarm_mode(), "mesh"); + manager.end_current_session(); + fs::remove_all(project_dir); + fs::remove_all(dir); +} From 5dc984c394fbd1cb3963a9f661f6ea3d828d50d9 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sat, 3 Oct 2026 10:41:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(mesh):=20=E8=9C=82=E7=BE=A4=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=88=E7=BD=91=E7=8A=B6=EF=BC=89=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=A4=8D=E5=88=BB=20Codex=20Multi-Agent=20V2=20=E7=9A=84=20age?= =?UTF-8?q?nt=20=E5=8D=8F=E4=BD=9C=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeshAgentService(host/session_host/mesh):按根会话建树,路径寻址、LRU 驻留 (默认 4 含根)、换出后按需恢复、完成回报(FINAL_ANSWER 不唤醒空闲父, interrupted 不回报)、重启后按 mesh_agents.json 重建 - 六个 agent_* 工具(spawn/list/send_message/followup_task/wait/interrupt), 参数与输出对齐 Codex;与 spawn_subagent / thread 工具按会话模式互斥 - 会话级 swarm_mode(off/star/mesh):消息体、/swarm(TUI + daemon)、headless --swarm;退出网状时有运行中 agent 则拒绝;网状子 agent 不接受直接输入 - TUI:SubagentHost 以主会话为外部根接入网状服务,侧栏以路径显示; agent 间信封在实时与回放中显示为系统行 - Web:两个互斥菜单项与芯片,芯片跟随服务端模式、只有改选才提交; 信封转系统提示行;后台任务面板显示 agent 路径 - 测试:网状服务集成测试(真实 SessionRegistry + 脚本化 provider)、 AgentLoop 邮箱投递、配置 / 命令 / headless / 前端纯逻辑 - 文档:docs/subagents.md §9、daemon-api、帮助页、CLAUDE.md、openspec 实现记录 --- CLAUDE.md | 2 + docs/daemon-api.md | 17 +- docs/help-source/group5.py | 11 +- docs/help-source/sources.json | 2 + docs/help/assets/search-index.js | 6 +- docs/help/swarm.html | 6 +- docs/subagents.md | 91 +++ .../changes/add-mesh-swarm-mode/design.md | 13 + .../specs/mesh-agent-collaboration/spec.md | 12 +- .../specs/mesh-agent-observability/spec.md | 40 +- .../specs/mesh-agent-residency/spec.md | 24 +- openspec/changes/add-mesh-swarm-mode/tasks.md | 99 +-- scripts/layers/size_baseline.txt | 2 +- src/apps/daemon/worker.cpp | 18 +- src/apps/headless/headless_options.cpp | 15 +- src/apps/headless/headless_options.hpp | 1 + src/apps/headless/headless_runner.cpp | 13 + src/apps/tui/app/tui_agent_bridge.cpp | 7 +- src/apps/tui/app/tui_app.hpp | 1 + src/apps/tui/app/tui_app_session.cpp | 6 + src/apps/tui/commands/command_bootstrap.cpp | 3 + src/apps/tui/commands/swarm_mode_command.cpp | 66 ++ src/apps/tui/commands/swarm_mode_command.hpp | 15 + src/apps/tui/resume/session_replay.cpp | 10 +- src/apps/tui/subagent_host.cpp | 28 +- src/apps/tui/subagent_host.hpp | 7 + .../web/handlers/builtin_command_handler.cpp | 4 +- src/apps/web/handlers/commands_handler.cpp | 4 + src/apps/web/routes/routes_sessions.cpp | 40 +- src/apps/web/server_helpers.cpp | 32 +- src/apps/web/server_impl.hpp | 3 + src/base/config/config.cpp | 2 + src/base/config/config.hpp | 17 + src/base/config/swarm_config.cpp | 57 ++ src/domain/session/agent_fork_history.cpp | 98 +++ src/domain/session/agent_fork_history.hpp | 33 + src/domain/session/inter_agent_message.cpp | 35 + src/domain/session/inter_agent_message.hpp | 7 + src/domain/session/mesh_tree_index.cpp | 63 ++ src/domain/session/mesh_tree_index.hpp | 25 + src/domain/session/session_client.hpp | 8 + src/domain/session/session_manager.hpp | 6 + src/domain/session/session_manager_agent.cpp | 12 + .../session/session_manager_history.cpp | 2 +- src/engine/agent/request/agent_request.cpp | 10 + src/engine/agent/request/swarm_context.hpp | 22 +- .../session_host/mesh/mesh_agent_events.cpp | 141 ++++ .../session_host/mesh/mesh_agent_service.cpp | 579 ++++++++++++++++ .../session_host/mesh/mesh_agent_service.hpp | 190 +++++ .../session_host/mesh/mesh_agent_spawn.cpp | 207 ++++++ .../session_host/mesh/mesh_agent_state.hpp | 57 ++ src/host/session_host/session_registry.cpp | 79 +-- src/host/session_host/session_registry.hpp | 18 + .../session_host/session_registry_history.cpp | 89 +++ .../session_host/session_registry_swarm.cpp | 117 ++++ src/host/session_host/swarm_command.cpp | 54 ++ src/host/session_host/swarm_command.hpp | 25 + .../session_host/tools/mesh_agent_tools.cpp | 395 +++++++++++ .../session_host/tools/mesh_agent_tools.hpp | 31 + tests/agent/agent_loop_mesh_mailbox_test.cpp | 282 ++++++++ tests/agent/agent_mailbox_test.cpp | 97 +++ .../agent/characterization_lifecycle_test.cpp | 10 +- tests/config/config_swarm_test.cpp | 75 ++ tests/headless/headless_options_test.cpp | 21 + tests/session/mesh_swarm_domain_test.cpp | 223 ++++++ .../session_host/mesh_agent_service_test.cpp | 656 ++++++++++++++++++ tests/session_host/swarm_command_test.cpp | 42 ++ tests/web/commands_handler_test.cpp | 13 +- tests/web/web_server_smoke_test.cpp | 16 +- web/scripts/i18n-en-overrides.mjs | 10 +- web/src/components/ChatView.jsx | 35 +- .../components/ComposerSessionControls.jsx | 25 +- web/src/components/InputBar.jsx | 49 +- web/src/components/SubagentPanel.jsx | 6 +- web/src/i18n/catalogs/systemNotice.js | 4 + web/src/i18n/sourceCatalog.generated.js | 18 +- web/src/lib/chatInputQueue.js | 5 +- web/src/lib/chatInputQueue.test.js | 17 + ...omposerSessionControlsArchitecture.test.js | 27 +- .../lib/expertComponentsArchitecture.test.js | 2 +- web/src/lib/interAgentMessage.js | 71 ++ web/src/lib/interAgentMessage.test.js | 98 +++ web/src/lib/runTests.js | 2 + web/src/lib/sessionTranscript.js | 19 +- .../subagentPanelSplitArchitecture.test.js | 6 +- web/src/lib/subagentTasks.js | 7 +- web/src/lib/subagentTasks.test.js | 18 + web/src/lib/swarmMode.js | 60 ++ web/src/lib/swarmMode.test.js | 72 ++ web/src/lib/systemNotice.js | 7 +- web/src/lib/useSubagentTasks.js | 5 +- 91 files changed, 4703 insertions(+), 272 deletions(-) create mode 100644 src/apps/tui/commands/swarm_mode_command.cpp create mode 100644 src/apps/tui/commands/swarm_mode_command.hpp create mode 100644 src/base/config/swarm_config.cpp create mode 100644 src/domain/session/agent_fork_history.cpp create mode 100644 src/domain/session/agent_fork_history.hpp create mode 100644 src/domain/session/mesh_tree_index.cpp create mode 100644 src/domain/session/mesh_tree_index.hpp create mode 100644 src/host/session_host/mesh/mesh_agent_events.cpp create mode 100644 src/host/session_host/mesh/mesh_agent_service.cpp create mode 100644 src/host/session_host/mesh/mesh_agent_service.hpp create mode 100644 src/host/session_host/mesh/mesh_agent_spawn.cpp create mode 100644 src/host/session_host/mesh/mesh_agent_state.hpp create mode 100644 src/host/session_host/session_registry_history.cpp create mode 100644 src/host/session_host/session_registry_swarm.cpp create mode 100644 src/host/session_host/swarm_command.cpp create mode 100644 src/host/session_host/swarm_command.hpp create mode 100644 src/host/session_host/tools/mesh_agent_tools.cpp create mode 100644 src/host/session_host/tools/mesh_agent_tools.hpp create mode 100644 tests/agent/agent_loop_mesh_mailbox_test.cpp create mode 100644 tests/agent/agent_mailbox_test.cpp create mode 100644 tests/config/config_swarm_test.cpp create mode 100644 tests/session/mesh_swarm_domain_test.cpp create mode 100644 tests/session_host/mesh_agent_service_test.cpp create mode 100644 tests/session_host/swarm_command_test.cpp create mode 100644 web/src/lib/interAgentMessage.js create mode 100644 web/src/lib/interAgentMessage.test.js create mode 100644 web/src/lib/swarmMode.js create mode 100644 web/src/lib/swarmMode.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 9853a0084..ab4807765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,8 @@ Full subagent reference (design, wire protocol, Web panel, TUI bridging, test ma Sub-agent sessions are **hidden from normal session lists** and surfaced only in the parent session's Web「后台任务」panel: the child meta persists `parent_session_id` (survives daemon restart; resume restores `subagent_depth=1` so the depth limit keeps holding), `GET /api/sessions` excludes them by default, `?parent=` queries them, and `DELETE /api/sessions/:id?purge=1` is the panel's「清除」(destroy + delete disk data; sub-agent sessions only, busy → 409). Frontend: `web/src/lib/subagentTasks.js` (pure state, Node tests) + `lib/useSubagentTasks.js` (REST list + WS increments + retainSession for running children so their permission/question requests reach App-level listeners even with the panel closed) + `components/SubagentPanel.jsx` (overlay inside ChatView's message area: running/settled card groups, per-card stop, read-only transcript that shares the main conversation's `projectCollapsedTranscriptItems` projection and `TranscriptItems` renderer, with AskUserQuestion rows filtered out through the projection seam). A running child's `permission_request` pops the global PermissionModal and its `question_request` renders in the parent's QuestionPicker (payloads carry the child `session_id`, so answers route back automatically); both show a「来自后台任务:」origin label. +**蜂群模式(网状)(openspec add-mesh-swarm-mode,复刻 Codex Multi-Agent V2,全文见 [docs/subagents.md](docs/subagents.md) §9)。** 蜂群模式是会话级三态 `off | star | mesh`(`SessionMeta.swarm_mode`,入口:消息体 `swarm_mode`、`/swarm`、headless `--swarm`),AgentLoop 每回合从 SessionManager 读,切换只影响下一回合。两套协作工具互斥:`swarm_mode_hidden_tools` → `ToolCapabilityPolicy::hidden_builtin_tools`(schema 与执行同一谓词),mesh 隐藏 `spawn_subagent` / `wait_subagent` / thread 工具,off / star 隐藏六个 `agent_*`。网状子 agent 的 `parent_session_id` 恒为**根** id(扁平,面板 / 权限冒泡 / purge 全复用星型通道),真实层级是 `SessionMeta.agent_path`(`/root/a/b`),树目录落在根会话目录的 `mesh_agents.json`。服务 `host/session_host/mesh/MeshAgentService` 在 worker / TUI(`SubagentHost`,TUI 主会话是外部根)/ headless 各一份,工具只捕获 `weak_ptr`。几条别改回去的语义:(1) 邮件一律进 AgentLoop 邮箱(`engine/agent/mailbox`),每次模型请求前并入;**子 agent 完成回报不唤醒空闲的父 agent**(Codex 原样),只有 `agent_followup_task` / NEW_TASK 唤醒;interrupted 回合不回报。(2) 信封落盘为 **user 角色** + `metadata.inter_agent`(有意偏离 Codex 的 assistant 角色:连续 assistant 在 Anthropic / DeepSeek 上会被拒),NEW_TASK 算真实用户消息、其余是内部上下文,TUI / Web 都转成系统提示行,别让它变成用户气泡(会开出假回合、触发「重发末尾用户消息」)。(3) 驻留上限 `swarm.mesh.max_concurrent_agents` 默认 4 **含根**,满了按 LRU 换出已结束的子 agent(`registry.destroy`,发消息时 `resume` 恢复),都在跑就报 `agent thread limit reached`。(4) Web 输入框芯片 = 服务端模式 + 未提交的本地选择,**只有二者不同才随消息提交 `swarm_mode`**(`web/src/lib/swarmMode.js`):每条消息都带会把 `/swarm` 刚切的模式改回去,网状树里有 agent 在跑时还会直接 409。回归:`tests/session_host/mesh_agent_service_test.cpp`(真实 SessionRegistry + 脚本化 provider 跑整棵树)、`tests/agent/agent_loop_mesh_mailbox_test.cpp`、`swarmMode.test.js` / `interAgentMessage.test.js`。 + `bash_tool` streams cleaned output, polls abort state, truncates very large output, and supports POSIX `stdin_inputs`. File tools should preserve checkpoint hooks by calling `track_file_write_before` before mutating files. **Prompt cache prefix invariant**:`RequestContextFactory::build()` 每次采样迭代都跑一遍,所以**注入到最后一条真实 user 消息之前的内容必须内容驱动** —— 输入不变就逐字节不变。任何随时间/随请求变化的东西进了那个位置,同一回合内每次工具往返都会把缓存前缀从注入点截断,后面整条尾巴(user 消息 + 全部 assistant/tool 消息)全价重算;工具越多损失越大。曾经的 `[当前环境状态]` 块带秒级时间戳,正好踩中这条。现在 cwd 与日期(只到天,`current_prompt_date`)留在静态 system prompt 的 `# Environment` 里,只在工作目录或日期变化时改变。守护测试:`agent_loop_termination_test.cpp::RequestPrefixIsByteStableAcrossIterationsInATurn`(逐条比对相邻两次请求的公共前缀)+ `system_prompt_test.cpp` 的两个 byte-stable 用例。同理,`cached_context_for_api` 按 cache_key pin 住 session context 内容、git 快照按会话缓存、`ToolExecutor::tools_` 用 `std::map` 保证工具顺序、Anthropic 合成 tool_call_id 走内容哈希 —— 都是为同一条不变量服务的,改动这些地方前先想清楚会不会打穿前缀。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index cda1c21a1..16e0da608 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -135,6 +135,7 @@ Session list endpoints return arrays of objects shaped like: "title": "Investigate daemon routes", "title_source": "user", "summary": "latest user summary", + "swarm_mode": "off", "created_at": "2026-07-04T01:23:45Z", "updated_at": "2026-07-04T01:25:00Z", "provider": "openai", @@ -1527,6 +1528,17 @@ with `type:"selection"` are sanitized and expanded into model-visible context while preserving the user's original display text. Other context objects are passed as browser context content parts. +`swarm_mode` (optional) switches the session-level swarm mode before the input +is queued: `"star"`, `"mesh"` or `"off"`; legacy clients may send `true` (= +`"star"`) or `false` (= `"off"`). Any other value returns `400` with +`swarm_mode must be "star", "mesh" or false`. The mode is sticky: omitting the +field keeps the session's current mode, and the accepted value is also recorded +as `metadata.swarm_mode` on that user message. Leaving `"mesh"` while agents of +the session's mesh tree are still running (or hold undelivered follow-up tasks) +returns `409` with the reason; so does posting directly to a mesh sub-agent +(`direct input is not allowed for mesh swarm sub-agents`). A successful switch +emits `session_updated {"swarm_mode"}`. See `docs/subagents.md` §9. + `client_message_id` is an optional non-empty string (maximum 256 bytes) used by Desktop queued-input handoff. When accepted, it is preserved as `metadata.client_message_id` on the canonical user message so an optimistic @@ -4775,7 +4787,10 @@ sends `{"title", "title_source"}`. After each visible user message is persisted the daemon sends `{"summary"}`: the latest user message's display text (`metadata.display_text` when present, otherwise `content`) collapsed to one line and truncated to 80 UTF-8 bytes plus `...`. The same three fields appear in -session listings and in the `GET .../messages?since=0` snapshot. Clients show +session listings and in the `GET .../messages?since=0` snapshot. A swarm-mode +switch sends `{"swarm_mode"}` (`"off" | "star" | "mesh"`); session listings and +the messages snapshot carry the current `swarm_mode` too, plus `agent_path` +(`/root/...`) for mesh sub-agents. Clients show `title` when it is non-empty (ignoring generated titles that start with `[Error]`) and `summary` otherwise, in both the session list and the chat header; they must not derive a title from message bodies. diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index 7af0e64d2..c8fd63b86 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -14,16 +14,21 @@ "swarm": page("把可以独立推进的工作交给子代理,同时保留主任务负责整合、验证和最终交付。", [ section("parallel", "任务分工与并行执行", - '''<p>在输入框左侧<strong>添加能力或上下文</strong>菜单选择<strong>蜂群模式</strong>,输入区出现对应标签。它会让下一条普通消息更积极地派遣子 Agent;开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。</p><p>适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。</p>''', + '''<p>在输入框左侧<strong>添加能力或上下文</strong>菜单里有<strong>蜂群模式(星型)</strong>与<strong>蜂群模式(网状)</strong>两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。</p>''', + table(["模式", "协作方式", "适合的任务"], [ + ["蜂群模式(星型)", "主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。", "几项互不相关、各自汇报即可的调查或检查。"], + ["蜂群模式(网状)", "任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。", "需要分层拆解、中途协调或多轮往返的大任务。"], + ]), + '''<p>蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 <code>/swarm star</code>、<code>/swarm mesh</code>、<code>/swarm off</code> 切换,<code>/swarm</code> 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。</p><p>适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。</p>''', code("并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。", "可独立并行的任务示例"), - '''<p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p>''', + '''<p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p><p>网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 <code>swarm.mesh.max_concurrent_agents</code> 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。</p>''', figure("AD-03", "开启蜂群模式并提交分工", "展示添加能力菜单中的蜂群模式、开启后的标签,以及说明任务边界的输入内容。")), section("progress", "查看进度与处理操作确认", '''<p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。</p>''', code("/tasks\n/tasks list\n/tasks abort 子任务ID\n/tasks clear", "TUI · 管理后台任务"), figure("AD-04", "后台任务与来自子任务的请求", "展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。"), '''<p>全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。</p>''') -], ["web/src/components/InputBar.jsx", "web/src/components/ComposerSessionControls.jsx", "web/src/components/SubagentPanel.jsx", "src/apps/tui/commands/builtin_commands.cpp", "docs/subagents.md"]), +], ["web/src/components/InputBar.jsx", "web/src/components/ComposerSessionControls.jsx", "web/src/components/SubagentPanel.jsx", "src/apps/tui/commands/builtin_commands.cpp", "src/apps/tui/commands/swarm_mode_command.cpp", "src/host/session_host/mesh/mesh_agent_service.cpp", "docs/subagents.md"]), "schedules": page("把清晰且可重复的任务保存为循环,按周期、间隔或指定时间执行,并通过记录检查每次结果。", [ section("create", "创建与设置执行时间", diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json index adc1dd08b..dce1e0ee6 100644 --- a/docs/help-source/sources.json +++ b/docs/help-source/sources.json @@ -321,6 +321,8 @@ "web/src/components/ComposerSessionControls.jsx", "web/src/components/SubagentPanel.jsx", "src/apps/tui/commands/builtin_commands.cpp", + "src/apps/tui/commands/swarm_mode_command.cpp", + "src/host/session_host/mesh/mesh_agent_service.cpp", "docs/subagents.md" ] }, diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index 13f7562a1..70a3bd94f 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -990,15 +990,15 @@ window.ACECODE_HELP_INDEX = [ "title": "蜂群模式与子代理", "url": "swarm.html", "description": "把可以独立推进的工作交给子代理,同时保留主任务负责整合、验证和最终交付。", - "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单选择 蜂群模式 ,输入区出现对应标签。它会让下一条普通消息更积极地派遣子 Agent;开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图 查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" + "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单里有 蜂群模式(星型) 与 蜂群模式(网状) 两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。 模式 协作方式 适合的任务 蜂群模式(星型) 主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。 几项互不相关、各自汇报即可的调查或检查。 蜂群模式(网状) 任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。 需要分层拆解、中途协调或多轮往返的大任务。 蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 /swarm star 、 /swarm mesh 、 /swarm off 切换, /swarm 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 swarm.mesh.max_concurrent_agents 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图 查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { "page": "蜂群模式与子代理", "group": "进阶使用", "title": "任务分工与并行执行", "url": "swarm.html#parallel", - "description": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单选择 蜂群模式 ,输入区出现对应标签。它会让下一条普通消息更积极地派遣子 Agent;开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独", - "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单选择 蜂群模式 ,输入区出现对应标签。它会让下一条普通消息更积极地派遣子 Agent;开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图" + "description": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单里有 蜂群模式(星型) 与 蜂群模式(网状) 两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。 模式 协作方式 适合的任务 蜂群模式(星型) 主任务派出子 Agent,子 A", + "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单里有 蜂群模式(星型) 与 蜂群模式(网状) 两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。 模式 协作方式 适合的任务 蜂群模式(星型) 主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。 几项互不相关、各自汇报即可的调查或检查。 蜂群模式(网状) 任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。 需要分层拆解、中途协调或多轮往返的大任务。 蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 /swarm star 、 /swarm mesh 、 /swarm off 切换, /swarm 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 swarm.mesh.max_concurrent_agents 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图" }, { "page": "蜂群模式与子代理", diff --git a/docs/help/swarm.html b/docs/help/swarm.html index 0b3a071d0..70c2f042c 100644 --- a/docs/help/swarm.html +++ b/docs/help/swarm.html @@ -37,9 +37,11 @@ <header class="article-header"><h1>蜂群模式与子代理</h1><p class="lead">把可以独立推进的工作交给子代理,同时保留主任务负责整合、验证和最终交付。</p></header> <details class="mobile-contents"><summary>本页内容<svg class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m8 5 7 7-7 7"/></svg></summary><ul><li><a href="#parallel" data-toc-link>任务分工与并行执行</a></li><li><a href="#progress" data-toc-link>查看进度与处理操作确认</a></li></ul></details> -<section aria-labelledby="parallel"><h2 id="parallel">任务分工与并行执行</h2><p>在输入框左侧<strong>添加能力或上下文</strong>菜单选择<strong>蜂群模式</strong>,输入区出现对应标签。它会让下一条普通消息更积极地派遣子 Agent;开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。</p><p>适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。</p> +<section aria-labelledby="parallel"><h2 id="parallel">任务分工与并行执行</h2><p>在输入框左侧<strong>添加能力或上下文</strong>菜单里有<strong>蜂群模式(星型)</strong>与<strong>蜂群模式(网状)</strong>两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。</p> +<div class="table-scroll"><table><thead><tr><th scope="col">模式</th><th scope="col">协作方式</th><th scope="col">适合的任务</th></tr></thead><tbody><tr><td>蜂群模式(星型)</td><td>主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。</td><td>几项互不相关、各自汇报即可的调查或检查。</td></tr><tr><td>蜂群模式(网状)</td><td>任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。</td><td>需要分层拆解、中途协调或多轮往返的大任务。</td></tr></tbody></table></div> +<p>蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 <code>/swarm star</code>、<code>/swarm mesh</code>、<code>/swarm off</code> 切换,<code>/swarm</code> 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。</p><p>适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。</p> <div class="code-block"><div class="code-heading"><span>可独立并行的任务示例</span><button class="copy-button js-only" type="button" aria-label="复制代码"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M8 8h12v13H8zM16 8V3H3v13h5"/></svg><span>复制</span></button></div><pre><code>并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。</code></pre></div> -<p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p> +<p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p><p>网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 <code>swarm.mesh.max_concurrent_agents</code> 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。</p> <figure class="screenshot" data-figure="AD-03" data-image-status="captured"><a class="screenshot-link" href="assets/screenshots/composer-capability-menu.jpg" aria-label="查看原图:添加能力菜单中的蜂群模式、专家组件与文件或文件夹入口"><img src="assets/screenshots/composer-capability-menu.jpg" width="1282" height="727" alt="添加能力菜单中的蜂群模式、专家组件与文件或文件夹入口" loading="lazy" decoding="async"></a> <a class="screenshot-link" href="assets/screenshots/composer-swarm-draft.jpg" aria-label="查看原图:蜂群模式标签与说明分工、文件范围和只读边界的草稿,截图时尚未发送"><img src="assets/screenshots/composer-swarm-draft.jpg" width="1282" height="727" alt="蜂群模式标签与说明分工、文件范围和只读边界的草稿,截图时尚未发送" loading="lazy" decoding="async"></a><figcaption>从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。<span class="screenshot-hint">Windows 实际界面 · 点击图片查看原图</span></figcaption></figure></section> <section aria-labelledby="progress"><h2 id="progress">查看进度与处理操作确认</h2><p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。</p> diff --git a/docs/subagents.md b/docs/subagents.md index 6ea970902..5ccb946fc 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -1,6 +1,8 @@ # 子代理(Subagent)体系 实现参考文档 — 覆盖 `spawn_subagent` / `wait_subagent` 工具、Web「后台任务」面板与 TUI 支持。 +蜂群模式分两种:**星型**(本文 1–8 节,`spawn_subagent`)与**网状**(第 9 节,`agent_*`,复刻 Codex +Multi-Agent V2,openspec `add-mesh-swarm-mode`)。 对应提交:`dd10b15`(工具内核,daemon)→ `2c20b5b`(Web 后台任务面板 + parent 持久化)→ `35b986f`(TUI 支持)。 ## 1. 设计概览 @@ -236,3 +238,92 @@ TuiState overlay,工具线程 wait ask_cv 天然带回结果)。只需两点:入 「完成后调用 `spawn_subagent(prompt="/next-stage-skill <args>", wait=false)` 点火下一阶段」。 用户只需在第一个会话输入 `/stage-1 <需求>`,阶段间上下文零互通、信息只走交接文档, 每阶段在后台任务面板 / `/tasks` 独立可查。 + +## 9. 蜂群模式(网状):agent_* 协作树(add-mesh-swarm-mode) + +复刻 Codex Multi-Agent V2(`codex-rs/core/src/tools/handlers/multi_agents_v2/`、`agent/control.rs`、 +`session/input_queue.rs`),语义逐条对齐,只有两处有意偏离:**工具名加 `agent_` 前缀**、 +**agent 间信封用 user 角色**(Codex 用 assistant;连续两条 assistant 在 Anthropic / DeepSeek 一类 +provider 上会被拒或合并)。 + +### 模式 + +- 会话级三态 `off | star | mesh`,存 `SessionMeta.swarm_mode`(off 省略)。入口:消息体 + `swarm_mode`(`"star"|"mesh"|"off"`,旧布尔 `true` = star)、`/swarm [star|mesh|off]`(TUI 命令 + + daemon 内置命令共用 `session_host/swarm_command`)、headless `--swarm`。AgentLoop 每回合从 + SessionManager 读模式,切换只影响下一回合。 +- 两套工具互斥(`swarm_mode_hidden_tools` → `ToolCapabilityPolicy::hidden_builtin_tools`,schema 与执行 + 同一谓词):off / star 隐藏 6 个 `agent_*`;mesh 隐藏 `spawn_subagent` / `wait_subagent` 与全部 + thread 工具,调用被拒时文案指向替代工具。 +- 网状树里还有运行中 / 待投递后续任务的 agent 时拒绝退出网状(`SessionRegistry::set_swarm_mode` + 守卫,Web 409);网状子 agent 自己不能改模式;Web 不接受直接发给网状子 agent 的输入(409,对应 + Codex「direct input is not allowed for multi-agent v2 sub-agents」)。 + +### 身份与持久化 + +- 子 agent 的 `parent_session_id` 恒为**根**会话 id(扁平挂在根下,后台任务面板 / 权限冒泡 / purge + 全部复用星型通道);真实层级是 `SessionMeta.agent_path`(`/root/a/b`,`session/agent_path` 移植 + Codex `AgentPath`,错误文案逐字一致)。根会话路径为空 = `/root`。 +- 树目录 `path → session id` 落在根会话数据目录 `<project_dir>/<root_id>/mesh_agents.json` + (`session/mesh_tree_index`),daemon 重启后按它重建,不扫全部 meta。 + +### 服务与投递 + +- `host/session_host/mesh/MeshAgentService`:每个根一棵 `Tree`(path → Record:session id、驻留、 + started / last_outcome / final_text / error_text、未加载时暂存的邮件 `held_mail`)。worker / TUI + (`SubagentHost`,TUI 主会话是外部根)/ headless 各持一份,工具只捕获 `weak_ptr`。 +- 邮箱在 AgentLoop(`engine/agent/mailbox/AgentMailbox`,对应 Codex `mailbox_pending_mails`): + `deliver_inter_agent_message(envelope, trigger_turn)` 一律入箱;每次模型请求前(含回合第一次)由 + `TurnRunner::drain_inputs` 先 steering 后邮箱整体并入;回合以最终回答收尾时只排队的邮件留到下一 + 回合;`trigger_turn` 邮件在空闲时由 `mailbox_wake` 任务唤醒(第一封作回合输入,其余随之并入)。 +- 信封正文是 Codex InterAgentMessage 文本外包 `<inter_agent_message>`,metadata `inter_agent` + `{type, sender, recipient, sender_session_id?, status?}`。NEW_TASK 计为真实用户消息(上下文锚点), + MESSAGE / FINAL_ANSWER 是内部上下文;都不计可见回合、不进摘要、不触发 UserPromptSubmit。TUI / Web + 都把它显示成系统提示行(`inter_agent_display_text` / `web/src/lib/interAgentMessage.js`)。 +- 完成回报(Codex `notify_parent_of_terminal_turn`):子 agent 回合 `Done` → 父 agent 邮箱收到 + FINAL_ANSWER(`trigger_turn=false`,**不唤醒**空闲的父);completed = 该回合最终回答,errored = + `Agent errored: …` + 下一步提示,**interrupted 不回报**。父未加载时暂存,恢复后投递;根不在时丢弃并记日志。 + +### 六个工具(`tools/mesh_agent_tools.cpp`,串行执行,输出对齐 Codex) + +| 工具 | Codex | 要点 | +|---|---|---| +| `agent_spawn` | `spawn_agent` | `task_name` + `message`(必填),`fork_turns`(`none`/`all`/正整数,默认 all,只继承用户消息与最终回答),`agent_type`(团队专家成员),`model` / `reasoning_effort`(`swarm.mesh.expose_model_overrides`,默认继承父);输出 `{"task_name": 路径}`;`fork_context` 报错;未知字段报错 | +| `agent_list` | `list_agents` | 只列已加载的 agent(根在前),状态 `pending_init` / `running` / `interrupted` / `{"completed":…}` / `{"errored":…}`,`path_prefix` 按段过滤 | +| `agent_send_message` | `send_message` | 入箱不唤醒,可发给根;成功输出空串 | +| `agent_followup_task` | `followup_task` | 唤醒空闲目标,运行中在消息边界投递;不能发给根 | +| `agent_wait` | `wait_agent` | 等本 agent 的邮箱活动或插话;默认 30s,低于 10s 夹取并注明,超过 1h 报错;输出 `{"message","timed_out"}` | +| `agent_interrupt` | `interrupt_agent` | 返回中断前状态;不能打断根或自己;未加载目标视为 not_found | + +### 驻留(Codex V2Residency) + +- `swarm.mesh.max_concurrent_agents` 默认 4 **含根**,即每棵树最多驻留 3 个子 agent。满了换出 + 最久没有活动、已有结果、无待办与空邮箱的子 agent(`registry.destroy`,记录仍在磁盘);一个都换不出 + 时报 `collab spawn failed: agent thread limit reached`(投递恢复时是 `collab tool failed: …`)。 +- 给未加载的 agent 发消息 / 后续任务会先 `registry.resume` 恢复(可能换出别的),再投递并冲刷暂存邮件。 +- `on_agent_loaded` 回调让 Web(`track_subagent`)与 TUI(`SubagentHost::on_spawned`)对新建 / 恢复的 + 子会话重新订阅事件。 + +### 前端与 TUI + +- Web 输入框:「蜂群模式(星型)」「蜂群模式(网状)」两个互斥菜单项;芯片 = 服务端模式(messages + 快照 / `session_updated{swarm_mode}`)+ 未提交的本地选择,只有不同才随消息提交(`web/src/lib/swarmMode.js`)。 +- 后台任务面板复用星型面板,卡片显示 `agent_path`;`agent_spawn` 同样自动打开面板。 +- TUI:`/swarm`;侧栏运行中任务以路径为显示名。 + +### 有意未复刻 / 与 Codex 的差异 + +- Codex 首次请求不 drain 邮箱(邮件要等下一次采样),这里回合第一次请求就并入。 +- Codex 父 agent 未加载时丢弃完成回报;这里非根父 agent 暂存到恢复时投递。 +- 未做:采样中途因新邮件抢占流式输出、环境上下文里的 `<subagents>` 名册、`agent_interrupt` + 后的 `<turn_aborted>` 标记、消息板 / 模型目录(Codex 也默认关闭)、共享预算与邮箱上限(Codex 无)。 + +### 测试地图 + +| 文件 | 覆盖 | +|---|---| +| `tests/session/agent_path_test.cpp`、`mesh_swarm_domain_test.cpp`、`inter_agent_message_test.cpp` | 路径 / 模式名 / 工具互斥 / 信封 / fork_turns / 树索引 | +| `tests/agent/agent_mailbox_test.cpp`、`agent_loop_mesh_mailbox_test.cpp` | 邮箱等待;运行中并入、空闲触发唤醒、只排队、agent_wait 结局 | +| `tests/session_host/mesh_agent_service_test.cpp` | spawn → 回报、send / followup、参数校验、LRU 换出与恢复、上限、interrupt、errored、嵌套、退出守卫、重启重建、工具输出契约 | +| `tests/session_host/swarm_command_test.cpp`、`tests/config/config_swarm_test.cpp`、`tests/headless/headless_options_test.cpp` | `/swarm` 文本、配置夹取、`--swarm` | +| `web/src/lib/swarmMode.test.js`、`interAgentMessage.test.js`、`subagentTasks.test.js`、`chatInputQueue.test.js` | 芯片状态、信封显示、面板路径、排队模式 | diff --git a/openspec/changes/add-mesh-swarm-mode/design.md b/openspec/changes/add-mesh-swarm-mode/design.md index d25c7d86c..f4a8c698f 100644 --- a/openspec/changes/add-mesh-swarm-mode/design.md +++ b/openspec/changes/add-mesh-swarm-mode/design.md @@ -143,6 +143,19 @@ Payload: 回滚:不选择网状模式即回到今天的行为;`swarm_mode` 字段向后兼容;新增元数据字段旧版本忽略。`add-agent-tree-collaboration` 目录只存在于主工作树且未纳入 git,应在主工作树里移入 `openspec/changes/archive/` 并注明由本变更取代。 +## 实现记录(与上文设计的差异,以代码为准) + +实现时用户拍板「全按 Codex V2」,上文几处设计据此改动: + +- **身份字段只加两个**:`SessionMeta.swarm_mode` 与 `agent_path`。根会话 id 直接用 `parent_session_id`(所有网状子 agent 扁平挂在根下),深度与任务名从路径推出;不合成 legacy 路径。 +- **`MeshTreeControl` → `host/session_host/mesh/MeshAgentService`**:worker / TUI / headless 各一份,工具与事件监听只捕获 `weak_ptr`;树目录持久化在根会话目录的 `mesh_agents.json`,重启后据此重建,不扫全部 meta。 +- **模式切换不经 `enqueue_control`**:AgentLoop 在每回合捕获请求源时从 SessionManager 读模式与路径(会话元数据是唯一事实源),切换天然只影响下一回合。 +- **邮箱投递**:信封一律先入箱,不直接 `submit()`;空闲时 `trigger_turn` 经 `mailbox_wake` 任务唤醒,唤醒前再查一次是否仍有触发邮件(已被运行中回合并入则不多跑一回合)。回合第一次模型请求前也会并入邮箱(Codex 要等下一次采样,属有意偏离)。 +- **状态与列表按 Codex**:`agent_list` 只列已加载的 agent,状态取 Codex 枚举(`pending_init / running / interrupted / {completed} / {errored} / not_found`),没有 `idle` / `inactive`;被换出的 agent 只在内部目录与 UI 快照里可见。 +- **完成回报按 Codex**:completed = 该回合最终回答,errored = `Agent errored: …` + 下一步提示,**interrupted 不回报**;父 agent 未加载时暂存到恢复(根不在则丢弃并记日志)。 +- **未做**:未读邮件计数(Codex 无)、面板按深度缩进、来源标签改用完整路径、采样中途因新邮件抢占输出、`<subagents>` 名册注入、`agent_interrupt` 后的 `<turn_aborted>` 标记。 +- **Web 芯片**:芯片 = 服务端模式(messages 快照 / `session_updated{swarm_mode}`)+ 未提交的本地选择,只有二者不同才随消息提交 `swarm_mode`,避免普通消息把 `/swarm` 刚切的模式改回去;信封在 transcript 摄入时转成系统提示行。 + ## Open Questions - 移植的角色提示词具体措辞(在实现时对照 Codex 源文本逐段改写,不影响规格与任务拆分)。 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md index 47e305cb2..57f665d78 100644 --- a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-collaboration/spec.md @@ -5,7 +5,7 @@ ## ADDED Requirements ### Requirement: agent 拥有 canonical 路径并支持相对寻址 -根会话的路径 SHALL 为 `/root`。`agent_spawn` 创建的子 agent 路径 SHALL 为调用者路径加 `/` 加 `task_name`,可无限嵌套。`task_name` MUST 匹配 `[a-z0-9_]+`,MUST NOT 为 `root`、`.` 或 `..`;同一父 agent 下重复的 `task_name` SHALL 返回错误,MUST NOT 静默改名。所有接受目标的工具参数 SHALL 接受 canonical 路径、相对调用者路径的相对名或底层会话 id;相对名 SHALL 相对调用者自身路径解析。只有 `parent_session_id` 的旧子会话 SHALL 呈现为 `/root/legacy_<会话 id 前 8 位>`。 +根会话的路径 SHALL 为 `/root`。`agent_spawn` 创建的子 agent 路径 SHALL 为调用者路径加 `/` 加 `task_name`,可无限嵌套。`task_name` MUST 匹配 `[a-z0-9_]+`,MUST NOT 为 `root`、`.` 或 `..`;同一父 agent 下重复的 `task_name` SHALL 返回错误,MUST NOT 静默改名。所有接受目标的工具参数 SHALL 接受 canonical 路径、相对调用者路径的相对名或底层会话 id;相对名 SHALL 相对调用者自身路径解析。星型模式的子会话(只有 `parent_session_id`、没有路径)MUST NOT 被并入任何网状树。 #### Scenario: 嵌套路径 - **WHEN** `/root/task1` 以 `task_name="task_3"` 调用 `agent_spawn` @@ -88,9 +88,9 @@ - **AND** 用户下一次向 `/root` 发送消息时,该信封出现在 `/root` 的上下文中 #### Scenario: 发给已被换出的 agent -- **WHEN** 目标 agent 处于 `inactive`(已换出)状态 +- **WHEN** 目标 agent 已被换出(未加载) - **THEN** 系统先恢复该 agent,再把信封写入其邮箱 -- **AND** 回执说明目标已被恢复且消息待投递 +- **AND** 回执与普通投递相同(空输出) ### Requirement: agent_followup_task 恰好一次地触发目标 `agent_followup_task(target, message)` SHALL 把 `NEW_TASK` 信封写入目标邮箱并保证目标处理它:目标空闲、已完成或已被换出时 SHALL(必要时恢复后)启动一个新回合;目标运行中时 SHALL 在当前回合的下一处消息边界投递,MUST NOT 并发启动第二个回合。目标从运行到空闲的竞态窗口内,同一条后续任务 MUST 恰好被投递一次,不得丢失或重复。目标 MUST NOT 为 root,否则 SHALL 返回错误 "Follow-up tasks can't target the root agent"。 @@ -116,7 +116,7 @@ - **AND** `/root` 的邮箱与状态不变 ### Requirement: 完成结果投递给父 agent 且不唤醒空闲父 -子 agent 回合结束时,系统 SHALL 向其父 agent 邮箱投递一条 `FINAL_ANSWER` 信封:正常结束时 Payload 为最终 assistant 回复;因错误或中断结束时 Payload 为状态说明与可用的部分输出。该投递 MUST NOT 唤醒空闲的父;父正在 `agent_wait` 中 SHALL 被唤醒;父正在运行中 SHALL 在下一处消息边界收到。 +子 agent 回合结束时,系统 SHALL 向其父 agent 邮箱投递一条 `FINAL_ANSWER` 信封(与 Codex 一致):正常结束时 Payload 为该回合的最终 assistant 回复(没有则为空);因错误结束时 Payload 为 `Agent errored: <错误>` 加上「可用协作工具给它派新任务」的下一步提示;被中断的回合 MUST NOT 投递。该投递 MUST NOT 唤醒空闲的父;父正在 `agent_wait` 中 SHALL 被唤醒;父正在运行中 SHALL 在下一处消息边界收到;父未加载时 SHALL 暂存到它被恢复后再投递。 #### Scenario: 父正在等待 - **WHEN** `/root` 阻塞在 `agent_wait`,`/root/a` 完成回合 @@ -134,7 +134,7 @@ - **AND** `/root/a` 仍可被寻址与派后续任务 ### Requirement: agent_wait 等待自身邮箱 -`agent_wait(timeout_ms?)` SHALL 阻塞直到调用者自身邮箱出现新活动、用户向调用者提交新输入或超时,并只返回 "Wait completed."、"Wait interrupted by new input." 或 "Wait timed out." 之一;MUST NOT 返回消息正文(正文以信封形式出现在调用者的下一次模型请求中)。`timeout_ms` SHALL 被钳制到最短 10 秒、最长 1 小时,缺省 30 秒;三者 SHALL 可通过配置调整。调用者被用户中止时等待 SHALL 立即结束,且 MUST NOT 影响任何其它 agent。 +`agent_wait(timeout_ms?)` SHALL 阻塞直到调用者自身邮箱出现新活动、用户向调用者提交新输入或超时,并只返回 "Wait completed."、"Wait interrupted by new input." 或 "Wait timed out." 之一;MUST NOT 返回消息正文(正文以信封形式出现在调用者的下一次模型请求中)。`timeout_ms` 低于最短 10 秒时 SHALL 按最短值等待并在结果中注明被夹取,超过最长 1 小时 SHALL 返回错误 "timeout_ms must be at most <最大值>",缺省 30 秒;三者 SHALL 可通过配置调整。调用者被用户中止时等待 SHALL 立即结束,且 MUST NOT 影响任何其它 agent。 #### Scenario: 邮箱活动唤醒 - **WHEN** `/root` 调用 `agent_wait(timeout_ms=600000)`,30 秒后 `/root/a` 发来消息 @@ -148,7 +148,7 @@ #### Scenario: 超时钳制 - **WHEN** `agent_wait(timeout_ms=1000)` 被调用 - **THEN** 实际等待下限为 10 秒 -- **AND** 超时后返回 "Wait timed out." +- **AND** 超时后返回 "Wait timed out." 并注明请求值被夹取到最小值 #### Scenario: 调用者被中止 - **WHEN** 用户停止正在 `agent_wait` 的 `/root` diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md index 045ffd913..37c8f13c5 100644 --- a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-observability/spec.md @@ -4,33 +4,25 @@ ## ADDED Requirements -### Requirement: 后台任务面板按树展示网状 agent -root 会话的后台任务面板 SHALL 按 canonical 路径深度缩进展示全部子 agent,按状态分组(运行中、空闲、被换出、出错/中断),每项 SHALL 显示路径或任务名、状态、最后一条消息摘要、token 用量与耗时。被换出的 agent SHALL 可打开只读对话记录,且打开 MUST NOT 触发恢复。 +### Requirement: 后台任务面板展示网状 agent +root 会话的后台任务面板 SHALL 列出该树的全部子 agent(任意深度都扁平挂在 root 下),每张卡片 SHALL 显示其 canonical 路径、任务名、运行状态、token 用量与耗时;`agent_spawn` 开始时 SHALL 自动打开面板。被换出的 agent SHALL 可打开只读对话记录,且打开 MUST NOT 触发恢复。 -#### Scenario: 二级子 agent 的缩进 +#### Scenario: 二级子 agent 的路径 - **WHEN** 面板展示 `/root/explore` 与 `/root/explore/tests` -- **THEN** `/root/explore/tests` 显示在 `/root/explore` 之下并多一级缩进 -- **AND** 两者各自显示状态与最后一条消息摘要 +- **THEN** 两张卡片分别显示各自的完整路径 +- **AND** 两者各自显示运行状态与耗时 #### Scenario: 查看被换出的 agent -- **WHEN** 用户点开状态为 `inactive` 的子 agent -- **THEN** 面板显示其对话记录 -- **AND** 该 agent 的状态保持 `inactive` - -### Requirement: root 空闲时显示未读的 agent 消息数 -root 空闲且邮箱中存在尚未进入上下文的 `FINAL_ANSWER` 或 `MESSAGE` 信封时,后台任务入口与面板 SHALL 显示未读数;root 下一回合消费邮箱后 SHALL 清零。 - -#### Scenario: 子 agent 在 root 空闲后完成 -- **WHEN** `/root` 已空闲,随后 `/root/a` 与 `/root/b` 相继完成 -- **THEN** 后台任务入口显示未读数 2 -- **AND** 用户向 `/root` 发送下一条消息后未读数归零 +- **WHEN** 用户点开已被换出的子 agent +- **THEN** 面板从磁盘显示其对话记录 +- **AND** 该 agent 不因此被恢复 ### Requirement: 跨 agent 消息在 transcript 中以独立行呈现 -收件方的 Web/Desktop 与 TUI transcript(含恢复回放)SHALL 把跨 agent 信封渲染为独立的消息行,显示消息类型与发件人路径,MUST NOT 渲染为用户气泡。会话摘要(侧栏与列表使用的最后一条用户消息截断)MUST NOT 取自跨 agent 信封。 +收件方的 Web/Desktop 与 TUI transcript(含恢复回放)SHALL 把跨 agent 信封渲染为独立的系统提示行,标题显示发件人路径与类型(派来新任务 / 发来消息 / 已完成 / 执行出错),展开为正文,MUST NOT 渲染为用户气泡。会话摘要(侧栏与列表使用的最后一条用户消息截断)MUST NOT 取自跨 agent 信封。 #### Scenario: 收件方看到来源 - **WHEN** `/root/a` 收到来自 `/root` 的 `MESSAGE` 信封 -- **THEN** `/root/a` 的 transcript 出现一条标注「来自 /root · 消息」的独立行 +- **THEN** `/root/a` 的 transcript 出现一条标题为「/root 发来消息」的系统提示行 - **AND** 该行不使用用户消息的气泡样式 #### Scenario: 恢复后的呈现一致 @@ -39,16 +31,16 @@ root 空闲且邮箱中存在尚未进入上下文的 `FINAL_ANSWER` 或 `MESSAG - **AND** 该会话的摘要仍为其最后一条真实用户消息 ### Requirement: 任意层级的权限请求与提问冒泡到 root 界面 -任意深度子 agent 的权限请求与 AskUserQuestion SHALL 出现在 root 会话所在的界面(Web/Desktop 全局弹窗、TUI 确认/提问浮层),来源标签 SHALL 为该 agent 的 canonical 路径;用户的回答 SHALL 路由回发起请求的 agent。 +任意深度子 agent 的权限请求与 AskUserQuestion SHALL 出现在 root 会话所在的界面(Web/Desktop 全局弹窗、TUI 确认/提问浮层),来源标签 SHALL 为该 agent 的任务名(会话标题);用户的回答 SHALL 路由回发起请求的 agent。 #### Scenario: 二级子 agent 请求权限 - **WHEN** `/root/explore/tests` 请求执行写文件工具的权限 -- **THEN** root 界面弹出权限确认并标注来源 `/root/explore/tests` +- **THEN** root 界面弹出权限确认并标注来源 `tests` - **AND** 用户允许后该工具在 `/root/explore/tests` 中执行 #### Scenario: 子 agent 向用户提问 - **WHEN** `/root/a` 调用 AskUserQuestion -- **THEN** 提问出现在 root 界面并标注来源 `/root/a` +- **THEN** 提问出现在 root 界面并标注来源 `a` - **AND** 用户的回答返回给 `/root/a` ### Requirement: 网状子 agent 的标题与隐藏规则 @@ -73,9 +65,9 @@ root 空闲且邮箱中存在尚未进入上下文的 `FINAL_ANSWER` 或 `MESSAG - **AND** root 回合完成时按现有规则通知 ### Requirement: TUI /tasks 展示网状 agent -TUI 的 `/tasks` 与 `/tasks list` SHALL 为网状 agent 显示 canonical 路径与统一状态;`/tasks abort <id>` 对网状 agent SHALL 等价于中止其当前回合并保留该 agent。 +TUI 的 `/tasks` 与 `/tasks list` SHALL 列出网状 agent 的任务名与运行状态,右侧栏运行中任务以 canonical 路径为显示名;`/tasks abort <id>` 对网状 agent SHALL 等价于中止其当前回合并保留该 agent。 #### Scenario: 列出网状 agent - **WHEN** 用户在 `mesh` 模式的 TUI 会话中输入 `/tasks` -- **THEN** 每个子 agent 一行,显示路径与状态 -- **AND** 被换出的 agent 标注为 `inactive` +- **THEN** 每个子 agent 一行,显示任务名与运行状态 +- **AND** 被换出的 agent 显示为已结束 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md index 1c2310ade..00d0f3b06 100644 --- a/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md +++ b/openspec/changes/add-mesh-swarm-mode/specs/mesh-agent-residency/spec.md @@ -23,11 +23,11 @@ - **AND** 任一树的驻留数不影响另一树的上限判定 ### Requirement: 换出只针对空闲 agent 且不丢失记录 -换出 SHALL 只选择没有运行中回合、且邮箱中没有待触发后续任务的非 root agent,按最久未活动优先。换出 SHALL 先确保该 agent 的会话记录与身份已落盘,再卸载其运行实例;换出后该 agent 的状态 SHALL 为 `inactive`,其历史、路径与身份 MUST 保持可读。 +换出 SHALL 只选择没有运行中回合、且邮箱中没有待触发后续任务的非 root agent,按最久未活动优先。换出 SHALL 先确保该 agent 的会话记录与身份已落盘,再卸载其运行实例;换出后该 agent 不再出现在 `agent_list` 中(Codex 只列已加载的 agent),但其历史、路径与身份 MUST 保持可读,并仍在树目录中可被寻址。 #### Scenario: 换出后仍可查看 - **WHEN** `/root/a` 被换出 -- **THEN** `agent_list` 仍列出 `/root/a` 且状态为 `inactive` +- **THEN** `agent_list` 不再列出 `/root/a`,但对它的 `agent_send_message` / `agent_followup_task` 仍然有效 - **AND** 用户仍能在后台任务面板打开其只读对话记录 #### Scenario: 有待触发任务的 agent 不被换出 @@ -36,25 +36,25 @@ - **AND** 若无其它可换出对象则返回上限错误 ### Requirement: 被换出的 agent 在被寻址时透明恢复 -对 `inactive` agent 的 `agent_send_message` 与 `agent_followup_task` SHALL 先为其取得驻留槽并从会话记录恢复运行实例,再执行投递;恢复后的 agent SHALL 保有原路径、历史、蜂群模式与专家身份。被中断后换出的 agent 同样 SHALL 可恢复。`agent_interrupt` 与 `agent_list` MUST NOT 为了执行而恢复 `inactive` agent。 +对已换出 agent 的 `agent_send_message` 与 `agent_followup_task` SHALL 先为其取得驻留槽并从会话记录恢复运行实例,再执行投递;恢复后的 agent SHALL 保有原路径、历史、蜂群模式与专家身份。被中断后换出的 agent 同样 SHALL 可恢复。`agent_interrupt` 与 `agent_list` MUST NOT 为了执行而恢复已换出的 agent。 #### Scenario: 后续任务恢复被换出的 agent -- **WHEN** `/root` 对 `inactive` 的 `/root/a` 调用 `agent_followup_task` +- **WHEN** `/root` 对已换出的 `/root/a` 调用 `agent_followup_task` - **THEN** `/root/a` 被恢复并以该任务启动新回合 - **AND** 其上下文包含换出前的全部历史 #### Scenario: 恢复受上限约束 -- **WHEN** 驻留已满且无可换出对象,`/root` 对 `inactive` 的 `/root/a` 调用 `agent_followup_task` -- **THEN** 调用返回上限错误 -- **AND** `/root/a` 保持 `inactive`,任务不丢失地留在回执中要求稍后重试 +- **WHEN** 驻留已满且无可换出对象,`/root` 对已换出的 `/root/a` 调用 `agent_followup_task` +- **THEN** 调用返回上限错误 "collab tool failed: agent thread limit reached" +- **AND** `/root/a` 保持未加载,消息未投递,调用者可稍后重试 ### Requirement: 统一的 agent 状态枚举 -所有报告 agent 状态的接口 SHALL 使用同一枚举:`pending_init`(已创建尚未开始首个回合)、`running`、`idle`(回合正常结束)、`interrupted`、`errored`、`inactive`(已换出或 daemon 重启后未加载)、`not_found`。`agent_list`、协作工具回执、会话状态接口与后台任务面板 MUST 使用相同取值。 +`agent_list` 与 `agent_interrupt` 的回执 SHALL 使用 Codex 的状态枚举:`pending_init`(已创建尚未开始首个回合)、`running`、`interrupted`、`{"completed": 最终回答}`、`{"errored": 错误}`、`not_found`(含已换出或 daemon 重启后未加载)。 #### Scenario: 状态一致 - **WHEN** `/root/a` 因用户中止而结束回合 - **THEN** `agent_list` 报告 `interrupted` -- **AND** 后台任务面板与会话状态接口报告同一取值 +- **AND** 父 agent 不收到 `FINAL_ANSWER` ### Requirement: 驻留槽的并发占用不得超限 驻留槽 SHALL 在创建会话或启动恢复之前原子预留,成功后提交、失败时释放;并发的 `agent_spawn` 与恢复请求 MUST NOT 使已加载 agent 数超过上限。 @@ -65,9 +65,9 @@ - **AND** 另一个返回上限错误且不创建会话 ### Requirement: daemon 重启后重建树状态 -daemon 重启后 SHALL 从会话元数据重建每棵树的目录(路径、父子关系、蜂群模式),所有节点初始为 `inactive`;邮箱内容为运行时状态,重启后 MAY 丢失。`agent_followup_task` SHALL 能恢复重建的节点;`agent_list` 与 `agent_wait` MUST NOT 为此自动恢复任何节点。 +daemon 重启后 SHALL 从根会话目录中的树索引重建每棵树的目录(路径 → 会话 id),所有子节点初始为未加载;邮箱内容为运行时状态,重启后 MAY 丢失。`agent_followup_task` SHALL 能恢复重建的节点;`agent_list` 与 `agent_wait` MUST NOT 为此自动恢复任何节点。 #### Scenario: 重启后列出树 - **WHEN** daemon 重启后用户恢复 `mesh` 模式的 root 会话并调用 `agent_list` -- **THEN** 结果按原路径列出全部子 agent 且状态为 `inactive` -- **AND** 没有子 agent 因该调用被恢复 +- **THEN** 结果只列出 `/root`(子 agent 尚未加载) +- **AND** 没有子 agent 因该调用被恢复;对原路径的 `agent_followup_task` 会恢复对应子 agent diff --git a/openspec/changes/add-mesh-swarm-mode/tasks.md b/openspec/changes/add-mesh-swarm-mode/tasks.md index 70c059f7f..140cf1062 100644 --- a/openspec/changes/add-mesh-swarm-mode/tasks.md +++ b/openspec/changes/add-mesh-swarm-mode/tasks.md @@ -1,74 +1,75 @@ ## 1. 模式枚举、身份字段与路径 -- [ ] 1.1 新增 `SwarmMode { Off, Star, Mesh }` 及字符串互转,扩展 `SessionMeta`(`swarm_mode`、`agent_root_session_id`、`agent_task_name`、`agent_path`、`agent_depth`,空值省略)、`SessionOptions`、`SessionEntry`、`SessionInfo`;验证 `tests/session/session_storage_test.cpp` 新增的往返用例通过且旧 meta 读取为 `off` -- [ ] 1.2 新增 `src/session/agent_path.{hpp,cpp}`:`root`/`is_root`/`join`/`resolve`/任务名校验/legacy 路径合成;验证 `tests/session/agent_path_test.cpp` 覆盖嵌套、相对解析、非法名、`root`/`.`/`..` 拒绝 -- [ ] 1.3 `SessionRegistry::make_entry_locked` 从 `SessionOptions` 与恢复的 meta 写入模式与身份并透传到 `SessionManager`/`AgentLoop`;验证 `tests/tool/spawn_subagent_tool_test.cpp` 风格的 resume 用例恢复出相同路径与模式 -- [ ] 1.4 消息路由 `routes_sessions.cpp` 把 `swarm_mode` 解析为 `"star"|"mesh"|false|true(=star)`,非法值 400,接受后更新会话模式并写入用户消息 metadata,省略时沿用会话模式;验证 `web_server_smoke_test.cpp` 覆盖四种取值与 sticky 行为 +- [x] 1.1 新增 `SwarmMode { Off, Star, Mesh }` 及字符串互转,扩展 `SessionMeta`(实际只加 `swarm_mode`、`agent_path` 两个字段,见 design.md「实现记录」)、`SessionOptions`、`SessionInfo`;验证 meta 往返与旧 meta 读取为 `off` +- [x] 1.2 新增 `src/domain/session/agent_path.{hpp,cpp}`:`root`/`is_root`/`join`/`resolve`/任务名校验(不合成 legacy 路径);验证 `tests/session/agent_path_test.cpp` 覆盖嵌套、相对解析、非法名、`root`/`.`/`..` 拒绝 +- [x] 1.3 `SessionRegistry` 从 `SessionOptions` 与恢复的 meta 写入模式与身份(`apply_swarm_identity`);验证 `mesh_agent_service_test.cpp::RestartRebuildsTreeFromIndexAndRestoresOnDemand` 恢复出相同路径与模式 +- [x] 1.4 消息路由 `routes_sessions.cpp` 把 `swarm_mode` 解析为 `"star"|"mesh"|"off"|true(=star)|false`,非法值 400,接受后更新会话模式并写入用户消息 metadata,省略时沿用会话模式;验证 `web_server_smoke_test.cpp::PostMessageQueuesInputInDaemonSession` ## 2. 工具互斥与模式切换 -- [ ] 2.1 `ToolCapabilityPolicy` 增加 `hidden_builtin_tools` 并在 `ToolExecutor::is_allowed` 中生效;验证 `tests/tool/` 新增用例:隐藏集内工具从 schema 消失且执行被拒 -- [ ] 2.2 按模式计算隐藏集(`off`/`star` 隐藏 `agent_*`;`mesh` 隐藏 `spawn_subagent`、`wait_subagent` 与全部 thread 工具),模式切换经 `enqueue_control` 重设 policy;验证 AgentLoop 测试中切换后下一回合的工具列表变化、当前回合不变 -- [ ] 2.3 被隐藏工具的调用返回带模式说明的拒绝文案(星型工具指向 `agent_spawn`,网状工具说明仅在网状可用);验证单测断言文案 -- [ ] 2.4 `MeshTreeControl::has_running_or_pending_children` 作为退出 mesh 的门:消息路由返回 409,`/swarm` 返回命令错误;验证 smoke test 与命令测试 +- [x] 2.1 `ToolCapabilityPolicy` 增加 `hidden_builtin_tools` 并在 `ToolExecutor::is_allowed` / 执行入口生效 +- [x] 2.2 按模式计算隐藏集(`off`/`star` 隐藏 `agent_*`;`mesh` 隐藏 `spawn_subagent`、`wait_subagent` 与全部 thread 工具);AgentLoop 每回合从 SessionManager 读模式(不经 `enqueue_control`),切换只影响下一回合;验证 `characterization_lifecycle_test.cpp::SwarmModeFollowsSessionContextBetweenTurns` +- [x] 2.3 被隐藏工具的调用返回带模式说明的拒绝文案;验证 `mesh_swarm_domain_test.cpp::ToolFamiliesAreMutuallyExclusive` +- [x] 2.4 退出 mesh 的守卫(`MeshAgentService::tree_busy_reason`,经 `SessionRegistry::set_swarm_mode_guard`):消息路由 409,`/swarm` 返回错误;网状子 agent 不能改模式;验证 `mesh_agent_service_test.cpp::LeavingMeshIsRefusedWhileAgentsRun` ## 3. AgentLoop 邮箱与投递 -- [ ] 3.1 在 `AgentLoop` 增加跨 agent 邮箱(信封队列、`Mailbox`/`Steer` 活动、condvar),`deliver(mail)` 在回合锁下判定「排入当前回合 / 开新回合 / 留箱」;验证 `tests/agent_loop_*` 新增用例覆盖运行中、空闲触发、空闲不触发三种路径 -- [ ] 3.2 `drain_active_turn_inputs` 在 steering 之后消费邮箱,`run_agent_with_input` 开始时先 drain;验证信封出现在下一次模型请求且顺序为 steering 先、邮箱后 -- [ ] 3.3 busy→idle 竞态:`close_if_empty` 与 `deliver` 同锁,后续任务恰好一次;验证状态机测试三种时序(关闭前入箱、关闭后入箱、同时)均恰好投递一次 -- [ ] 3.4 `steer_input` 入队时通知邮箱 condvar;`wait_for_mailbox_activity(deadline, abort_flag)` 返回活动类型不出队;验证等待被邮箱、steer、abort、超时四种方式结束 -- [ ] 3.5 把 `hidden_goal_context` 泛化为隐藏内部输入(不触发 UserPromptSubmit、不计 `turn_count`、不做技能展开、不入摘要),信封以 `role=user` + `metadata.inter_agent` 持久化;验证 `RequestPrefixIsByteStableAcrossIterationsInATurn` 仍通过且新增用例断言 turn_count 不变 -- [ ] 3.6 检查 `AgentLoopDoomGuard` 对重复 `agent_wait` 调用的判定,必要时按工具名豁免;验证连续 5 次同参 `agent_wait` 不触发守卫 +- [x] 3.1 `engine/agent/mailbox/AgentMailbox`:信封一律入箱,`trigger_turn` 在空闲时经 `mailbox_wake` 任务唤醒;验证 `agent_loop_mesh_mailbox_test.cpp` 覆盖运行中、空闲触发、空闲不触发 +- [x] 3.2 `TurnRunner::drain_inputs` 先 steering 后邮箱,回合第一次请求前也并入;验证 `QueueOnlyMailWaitsForNextTurn`、`MailArrivingMidTurnJoinsTheNextRequest` +- [x] 3.3 回合以最终回答收尾时只排队的邮件留到下一回合,已被并入的触发邮件不再多跑一回合;验证 `MailArrivingMidTurnJoinsTheNextRequest`(触发 / 非触发两种) +- [x] 3.4 steer 通知邮箱;`wait_for_mailbox_activity` 不出队;验证 `agent_mailbox_test.cpp` 与 `WaitForMailboxActivityOutcomes`(邮件、steer、abort、超时) +- [x] 3.5 信封以 `role=user` + `metadata.inter_agent` 持久化;NEW_TASK 计为真实用户消息,MESSAGE / FINAL_ANSWER 是内部上下文;不计可见回合、不入摘要、不触发 UserPromptSubmit;验证 `RequestPrefixIsByteStableAcrossIterationsInATurn` 仍通过 +- [x] 3.6 `AgentLoopDoomGuard` 每次模型响应开头 `begin_model_turn()` 清空重复窗口,跨回合的重复 `agent_wait` 不会被拦,无需豁免 -## 4. MeshTreeControl 与驻留 +## 4. MeshAgentService 与驻留 -- [ ] 4.1 新增 `src/session/mesh_tree_control.{hpp,cpp}`:按 root 的 `TreeState`(目录、状态、LRU、pending 槽)、目标解析(会话 id 优先、再路径、必须同根)、名称冲突检测;验证纯逻辑单测覆盖解析、冲突、跨树拒绝且不泄露信息 -- [ ] 4.2 `reserve_slot`:预留 → 换出 LRU 空闲(非 root、`!is_busy()`、邮箱无触发项)→ 上限错误;换出走 `registry.destroy` 并置 `inactive`;验证测试:3 空闲 + spawn 换出最久未用者、全部运行时报错且不创建会话 -- [ ] 4.3 `ensure_loaded`:`inactive` → 预留槽 → `registry.resume`(带身份、模式、专家)→ `on_agent_loaded` 回调;验证恢复后路径/历史/模式一致,槽满时返回上限错误且目标保持 `inactive` -- [ ] 4.4 spawn 两阶段(持锁校验与预留 → 锁外 create/fork + 首条 `NEW_TASK` → 持锁提交;失败回滚并清理半成品会话);验证并发测试:两方争最后一个槽恰好一个成功、失败路径不留幽灵节点 -- [ ] 4.5 状态推导(`pending_init/running/idle/interrupted/errored/inactive/not_found`)来自 `BusyChanged`/`Done` 的 outcome 与 `is_aborting()`;验证每种终态映射用例 -- [ ] 4.6 完成回调:`on_child_turn_finished` 构造 `FINAL_ANSWER`(正常 = 最后一条 assistant 文本;错误/中断 = 状态说明 + 部分输出)以 `trigger_turn=false` 投父邮箱并 emit 带 `unread_mailbox` 的 `session_status`;验证父空闲不被唤醒、父等待被唤醒、错误结束时信封含状态 -- [ ] 4.7 daemon 启动扫描 `agent_root_session_id` 重建目录为 `inactive`;验证重启测试中 `agent_list` 按原路径列出且无自动恢复 -- [ ] 4.8 配置 `swarm.mesh.max_concurrent_agents`(默认 4,最小 2)与 `swarm.mesh.{min,default,max}_wait_timeout_ms`(10000/30000/3600000);验证 `tests/config/` 读取、默认值与越界校验 +- [x] 4.1 `host/session_host/mesh/MeshAgentService`(取代设计稿的 `MeshTreeControl`):按根的 `Tree`、目标解析(会话 id 优先、再路径)、重名检测;验证 `mesh_agent_service_test.cpp` +- [x] 4.2 `reserve_slot`:预留 → 换出 LRU(已有结果、无待办、空邮箱)→ 上限错误;验证 `FullTreeEvictsLeastRecentlyActiveAgentAndRestoresOnDemand`、`ThreadLimitReachedWhenNoAgentCanBeEvicted` +- [x] 4.3 `ensure_loaded`:未加载 → 预留槽 → `registry.resume` → 冲刷暂存邮件 → `on_agent_loaded`;验证同上与 `RestartRebuildsTreeFromIndexAndRestoresOnDemand` +- [x] 4.4 spawn 三阶段(持锁占路径 → 锁外创建 + fork 历史 → 持锁发布并投递 NEW_TASK;失败回滚路径与槽) +- [x] 4.5 状态按 Codex 枚举:`pending_init / running / interrupted / completed / errored / not_found`(未加载 = not_found,不列入 agent_list) +- [x] 4.6 完成回报:completed = 该回合最终回答,errored = `Agent errored: …` + 下一步提示,interrupted 不回报;`trigger_turn=false` 不唤醒父;验证 `SpawnRunsChildAndRoutesFinalAnswerToIdleRoot`、`ErroredChildReportsAgentErroredToParent`、`InterruptAbortsRunningChildWithoutFinalAnswer` +- [x] 4.7 重启后按根会话目录的 `mesh_agents.json` 重建树(不扫全部 meta);验证 `RestartRebuildsTreeFromIndexAndRestoresOnDemand` +- [x] 4.8 配置 `swarm.mesh.{max_concurrent_agents, min/default/max_wait_timeout_ms, expose_model_overrides}`;验证 `tests/config/config_swarm_test.cpp` ## 5. 六个协作工具 -- [ ] 5.1 新增 `src/tool/mesh_agent_tools.{hpp,cpp}` 与 `MeshAgentToolDeps`(延迟回填,与 `SubagentToolDeps` 同法),三处注册(`worker.cpp`、`main.cpp`、`headless_runner.cpp`);验证 `--list-tools` 与工具注册测试列出六个工具 -- [ ] 5.2 `agent_spawn(task_name, message, agent_type?, fork_turns?, model?, reasoning_effort?)`:校验、预留、创建/fork、首条 `NEW_TASK`、立即返回路径/会话 id/状态,结果带 `summary.icon="agent"` 与 `metadata.subagent_session_id`/`agent_path`;验证二级派生成功、重名与非法名拒绝、未知模型拒绝、不阻塞 -- [ ] 5.3 `agent_list(path_prefix?)` 返回含 root 与 `inactive` 节点的有序目录;验证兄弟可见与前缀过滤 -- [ ] 5.4 `agent_send_message(target, message)`:入箱不唤醒,可发 root,`inactive` 先恢复;验证运行中目标下一请求可见、空闲 root 保持空闲、恢复路径 -- [ ] 5.5 `agent_followup_task(target, message)`:禁止 root,空闲/完成/`inactive` 开新回合,运行中边界投递;验证三种目标状态与 root 拒绝文案 -- [ ] 5.6 `agent_wait(timeout_ms?)`:钳制 [10s, 1h] 默认 30s,返回三种固定文案;验证邮箱唤醒、steer 打断、超时、abort 结束且不影响其它 agent -- [ ] 5.7 `agent_interrupt(target)`:禁止 root 与自身,返回中止前状态,空闲目标不报错;验证运行中中止后仍可接后续任务 -- [ ] 5.8 `agent_type` → 专家团成员映射(任意层级可用,非法 id 列出可选成员,未绑定专家团报错);验证以现有 team expert fixture 的用例 -- [ ] 5.9 六个工具的描述文案对照 Codex `multi_agents_spec.rs` V2 描述改写为 `agent_*` 名;验证单测断言关键句(相对/canonical 路径说明、不触发回合、禁止 root) +- [x] 5.1 `host/session_host/tools/mesh_agent_tools.{hpp,cpp}`,三处注册(worker、TUI、headless;headless 先注册后 `rebind_mesh_agent_tools`) +- [x] 5.2 `agent_spawn`:输出 `{"task_name": 路径}`,metadata `subagent_session_id` / `agent_path`;验证 `ToolsMatchCodexArgumentAndOutputContracts`、`SpawnValidatesCallerAndArguments` +- [x] 5.3 `agent_list(path_prefix?)`:只列已加载 agent(Codex 原样),根在前;验证 `NestedChildReportsToItsParentNotRoot` +- [x] 5.4 `agent_send_message`:入箱不唤醒,可发根,未加载目标先恢复;验证 `FollowupWakesIdleChildWhileSendMessageOnlyQueues` +- [x] 5.5 `agent_followup_task`:禁止根,空闲唤醒,运行中边界投递;验证同上 +- [x] 5.6 `agent_wait(timeout_ms?)`:低于最小值夹取并注明,超过最大值报错,返回 Codex 三种文案;验证 `ToolsMatchCodexArgumentAndOutputContracts` +- [x] 5.7 `agent_interrupt`:禁止根与自身,返回中断前状态;验证 `InterruptAbortsRunningChildWithoutFinalAnswer` +- [x] 5.8 `agent_type` → 团队专家成员(未绑定团队专家报错,未知成员 `unknown agent_type`) +- [x] 5.9 描述文案对照 Codex V2 改为 `agent_*` 名 ## 6. fork_turns 与提示词 -- [ ] 6.1 实现 `fork_turns` 过滤器(保留真实用户消息与无 tool_calls 的 assistant 最终回复;丢弃 tool/system/meta/压缩/信封;N = 最近 N 个用户回合)并接 `fork_session_to_new_id` + `resume`;验证 `none`/`all`/`"2"`/非法值四个用例 -- [ ] 6.2 新增 `src/prompt/mesh_swarm_prompts.{hpp,cpp}`:移植 root/子 agent 角色提示、主动委派文案、等待与并发槽提示并改用 `agent_*` 名;`build_mesh_swarm_context_prompt` 在现有 swarm 引导同位置注入,`star` 仍走 `build_swarm_mode_context_prompt`;验证 `tests/prompt/system_prompt_test.cpp`:root/子提示内容、静态前缀逐字节不变、同回合两次请求逐字节相同 -- [ ] 6.3 网状子会话标题 = `task_name`,跳过 `maybe_start_auto_title`;验证 registry 测试中派生后无标题生成尝试且标题为任务名 +- [x] 6.1 `fork_turns` 过滤器(`session/agent_fork_history`):从父会话的有效模型历史保留用户消息与最终回答;验证 `mesh_swarm_domain_test.cpp::KeepsUserTurnsAndFinalAnswersOnly` +- [x] 6.2 `engine/prompt/mesh_swarm_prompts`:根 / 子 agent 角色提示与主动委派文案,注入在可变上下文段,静态前缀不变 +- [x] 6.3 网状子会话标题 = `task_name`,投递不走 `send_input` 所以不触发自动标题 ## 7. Web/Desktop 入口与面板 -- [ ] 7.1 `InputBar.jsx` `+` 菜单改为「蜂群模式(星型)」「蜂群模式(网状)」两个 `menuitemradio`,`ComposerSessionControls.jsx` 芯片按模式显示文案与关闭;`ChatView.jsx` 的 `composerSwarmMode` 改枚举并从 `SessionInfo.swarm_mode` 恢复,`normalizeComposerPayload` 输出字符串;验证 `composerSessionControlsArchitecture.test.js`、`chatInputQueue.test.js` 更新用例通过 -- [ ] 7.2 `chatInputQueue.js` 透传字符串取值并保留旧布尔兼容;验证队列重建/重试/引导保留模式 -- [ ] 7.3 `subagentTasks.js` 归一化 `agentPath`、`agentDepth`、`agentStatus`、`unreadMailbox`,分组按统一状态、排序按路径;`SubagentPanel.jsx` 按深度缩进、显示路径/状态/最后一条消息/tokens/耗时,`inactive` 可只读查看;`useSubagentTasks.js` 对恢复后的会话重新 retain;验证 `subagentTasks.test.js` 新增缩进/分组/未读用例 -- [ ] 7.4 ChatView 标题栏后台任务入口显示 root 未读数并在 root 回合开始后清零;验证前端测试 -- [ ] 7.5 `Message.jsx` 识别 `metadata.inter_agent` 渲染独立行(类型 + 发件人路径),`sessionTranscript.js` 摘要与折叠投影排除信封;验证渲染测试与 resume 回放一致性用例 -- [ ] 7.6 `web/scripts/i18n-en-overrides.mjs` 增加 `蜂群模式(星型)`/`蜂群模式(网状)`及提示文案的英文(`Swarm mode (Star)` / `Swarm mode (Mesh)`),运行 `pnpm i18n:catalog`;验证 `pnpm i18n:audit` 无缺失 +- [x] 7.1 `InputBar.jsx` 两个 `menuitemradio`,`ComposerSessionControls.jsx` 芯片按模式;`ChatView.jsx` 芯片 = 服务端模式 + 本地选择,只有不同才提交;验证 `composerSessionControlsArchitecture.test.js`、`swarmMode.test.js` +- [x] 7.2 `chatInputQueue.js` 透传 `"star"|"mesh"|"off"` 并兼容旧布尔;验证 `chatInputQueue.test.js` +- [~] 7.3 `subagentTasks.js` 归一化 `agentPath`,卡片显示路径,`agent_spawn` 打开面板、`agent_spawn`/`agent_wait` 结束时刷新;**未做**按深度缩进、未读计数 +- [ ] 7.4 标题栏后台任务入口的根未读数(Codex 无对应物,未做) +- [x] 7.5 信封在 transcript 摄入时转成系统提示行(`web/src/lib/interAgentMessage.js` + `systemNotice` 四个代码),不进用户气泡;验证 `interAgentMessage.test.js` +- [x] 7.6 i18n:`蜂群模式(星型)/(网状)` 等文案已加英文并重新生成目录;`pnpm i18n:audit` 通过 ## 8. TUI 与 headless -- [ ] 8.1 注册 `/swarm [star|mesh|off]`(TUI 命令注册表、daemon `execute_builtin_command`、Web 斜杠目录 `commands_handler.cpp`),无参显示当前模式,非法参数列出合法值;验证命令测试三端 -- [ ] 8.2 `SubagentHost` 处理 `on_agent_loaded` 重订阅,任务标题优先路径,`/tasks` 显示路径与统一状态,`/tasks abort` 对网状 agent 等价于中止回合;验证 `tests/tui/subagent_host_test.cpp` 新用例 -- [ ] 8.3 确认/提问浮层与 Web 全局弹窗的来源标签使用 canonical 路径,任意深度冒泡到 root;验证二级子 agent 的权限与提问路由测试 -- [ ] 8.4 headless `--swarm star|mesh`(缺省 off,非法值 exit 64)透传到 `SessionOptions::swarm_mode`;验证 `tests/headless/` 参数解析与帮助文本用例 -- [ ] 8.5 星型子代理的 `SessionOptions::swarm_mode` 为 `off`,`spawn_subagent` 深度规则与 `build_swarm_mode_context_prompt` 文案不变;验证现有 `SwarmModeContextIsRequestLocalAndPolicyGated` 与 spawn 深度用例原样通过 +- [x] 8.1 `/swarm [star|mesh|off]`(TUI `swarm_mode_command.cpp`、daemon 内置命令、Web 斜杠目录);验证 `swarm_command_test.cpp`、`commands_handler_test.cpp` +- [x] 8.2 `SubagentHost` 在 `on_agent_loaded` 时登记并订阅,侧栏以路径为显示名;TUI 显示信封为系统行(`inter_agent_display_text`,实时与回放) +- [~] 8.3 权限 / 提问的来源标签沿用子会话标题(= task_name),未改成完整路径 +- [x] 8.4 headless `--swarm star|mesh|off`(非法值 usage error);验证 `headless_options_test.cpp::ParsesAndValidatesSwarmMode` +- [x] 8.5 星型路径不变;现有 spawn / swarm 用例原样通过 ## 9. 文档与收尾 -- [ ] 9.1 更新 `docs/subagents.md`(两种模式、六个工具、邮箱与驻留语义)、`docs/daemon-api.md`(`swarm_mode` 取值、`SessionSummary` 新字段、`session_status` 未读字段)、`docs/help/swarm.html` 文案与 `CLAUDE.md` 子代理段落;验证文档中的工具名与代码注册名一致 -- [ ] 9.2 单元测试全部使用中文注释并写明触发场景与期望行为;验证 `cmake --build` 与 `acecode_unit_tests` 全绿,`pnpm test`、`pnpm build` 通过 -- [ ] 9.3 运行 `openspec validate add-mesh-swarm-mode --strict` 与 `scripts/code_quality_check.bat`;验证无报错,并用 `git add -f openspec/changes/add-mesh-swarm-mode` 把变更文档随分支提交(`openspec/` 在 `.gitignore` 中) +- [x] 9.1 `docs/subagents.md` §9、`docs/daemon-api.md`(`swarm_mode` 取值、409、`session_updated{swarm_mode}`、列表字段)、帮助页 `swarm`、`CLAUDE.md` +- [x] 9.2 单元测试中文注释;`acecode_unit_tests` fast profile 全绿,`pnpm test` / `pnpm build` 通过 +- [x] 9.3 `openspec validate add-mesh-swarm-mode --strict` 通过;变更文档随分支强制加入版本库(`openspec/` 被 ignore) +- [ ] 9.4 帮助页截图 AD-03 仍是旧菜单(只有一个「蜂群模式」),需补拍 diff --git a/scripts/layers/size_baseline.txt b/scripts/layers/size_baseline.txt index e5b92f547..78a602b6a 100644 --- a/scripts/layers/size_baseline.txt +++ b/scripts/layers/size_baseline.txt @@ -29,5 +29,5 @@ src/engine/agent/agent_loop.cpp 6952 src/engine/agent/agent_loop.hpp 1010 src/engine/prompt/system_prompt.cpp 1383 src/host/remote_control/session_channel_binder.cpp 1236 -src/host/session_host/session_registry.cpp 2466 +src/host/session_host/session_registry.cpp 2400 src/host/session_host/thread_service.cpp 1211 diff --git a/src/apps/daemon/worker.cpp b/src/apps/daemon/worker.cpp index 9256e2e7e..add3ba9a5 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -16,6 +16,8 @@ #include "version.hpp" #include "session_host/tools/spawn_subagent_tool.hpp" #include "session_host/tools/thread_tools.hpp" +#include "session_host/tools/mesh_agent_tools.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" #include "tool/workspace_tools.hpp" #include "workspace/workspace_registry.hpp" #include "experts/expert_registry.hpp" @@ -564,6 +566,11 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { auto task_suggestions = std::make_shared<acecode::TaskSuggestionService>( acecode::TaskSuggestionService::Deps{®istry, &client, &cfg_mut, &app_config_mu}); acecode::register_task_suggestion_tools(tools, task_suggestions); + // 蜂群模式(网状):agent_* 工具在 registry 之后注册,闭包只捕获 weak_ptr(C12)。 + auto mesh_service = std::make_shared<acecode::mesh::MeshAgentService>( + acecode::mesh::MeshAgentService::Deps{®istry, &expert_registry, &cfg_mut, &app_config_mu, {}, {}}); + mesh_service->attach(); + acecode::register_mesh_agent_tools(tools, mesh_service, cfg_mut); // LOOP is daemon-owned and independent of browser connections. SQLite is // initialized before HTTP routes are exposed; scheduler shutdown happens @@ -791,7 +798,10 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { break; case DaemonShutdownStep::TaskSuggestions: task_suggestions->shutdown(); break; case DaemonShutdownStep::Sessions: registry.shutdown_all(); break; - case DaemonShutdownStep::SpawnListener: subagent_deps->on_spawn = {}; break; + case DaemonShutdownStep::SpawnListener: + subagent_deps->on_spawn = {}; + mesh_service->shutdown(); + break; case DaemonShutdownStep::Mcp: mcp_runtime.shutdown(); break; case DaemonShutdownStep::Lsp: acecode::lsp::shutdown(); break; case DaemonShutdownStep::ModelPool: acecode::model_pool_status_service().stop(); break; @@ -856,6 +866,12 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { active.track_subagent(child_id); }); }; + mesh_service->set_on_agent_loaded( + [server_ref](const std::string& child_id, const std::string& /*root_id*/) { + server_ref.with([&](acecode::web::WebServer& active) { + active.track_subagent(child_id); + }); + }); // 行为①:持久化的 bound_session_id 非空且会话存在(active 或可从磁盘 // resume)→ 自动 start rc 服务 + 激活默认 channel + 重建绑定。失败只记 diff --git a/src/apps/headless/headless_options.cpp b/src/apps/headless/headless_options.cpp index a080181a7..969b60db2 100644 --- a/src/apps/headless/headless_options.cpp +++ b/src/apps/headless/headless_options.cpp @@ -184,6 +184,11 @@ HeadlessCliOptions parse_headless_cli_options(const std::vector<std::string>& to o.permission_mode = tokens[++i]; } else if (split_eq(t, "--permission-mode", eq_value)) { o.permission_mode = eq_value; + } else if (t == "--swarm") { + if (i + 1 >= tokens.size()) return fail("--swarm requires a value (star|mesh|off)"); + o.swarm_mode = tokens[++i]; + } else if (split_eq(t, "--swarm", eq_value)) { + o.swarm_mode = eq_value; } else if (t == "--model") { if (i + 1 >= tokens.size()) return fail("--model requires a saved model name"); o.model_name = tokens[++i]; @@ -253,6 +258,10 @@ HeadlessCliOptions parse_headless_cli_options(const std::vector<std::string>& to return fail("--session-id names a NEW session and cannot be combined " "with --resume/--continue"); } + if (!o.swarm_mode.empty() && o.swarm_mode != "star" && o.swarm_mode != "mesh" && + o.swarm_mode != "off") { + return fail("invalid --swarm: " + o.swarm_mode + " (expected star|mesh|off)"); + } if (!o.output_format.empty() && o.output_format != "text" && o.output_format != "json" && o.output_format != "stream-json") { return fail("invalid --output-format: " + o.output_format + @@ -264,7 +273,7 @@ HeadlessCliOptions parse_headless_cli_options(const std::vector<std::string>& to !o.prompt.empty() || o.dangerous_mode || o.include_thinking || o.continue_latest || !o.resume_session_id.empty() || !o.session_id.empty() || !o.output_format.empty() || - !o.permission_mode.empty() || !o.model_name.empty() || + !o.permission_mode.empty() || !o.model_name.empty() || !o.swarm_mode.empty() || o.max_turns != 0 || !o.disabled_system_tools.empty() || !o.enabled_skills.empty() || !o.enabled_mcp_servers.empty(); if (discovery_mode && has_execution_input) { @@ -285,7 +294,7 @@ std::string print_mode_usage_line() { " or: acecode -p [-c | --resume <id> | --session-id <id>] " "[--output-format text|json|stream-json] [--thinking] " "[--yolo] [--permission-mode <m>] " - "[--model <name>] [--max-turns <n>] " + "[--model <name>] [--swarm <mode>] [--max-turns <n>] " "[--disable-tools <names>] [--enable-skills <names>] " "[--enable-mcp <names>] \"<prompt>\"\n" "run `acecode -p --help` for details\n"; @@ -321,6 +330,8 @@ std::string print_mode_help() { " its saved model unless this option is supplied)\n" " --permission-mode <m> default | auto | plan | yolo (accept-edits = auto)\n" " (default: default; also applies on resume)\n" + " --swarm <mode> star | mesh | off: swarm mode for this session\n" + " (new sessions: off; resume keeps the saved mode)\n" " --max-turns <n> Cap agent-loop iterations (default: unlimited)\n" " --disable-tools <names> Disable exact system tool names (default: none)\n" " --enable-skills <names> Enable exact installed Skill names (default: none)\n" diff --git a/src/apps/headless/headless_options.hpp b/src/apps/headless/headless_options.hpp index 956062a95..ccb7812bf 100644 --- a/src/apps/headless/headless_options.hpp +++ b/src/apps/headless/headless_options.hpp @@ -56,6 +56,7 @@ struct HeadlessCliOptions { std::string output_format; // --output-format <text|json|stream-json>,空 = text std::string permission_mode; // --permission-mode <default|accept-edits|plan|yolo> std::string model_name; // --model <saved_models.name> + std::string swarm_mode; // --swarm <star|mesh|off>,空 = 新会话 off、resume 沿用 int max_turns = 0; // --max-turns <n>,0 = 本次无限制 std::vector<std::string> disabled_system_tools; // --disable-tools 精确名称 std::vector<std::string> enabled_skills; // --enable-skills 精确名称 diff --git a/src/apps/headless/headless_runner.cpp b/src/apps/headless/headless_runner.cpp index 61ec1cb60..3d924d541 100644 --- a/src/apps/headless/headless_runner.cpp +++ b/src/apps/headless/headless_runner.cpp @@ -37,6 +37,9 @@ #include "tool/skills_tool.hpp" #include "tool/tool_executor.hpp" #include "session_host/tools/thread_tools.hpp" +#include "session_host/tools/mesh_agent_tools.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" +#include "utils/scope_exit.hpp" #include "tool/workspace_tools.hpp" #include "tool/web_search/backend_router.hpp" #include "tool/web_search/region_detector.hpp" @@ -206,6 +209,8 @@ void register_headless_tools( const std::shared_ptr<ThreadToolDeps>& thread_deps, const std::shared_ptr<WorkspaceToolDeps>& workspace_deps, const std::shared_ptr<MemoryService>& memory) { + // 网状蜂群工具先以空服务注册(--list-tools / --disable-tools 能看到), + // registry 建好后再 rebind_mesh_agent_tools 绑定真实服务。 register_session_builtin_tools(tools, cfg); // 记忆工具与 TUI / daemon 同一份;可被 --disable-tools 移除(openspec unify-memory-system)。 if (memory) { @@ -223,6 +228,7 @@ void register_headless_tools( tools.register_tool(create_wait_subagent_tool(subagent_deps)); register_codex_thread_tools(tools, thread_deps); register_workspace_tools(tools, workspace_deps); + register_mesh_agent_tools(tools, {}, cfg); } int print_available_capabilities(const HeadlessCliOptions& opts) { @@ -562,12 +568,19 @@ int run_print_mode(const HeadlessCliOptions& opts) { subagent_deps->config = &cfg; thread_deps->service = std::make_shared<acecode::ThreadService>( acecode::ThreadService::Deps{®istry, &client}); + auto mesh_service = std::make_shared<acecode::mesh::MeshAgentService>( + acecode::mesh::MeshAgentService::Deps{®istry, nullptr, &cfg, nullptr, {}, {}}); + mesh_service->attach(); + acecode::rebind_mesh_agent_tools(tools, mesh_service, cfg); + // 声明在 session_cleanup 之后 → 先于 registry.shutdown_all 退订子 agent 事件。 + acecode::ScopeExit mesh_cleanup([mesh_service] { mesh_service->shutdown(); }); SessionOptions session_opts; session_opts.cwd = cwd; session_opts.model_name = opts.model_name; session_opts.permission_mode = effective_permission_mode; session_opts.auto_start = false; + session_opts.swarm_mode = opts.swarm_mode; // meta 探针:--continue 找最近会话 / --session-id 查碰撞都只读磁盘, // 复用 registry resume 的同款惰性 SessionManager 用法(不落任何文件)。 diff --git a/src/apps/tui/app/tui_agent_bridge.cpp b/src/apps/tui/app/tui_agent_bridge.cpp index ea0b33e5b..6caecfd22 100644 --- a/src/apps/tui/app/tui_agent_bridge.cpp +++ b/src/apps/tui/app/tui_agent_bridge.cpp @@ -1,5 +1,6 @@ #include "tui/app/tui_agent_bridge.hpp" #include "session/compact_notice.hpp" +#include "session/inter_agent_message.hpp" #include "tui/app/tui_screen_host.hpp" #include "tui/tui_state.hpp" #include "tui/chat/chat_viewport.hpp" @@ -88,7 +89,11 @@ void TuiAgentBridge::on_transcript_message(const ChatMessage& message) { std::chrono::steady_clock::now(); } - if (!acecode::tui::append_compact_notice_row( + // 蜂群模式(网状)的 agent 间消息落盘为 user 信封,界面显示为系统行。 + const std::string inter_agent = acecode::mesh::inter_agent_display_text(message); + if (!inter_agent.empty()) { + state.conversation.push_back({"system", inter_agent, false}); + } else if (!acecode::tui::append_compact_notice_row( state.conversation, message)) { state.conversation.push_back( {message.role, message.content, false}); diff --git a/src/apps/tui/app/tui_app.hpp b/src/apps/tui/app/tui_app.hpp index 6d49eb05c..361341835 100644 --- a/src/apps/tui/app/tui_app.hpp +++ b/src/apps/tui/app/tui_app.hpp @@ -57,6 +57,7 @@ class TuiApp final : private ITuiApplicationLifecycle { nlohmann::json ask_questions(const nlohmann::json& payload, const std::atomic<bool>* abort_flag, int timeout, const std::string& origin); std::string parent_session_id(); + AgentLoop* main_agent_loop(); void publish_subagent_tasks(std::vector<SubagentTaskSnapshot> tasks); void receive_subagent_permission(const std::string& session_id, const std::string& task_title, nlohmann::json payload); diff --git a/src/apps/tui/app/tui_app_session.cpp b/src/apps/tui/app/tui_app_session.cpp index a82f59c72..88e521cd6 100644 --- a/src/apps/tui/app/tui_app_session.cpp +++ b/src/apps/tui/app/tui_app_session.cpp @@ -13,6 +13,7 @@ #include "agent/agent_loop.hpp" #include "session/token_tracker.hpp" #include "session/permission_prompter.hpp" +#include "session_host/tools/mesh_agent_tools.hpp" #include "session_host/tools/spawn_subagent_tool.hpp" #include "tool/tool_executor.hpp" #include "session_host/apply_model_to_session.hpp" @@ -61,6 +62,8 @@ void TuiApp::initialize_agent() { loop_options.skill_idle_days = services_->config.skills.idle_days; loop_options.tool_policy = mcp_scope_policy(&services_->config, environment_.working_dir, std::nullopt, services_->mcp.get(), services_->tools.get()); + // 模式与网状路径每回合从 SessionManager 读取,这里只提供网状提示参数。 + loop_options.swarm = agent::make_swarm_context(SwarmMode::Off, "", services_->config.swarm.mesh); agent_loop_ = std::make_unique<AgentLoop>(std::move(loop_services), std::move(loop_options)); submitter_->attach(*agent_loop_); overlay_gate_->attach(*agent_loop_); @@ -133,6 +136,7 @@ void TuiApp::initialize_subagents() { subagent_host_deps.parent_session_id = bind(&TuiApp::parent_session_id); subagent_host_deps.publish_tasks = bind(&TuiApp::publish_subagent_tasks); subagent_host_deps.on_permission_request = bind(&TuiApp::receive_subagent_permission); + subagent_host_deps.main_loop = bind(&TuiApp::main_agent_loop); subagent_host_ = std::make_unique<SubagentHost>(std::move(subagent_host_deps)); auto& subagent_host = *subagent_host_; { @@ -149,6 +153,7 @@ void TuiApp::initialize_subagents() { ThreadService::Deps{ &subagent_host.registry(), &subagent_host.client()}); register_codex_thread_tools(tools, std::move(thread_deps)); + register_mesh_agent_tools(tools, subagent_host.mesh(), config); } } void TuiApp::resume_startup() { @@ -349,6 +354,7 @@ nlohmann::json TuiApp::ask_questions(const nlohmann::json& payload, return ask_via_tui_overlay(state_, screen_host_->screen(), payload, abort_flag, timeout, origin); } std::string TuiApp::parent_session_id() { return session_manager_.current_session_id(); } +AgentLoop* TuiApp::main_agent_loop() { return agent_loop_.get(); } void TuiApp::subagent_spawned(const std::string& id, const std::string& prompt) { subagent_host_->on_spawned(id, prompt); } diff --git a/src/apps/tui/commands/command_bootstrap.cpp b/src/apps/tui/commands/command_bootstrap.cpp index 120826717..641235395 100644 --- a/src/apps/tui/commands/command_bootstrap.cpp +++ b/src/apps/tui/commands/command_bootstrap.cpp @@ -3,6 +3,7 @@ #include "builtin_commands.hpp" #include "opencode_command_registry.hpp" #include "skill_commands.hpp" +#include "swarm_mode_command.hpp" #include "utils/logger.hpp" namespace acecode::tui { @@ -12,6 +13,8 @@ void register_slash_commands(CommandRegistry& cmd_registry, const AppConfig& config, const std::string& working_dir) { register_builtin_commands(cmd_registry); + // builtin_commands.cpp 已到行数基线,新内置命令从这里注册。 + register_swarm_mode_command(cmd_registry); auto command_keys = register_opencode_commands_tracked( cmd_registry, config, working_dir); if (!command_keys.empty()) { diff --git a/src/apps/tui/commands/swarm_mode_command.cpp b/src/apps/tui/commands/swarm_mode_command.cpp new file mode 100644 index 000000000..7c5dea7c1 --- /dev/null +++ b/src/apps/tui/commands/swarm_mode_command.cpp @@ -0,0 +1,66 @@ +#include "swarm_mode_command.hpp" + +#include "session/session_manager.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" +#include "session_host/swarm_command.hpp" +#include "tui/subagent_host.hpp" + +#include <mutex> + +namespace acecode { + +namespace { + +void emit(CommandContext& ctx, const std::string& text) { + { + std::lock_guard<std::mutex> lk(ctx.state.mu); + ctx.state.conversation.push_back({"system", text, false}); + ctx.state.chat_follow_tail = true; + } + if (ctx.post_event) ctx.post_event(); +} + +void cmd_swarm(CommandContext& ctx, const std::string& args) { + if (!ctx.session_manager) { + emit(ctx, "Swarm mode is unavailable: no session."); + return; + } + const SwarmMode current = + parse_swarm_mode(ctx.session_manager->current_swarm_mode()).value_or(SwarmMode::Off); + const auto parsed = parse_swarm_command(args); + if (!parsed.error.empty()) { + emit(ctx, parsed.error); + return; + } + if (parsed.show || !parsed.mode) { + emit(ctx, swarm_command_status_text(current)); + return; + } + const SwarmMode next = *parsed.mode; + if (current == SwarmMode::Mesh && next != SwarmMode::Mesh && ctx.subagent_host) { + // 与 daemon 的 SessionRegistry 守卫同一判定:子 agent 仍在跑时不许退出网状模式。 + if (auto mesh = ctx.subagent_host->mesh()) { + const std::string refused = + mesh->tree_busy_reason(ctx.session_manager->current_session_id()); + if (!refused.empty()) { + emit(ctx, refused); + return; + } + } + } + // AgentLoop 每回合从 SessionManager 读模式,写会话元数据即对下一回合生效。 + ctx.session_manager->set_swarm_mode(swarm_mode_name(next)); + emit(ctx, swarm_command_applied_text(next)); +} + +} // namespace + +void register_swarm_mode_command(CommandRegistry& registry) { + registry.register_command({ + "swarm", + "Show or switch swarm mode: /swarm star|mesh|off", + cmd_swarm, + }); +} + +} // namespace acecode diff --git a/src/apps/tui/commands/swarm_mode_command.hpp b/src/apps/tui/commands/swarm_mode_command.hpp new file mode 100644 index 000000000..7dd0d974e --- /dev/null +++ b/src/apps/tui/commands/swarm_mode_command.hpp @@ -0,0 +1,15 @@ +#pragma once + +// /swarm [star|mesh|off] — 切换当前会话的蜂群模式(add-mesh-swarm-mode)。 +// +// 文本解析与输出与 daemon builtin 共用 session_host/swarm_command.hpp; +// TUI 主会话不在 SessionRegistry 里,模式直接写主会话的 SessionManager, +// 下一回合生效。退出网状模式前由 agent 树服务判定是否仍有子 agent 在跑。 + +#include "command_registry.hpp" + +namespace acecode { + +void register_swarm_mode_command(CommandRegistry& registry); + +} // namespace acecode diff --git a/src/apps/tui/resume/session_replay.cpp b/src/apps/tui/resume/session_replay.cpp index 24d4677db..0fa495e75 100644 --- a/src/apps/tui/resume/session_replay.cpp +++ b/src/apps/tui/resume/session_replay.cpp @@ -4,6 +4,7 @@ #include "session/compact_checkpoint.hpp" #include "session/compact_notice.hpp" +#include "session/inter_agent_message.hpp" #include "session/session_rewind.hpp" #include "session/tool_metadata_codec.hpp" #include "session/tool_result_storage.hpp" @@ -94,9 +95,14 @@ std::vector<TuiState::Message> replay_session_messages( } if (msg.role == "user" || msg.role == "system") { - // 规范角色,文本承载所有信息,直接推入。 + // 规范角色,文本承载所有信息,直接推入;agent 间信封与实时一样显示为系统行。 flush_pending_calls(); - out.push_back({msg.role, msg.content, /*is_tool=*/false}); + const std::string inter_agent = mesh::inter_agent_display_text(msg); + if (!inter_agent.empty()) { + out.push_back({"system", inter_agent, /*is_tool=*/false}); + } else { + out.push_back({msg.role, msg.content, /*is_tool=*/false}); + } continue; } diff --git a/src/apps/tui/subagent_host.cpp b/src/apps/tui/subagent_host.cpp index 493728cba..ec6b65dac 100644 --- a/src/apps/tui/subagent_host.cpp +++ b/src/apps/tui/subagent_host.cpp @@ -14,13 +14,39 @@ SubagentHost::SubagentHost(Deps deps) : parent_session_id_(std::move(deps.parent_session_id)), publish_tasks_(std::move(deps.publish_tasks)), on_permission_request_(std::move(deps.on_permission_request)), - registry_(std::move(deps.registry_deps)), client_(registry_) {} + // Copied: the mesh service below borrows the same config/expert pointers. + registry_(deps.registry_deps), client_(registry_) { + mesh::MeshAgentService::Deps mesh_deps; + mesh_deps.registry = ®istry_; + mesh_deps.experts = deps.registry_deps.expert_registry; + mesh_deps.config = deps.registry_deps.config; + mesh_deps.config_mutex = deps.registry_deps.config_mutex; + mesh_deps.external_root_id = parent_session_id_; + mesh_deps.external_root_loop = std::move(deps.main_loop); + mesh_ = std::make_shared<mesh::MeshAgentService>(std::move(mesh_deps)); + mesh_->attach(); + // 网状子 agent 创建 / 换出后恢复时登记为后台任务并(重新)订阅事件。标题事件在 + // 订阅之前已发出,侧栏先用 agent 路径(/root/worker)当显示名。 + mesh_->set_on_agent_loaded( + [ref = lifetime_.ref(*this)](const std::string& child_id, const std::string&) { + ref.with([child_id](SubagentHost& host) { + std::string label = child_id; + if (auto entry = host.registry().acquire(child_id); entry && entry->sm) { + const std::string path = entry->sm->current_agent_path(); + if (!path.empty()) label = path; + } + host.on_spawned(child_id, label); + }); + }); +} SubagentHost::~SubagentHost() { shutdown(); } void SubagentHost::shutdown() { std::lock_guard<std::mutex> shutdown_lock(shutdown_mu_); shutting_down_.store(true); + // Drop mesh subscriptions before the children they watch are joined. + if (mesh_) mesh_->shutdown(); // Wake and join children while callback state and the client still exist. registry_.shutdown_all(); lifetime_.revoke(); diff --git a/src/apps/tui/subagent_host.hpp b/src/apps/tui/subagent_host.hpp index 353d625a6..e51d74c9c 100644 --- a/src/apps/tui/subagent_host.hpp +++ b/src/apps/tui/subagent_host.hpp @@ -21,6 +21,7 @@ // PostEvent)。本文件不依赖 FTXUI,编译进 acecode_testable 供单测。 #include "session_host/local_session_client.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" #include "session_host/session_registry.hpp" #include "session/scoped_subscription.hpp" #include "utils/lifetime_token.hpp" @@ -56,6 +57,9 @@ class SubagentHost { std::function<void(const std::string& session_id, const std::string& task_title, nlohmann::json payload)> on_permission_request; + // 蜂群模式(网状):TUI 主会话不在 registry 里,是 agent 树的外部根。 + // 返回的指针只在一次调用内借用。 + std::function<AgentLoop*()> main_loop; }; explicit SubagentHost(Deps deps); @@ -64,6 +68,8 @@ class SubagentHost { SessionRegistry& registry() { return registry_; } LocalSessionClient& client() { return client_; } + // 网状 agent 树服务;shutdown 前一直可用,工具只持有 weak_ptr。 + std::shared_ptr<mesh::MeshAgentService> mesh() { return mesh_; } // spawn_subagent 工具的 on_spawn 回调实现。 void on_spawned(const std::string& child_id, const std::string& prompt); @@ -111,6 +117,7 @@ class SubagentHost { std::vector<SubagentTaskSnapshot> running_; SessionRegistry registry_; LocalSessionClient client_; + std::shared_ptr<mesh::MeshAgentService> mesh_; std::unordered_map<std::string, ScopedSubscription> subscriptions_; LifetimeToken lifetime_; }; diff --git a/src/apps/web/handlers/builtin_command_handler.cpp b/src/apps/web/handlers/builtin_command_handler.cpp index f0e118e59..d44ce15bb 100644 --- a/src/apps/web/handlers/builtin_command_handler.cpp +++ b/src/apps/web/handlers/builtin_command_handler.cpp @@ -78,8 +78,8 @@ bool is_supported_builtin_command(const std::string& name) { // command handler 由 daemon 宿主(worker.cpp 注册的 SessionChannelBinder) // 承接;这里只负责放行 HTTP 网关。 return name == "init" || name == "compact" || name == "goal" || name == "plan" || - name == "lsp" || name == "sandbox" || name == "memory" || name == "rc" || - name == "remote-control"; + name == "lsp" || name == "sandbox" || name == "memory" || name == "swarm" || + name == "rc" || name == "remote-control"; } BuiltinCommandHttpParseResult parse_builtin_command_request(const std::string& body) { diff --git a/src/apps/web/handlers/commands_handler.cpp b/src/apps/web/handlers/commands_handler.cpp index a2f657f4d..6e084e22a 100644 --- a/src/apps/web/handlers/commands_handler.cpp +++ b/src/apps/web/handlers/commands_handler.cpp @@ -83,6 +83,10 @@ nlohmann::json build_commands_payload(const SkillRegistry& global_skills, {"name", "memory"}, {"description", "List, view, edit or forget memory; flush memory summarization; turn memory off/on for this session"}, }); + builtins.push_back({ + {"name", "swarm"}, + {"description", "Show or switch swarm mode: /swarm star|mesh|off"}, + }); builtins.push_back({ {"name", "rc"}, {"description", "Alias for /remote-control"}, diff --git a/src/apps/web/routes/routes_sessions.cpp b/src/apps/web/routes/routes_sessions.cpp index 912aaaab9..8f24df5db 100644 --- a/src/apps/web/routes/routes_sessions.cpp +++ b/src/apps/web/routes/routes_sessions.cpp @@ -202,7 +202,6 @@ WebServer::Impl::parse_session_user_input_request( std::string composer_text; bool leads_with_pasted_text = false; std::vector<SessionReferenceDescriptor> session_references; - bool swarm_mode = false; try { auto payload = json::parse(body); @@ -245,12 +244,17 @@ WebServer::Impl::parse_session_user_input_request( // string. Preserve that compatibility projection when supplied. if (session_references.empty() || !payload.contains("text")) text = composer_text; } - if (payload.contains("swarm_mode")) { - if (!payload["swarm_mode"].is_boolean()) { - result.error = "swarm_mode must be a boolean"; + if (payload.contains("swarm_mode") && !payload["swarm_mode"].is_null()) { + const auto& value = payload["swarm_mode"]; + if (value.is_boolean()) { + result.swarm_mode = value.get<bool>() ? SwarmMode::Star : SwarmMode::Off; + } else if (value.is_string() && + (value == "star" || value == "mesh" || value == "off")) { + result.swarm_mode = parse_swarm_mode(value.get<std::string>()); + } else { + result.error = R"(swarm_mode must be "star", "mesh" or false)"; return result; } - swarm_mode = payload["swarm_mode"].get<bool>(); } if (payload.contains("client_message_id") && payload["client_message_id"].is_string()) { @@ -513,8 +517,8 @@ WebServer::Impl::parse_session_user_input_request( if (!client_message_id.empty()) { result.input.metadata["client_message_id"] = client_message_id; } - if (swarm_mode) { - result.input.metadata["swarm_mode"] = true; + if (result.swarm_mode) { + result.input.metadata["swarm_mode"] = swarm_mode_name(*result.swarm_mode); } json verified_attachment_records = json::array(); if (session_references_expanded) { @@ -2109,6 +2113,28 @@ void WebServer::Impl::register_sessions() { } // 数据目录迁移期间不接新回合(复制中写会话会丢数据)。 if (auto rej = reject_if_migrating(req)) return std::move(*rej); + // 网状子 agent 只接受 agent_* 协作工具的投递(Codex:direct input is not + // allowed for multi-agent v2 sub-agents)。 + if (deps.session_registry) { + if (auto target = deps.session_registry->acquire(id); + target && target->sm && !target->sm->current_agent_path().empty()) { + crow::response r(409); + r.body = R"({"error":"direct input is not allowed for mesh swarm sub-agents"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + } + // 蜂群模式是会话级状态:随消息提交写入,下一回合起生效。网状树内 + // 仍有运行中的子 agent 时拒绝退出网状(409)。 + if (parsed.swarm_mode && deps.session_registry) { + std::string swarm_error; + if (!deps.session_registry->set_swarm_mode(id, *parsed.swarm_mode, &swarm_error)) { + crow::response r(swarm_error == "unknown session" ? 404 : 409); + r.body = json{{"error", swarm_error}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + } bool ok = deps.session_client->send_input(id, parsed.input); if (!ok) { diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 2f9e0f7e1..9f09de8bf 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -898,6 +898,10 @@ json WebServer::Impl::session_info_to_json(const SessionInfo& s, const SessionMe o["parent_session_id"] = !s.parent_session_id.empty() ? s.parent_session_id : (m ? m->parent_session_id : std::string{}); + o["swarm_mode"] = s.active ? s.swarm_mode + : (m && !m->swarm_mode.empty() ? m->swarm_mode : std::string("off")); + o["agent_path"] = !s.agent_path.empty() ? s.agent_path + : (m ? m->agent_path : std::string{}); const std::string expert_id = !s.expert_id.empty() ? s.expert_id : (m ? m->expert_id : std::string{}); const std::string expert_member_id = !s.expert_member_id.empty() @@ -968,6 +972,8 @@ json WebServer::Impl::session_meta_to_json(const SessionMeta& m, const std::stri } o["archived"] = m.archived; o["parent_session_id"] = m.parent_session_id; + o["swarm_mode"] = m.swarm_mode.empty() ? std::string("off") : m.swarm_mode; + o["agent_path"] = m.agent_path; o["expert_id"] = m.expert_id; o["expert_member_id"] = m.expert_member_id; if (!m.expert_id.empty()) { @@ -1024,6 +1030,12 @@ void WebServer::Impl::append_session_runtime_snapshot(json& wrapper, : entry->sm->current_title_source(); const std::string live_summary = entry->sm->current_summary(); wrapper["summary"] = !live_summary.empty() ? live_summary : meta.summary; + // 会话级蜂群模式(add-mesh-swarm-mode):输入框芯片的初值。 + const std::string swarm = entry->sm->current_swarm_mode(); + wrapper["swarm_mode"] = swarm.empty() ? std::string("off") : swarm; + if (const std::string path = entry->sm->current_agent_path(); !path.empty()) { + wrapper["agent_path"] = path; + } wrapper["turn_count"] = entry->sm->current_turn_count(); wrapper["permission_mode"] = entry->sm->current_permission_mode(); wrapper["token_usage"] = token_usage_or_null(entry->sm->current_last_token_usage()); @@ -1062,6 +1074,10 @@ void WebServer::Impl::append_session_runtime_snapshot(json& wrapper, wrapper["title_source"] = meta.title_source; } if (!wrapper.contains("summary")) wrapper["summary"] = meta.summary; + if (!wrapper.contains("swarm_mode")) { + wrapper["swarm_mode"] = meta.swarm_mode.empty() ? std::string("off") : meta.swarm_mode; + if (!meta.agent_path.empty()) wrapper["agent_path"] = meta.agent_path; + } if (!wrapper.contains("turn_count")) wrapper["turn_count"] = meta.turn_count; if (!wrapper.contains("permission_mode")) { wrapper["permission_mode"] = meta.permission_mode.empty() ? "default" : meta.permission_mode; @@ -2064,6 +2080,10 @@ json WebServer::Impl::attention_payload_for_record( if (auto entry = deps.session_registry->acquire(session_id); entry && !entry->parent_session_id.empty()) { payload["parent_session_id"] = entry->parent_session_id; + if (entry->sm) { + const auto path = entry->sm->display_snapshot().agent_path; + if (!path.empty()) payload["agent_path"] = path; + } } } return payload; @@ -2154,13 +2174,19 @@ void WebServer::Impl::broadcast_remote_control_session_selected( void WebServer::Impl::track_subagent(const std::string& child_id) { if (child_id.empty() || !deps.session_client || !deps.session_registry) return; + // 蜂群模式(网状)会把空闲子 agent 换出(destroy)再按需恢复(resume):旧订阅 + // 随旧 entry 失效,必须换成挂在新 entry 上的订阅,否则恢复后的子 agent 不再 + // 广播 session_status。对已失效的旧订阅退订是 no-op。 + SessionClient::SubscriptionId previous = 0; { std::lock_guard<std::mutex> lk(tracked_subagents_mu); - if (tracked_subagent_subscriptions.find(child_id) != - tracked_subagent_subscriptions.end()) { - return; + const auto existing = tracked_subagent_subscriptions.find(child_id); + if (existing != tracked_subagent_subscriptions.end()) { + previous = existing->second; + tracked_subagent_subscriptions.erase(existing); } } + if (previous != 0) deps.session_client->unsubscribe(child_id, previous); std::string ws_hash; std::string cwd; if (auto e = deps.session_registry->acquire(child_id)) { diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index 7d83811bb..c6fbde8a1 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -164,6 +164,9 @@ struct ParsedSessionUserInputRequest { std::string expected_turn_id; // 提问插话路由专用:body.request_id,对应 question_request.request_id。 std::string question_request_id; + // body.swarm_mode:"star" | "mesh" | "off" | false,旧客户端的 true 等价 star。 + // 缺省 = 沿用会话当前模式。 + std::optional<SwarmMode> swarm_mode; }; // Where a home (workspace) draft lives. draft_dir holds input_draft.json; diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 222b08a19..e0d4449b0 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -944,6 +944,7 @@ static AppConfig load_config_from_path_once( } } if (j.contains("memory")) load_memory_config_json(j["memory"], cfg.memory); + if (j.contains("swarm")) load_swarm_config_json(j["swarm"], cfg.swarm); if (j.contains("project_instructions") && j["project_instructions"].is_object()) { const auto& pj = j["project_instructions"]; if (pj.contains("enabled") && pj["enabled"].is_boolean()) @@ -2319,6 +2320,7 @@ nlohmann::json build_config_json(const AppConfig& cfg) { if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); if (auto memj = memory_config_to_json(cfg.memory); !memj.empty()) j["memory"] = std::move(memj); + if (auto swj = swarm_config_to_json(cfg.swarm); !swj.empty()) j["swarm"] = std::move(swj); ProjectInstructionsConfig pi_d; nlohmann::json pij = nlohmann::json::object(); diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index 0f02882a2..3b0fdfa57 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -104,6 +104,22 @@ struct MemoryConfig { void load_memory_config_json(const nlohmann::json& j, MemoryConfig& out); nlohmann::json memory_config_to_json(const MemoryConfig& cfg); +// 蜂群模式(网状)调优(swarm_config.cpp,默认值对齐 Codex MultiAgentV2Config)。 +struct MeshSwarmConfig { + // 每棵 agent 树同时驻留的 agent 数(含 root),[2, 64]。 + int max_concurrent_agents = 4; + int min_wait_timeout_ms = 10000; // agent_wait 下限,[1000, 600000] + int default_wait_timeout_ms = 30000; // 夹在 [min, max] 内 + int max_wait_timeout_ms = 3600000; // [min, 86400000] + // agent_spawn 暴露 model / reasoning_effort 覆盖参数。 + bool expose_model_overrides = true; +}; +struct SwarmConfig { + MeshSwarmConfig mesh; +}; +void load_swarm_config_json(const nlohmann::json& j, SwarmConfig& out); +nlohmann::json swarm_config_to_json(const SwarmConfig& cfg); + struct ProjectInstructionsConfig { bool enabled = true; int max_depth = 8; // max dirs walked from cwd towards HOME @@ -581,6 +597,7 @@ struct AppConfig { std::map<std::string, McpServerConfig> mcp_servers; // MCP stdio servers (optional) SkillsConfig skills; // skill system configuration (optional) MemoryConfig memory; // persistent user memory settings + SwarmConfig swarm; // 蜂群模式(网状)调优 ProjectInstructionsConfig project_instructions; // AGENT.md / AGENTS.md / CLAUDE.md loader CustomInstructionsConfig custom_instructions; // Desktop/Web user-authored prompt context std::vector<ConnectorConfig> connectors; // user-configured desktop connectors diff --git a/src/base/config/swarm_config.cpp b/src/base/config/swarm_config.cpp new file mode 100644 index 000000000..8c7dc2a3a --- /dev/null +++ b/src/base/config/swarm_config.cpp @@ -0,0 +1,57 @@ +#include "config.hpp" + +#include <algorithm> + +namespace acecode { + +namespace { + +int clamp_int(int value, int lo, int hi) { + return std::max(lo, std::min(hi, value)); +} + +void read_int(const nlohmann::json& j, const char* key, int& out) { + if (j.contains(key) && j[key].is_number_integer()) out = j[key].get<int>(); +} + +} // namespace + +void load_swarm_config_json(const nlohmann::json& j, SwarmConfig& out) { + if (!j.is_object() || !j.contains("mesh") || !j["mesh"].is_object()) return; + const auto& mj = j["mesh"]; + auto& mesh = out.mesh; + read_int(mj, "max_concurrent_agents", mesh.max_concurrent_agents); + read_int(mj, "min_wait_timeout_ms", mesh.min_wait_timeout_ms); + read_int(mj, "default_wait_timeout_ms", mesh.default_wait_timeout_ms); + read_int(mj, "max_wait_timeout_ms", mesh.max_wait_timeout_ms); + if (mj.contains("expose_model_overrides") && mj["expose_model_overrides"].is_boolean()) { + mesh.expose_model_overrides = mj["expose_model_overrides"].get<bool>(); + } + // 越界静默夹取:这些只是调优值,不值得阻塞启动。 + mesh.max_concurrent_agents = clamp_int(mesh.max_concurrent_agents, 2, 64); + mesh.min_wait_timeout_ms = clamp_int(mesh.min_wait_timeout_ms, 1000, 600000); + mesh.max_wait_timeout_ms = + clamp_int(mesh.max_wait_timeout_ms, mesh.min_wait_timeout_ms, 86400000); + mesh.default_wait_timeout_ms = clamp_int(mesh.default_wait_timeout_ms, + mesh.min_wait_timeout_ms, mesh.max_wait_timeout_ms); +} + +nlohmann::json swarm_config_to_json(const SwarmConfig& cfg) { + const MeshSwarmConfig defaults; + nlohmann::json mesh = nlohmann::json::object(); + if (cfg.mesh.max_concurrent_agents != defaults.max_concurrent_agents) + mesh["max_concurrent_agents"] = cfg.mesh.max_concurrent_agents; + if (cfg.mesh.min_wait_timeout_ms != defaults.min_wait_timeout_ms) + mesh["min_wait_timeout_ms"] = cfg.mesh.min_wait_timeout_ms; + if (cfg.mesh.default_wait_timeout_ms != defaults.default_wait_timeout_ms) + mesh["default_wait_timeout_ms"] = cfg.mesh.default_wait_timeout_ms; + if (cfg.mesh.max_wait_timeout_ms != defaults.max_wait_timeout_ms) + mesh["max_wait_timeout_ms"] = cfg.mesh.max_wait_timeout_ms; + if (cfg.mesh.expose_model_overrides != defaults.expose_model_overrides) + mesh["expose_model_overrides"] = cfg.mesh.expose_model_overrides; + nlohmann::json out = nlohmann::json::object(); + if (!mesh.empty()) out["mesh"] = std::move(mesh); + return out; +} + +} // namespace acecode diff --git a/src/domain/session/agent_fork_history.cpp b/src/domain/session/agent_fork_history.cpp new file mode 100644 index 000000000..6db1dd970 --- /dev/null +++ b/src/domain/session/agent_fork_history.cpp @@ -0,0 +1,98 @@ +#include "agent_fork_history.hpp" + +#include "inter_agent_message.hpp" +#include "llm/message_predicates.hpp" + +#include <algorithm> +#include <cctype> + +namespace acecode::mesh { + +namespace { + +constexpr const char* kForkTurnsError = + "fork_turns must be `none`, `all`, or a positive integer string"; + +std::string trim_lower(const std::string& text) { + std::size_t begin = 0; + std::size_t end = text.size(); + while (begin < end && std::isspace(static_cast<unsigned char>(text[begin]))) ++begin; + while (end > begin && std::isspace(static_cast<unsigned char>(text[end - 1]))) --end; + std::string out = text.substr(begin, end - begin); + for (auto& ch : out) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch))); + return out; +} + +bool keep_for_fork(const ChatMessage& message) { + if (message.role == "user") { + if (is_inter_agent_message(message)) return false; + return is_real_user_message(message) || is_compact_summary_message(message); + } + if (message.role == "assistant") { + const bool has_tool_calls = !message.tool_calls.is_null() && !message.tool_calls.empty(); + return !has_tool_calls && !message.content.empty(); + } + return false; +} + +// Codex fork-turn boundaries: real user messages and NEW_TASK (trigger) mail. +bool starts_fork_turn(const ChatMessage& message) { + if (message.role != "user") return false; + if (const auto envelope = inter_agent_envelope_from_metadata(message.metadata)) { + return envelope->type == InterAgentMessageType::NewTask; + } + return is_real_user_message(message); +} + +} // namespace + +std::optional<ForkTurns> parse_fork_turns(const std::string& text, std::string* error) { + const std::string value = trim_lower(text); + ForkTurns fork; + if (value.empty() || value == "all") return fork; + if (value == "none") { + fork.kind = ForkTurns::Kind::None; + return fork; + } + const bool digits = std::all_of(value.begin(), value.end(), + [](char ch) { return ch >= '0' && ch <= '9'; }); + std::size_t parsed = 0; + if (digits && value.size() <= 9) parsed = static_cast<std::size_t>(std::stoul(value)); + if (!digits || parsed == 0) { + if (error) *error = kForkTurnsError; + return std::nullopt; + } + fork.kind = ForkTurns::Kind::LastN; + fork.last_n = parsed; + return fork; +} + +std::vector<ChatMessage> build_fork_history(const std::vector<ChatMessage>& effective_history, + const ForkTurns& fork) { + std::vector<ChatMessage> kept; + if (fork.kind == ForkTurns::Kind::None) return kept; + std::size_t begin = 0; + if (fork.kind == ForkTurns::Kind::LastN) { + // Truncate first, then filter: with fewer than N boundaries keep from + // the first one; with none the fork is empty. + std::vector<std::size_t> starts; + for (std::size_t i = 0; i < effective_history.size(); ++i) { + if (starts_fork_turn(effective_history[i])) starts.push_back(i); + } + if (starts.empty()) return kept; + begin = starts.size() > fork.last_n ? starts[starts.size() - fork.last_n] : starts.front(); + } + for (std::size_t i = begin; i < effective_history.size(); ++i) { + const auto& message = effective_history[i]; + if (!keep_for_fork(message)) continue; + ChatMessage copy = message; + copy.reasoning_content.clear(); + copy.display_override.clear(); + if (!copy.metadata.is_object()) copy.metadata = nlohmann::json::object(); + copy.metadata["mesh_inherited"] = true; + kept.push_back(std::move(copy)); + } + return kept; +} + +} // namespace acecode::mesh diff --git a/src/domain/session/agent_fork_history.hpp b/src/domain/session/agent_fork_history.hpp new file mode 100644 index 000000000..0df27a98e --- /dev/null +++ b/src/domain/session/agent_fork_history.hpp @@ -0,0 +1,33 @@ +#pragma once + +// fork_turns for agent_spawn (Codex SpawnAgentArgs::fork_mode and +// keep_forked_rollout_item). The input is the parent's effective model history +// (reconstruct_effective_model_history: compaction replacement + suffix). The +// child keeps user messages, compaction summaries and assistant final answers; +// tool calls/results, reasoning, internal context and inter-agent envelopes +// are dropped. + +#include "llm/llm_provider.hpp" + +#include <cstddef> +#include <optional> +#include <string> +#include <vector> + +namespace acecode::mesh { + +struct ForkTurns { + enum class Kind { None, All, LastN }; + Kind kind = Kind::All; + std::size_t last_n = 0; +}; + +// Empty/whitespace -> all; "none" / "all" (case-insensitive); positive integer +// string -> last N user turns. Anything else -> nullopt with the Codex error. +std::optional<ForkTurns> parse_fork_turns(const std::string& text, std::string* error = nullptr); + +// Inherited messages carry metadata.mesh_inherited = true. +std::vector<ChatMessage> build_fork_history(const std::vector<ChatMessage>& effective_history, + const ForkTurns& fork); + +} // namespace acecode::mesh diff --git a/src/domain/session/inter_agent_message.cpp b/src/domain/session/inter_agent_message.cpp index bf76a914c..587c57445 100644 --- a/src/domain/session/inter_agent_message.cpp +++ b/src/domain/session/inter_agent_message.cpp @@ -64,4 +64,39 @@ bool is_inter_agent_message(const ChatMessage& message) { inter_agent_envelope_from_metadata(message.metadata).has_value(); } +std::string inter_agent_payload_from_content(const std::string& content) { + static const std::string kPayload = "\nPayload:\n"; + static const std::string kClose = "\n</inter_agent_message>"; + const auto start = content.find(kPayload); + if (start == std::string::npos) return {}; + std::string payload = content.substr(start + kPayload.size()); + if (payload.size() >= kClose.size() && + payload.compare(payload.size() - kClose.size(), kClose.size(), kClose) == 0) { + payload.resize(payload.size() - kClose.size()); + } + return payload; +} + +std::string inter_agent_display_text(const ChatMessage& message) { + if (message.role != "user") return {}; + const auto envelope = inter_agent_envelope_from_metadata(message.metadata); + if (!envelope) return {}; + std::string header; + switch (envelope->type) { + case InterAgentMessageType::NewTask: + header = "Task from " + envelope->sender + " to " + envelope->recipient; + break; + case InterAgentMessageType::Message: + header = "Message from " + envelope->sender + " to " + envelope->recipient; + break; + case InterAgentMessageType::FinalAnswer: + header = "Agent " + envelope->sender + " " + + (envelope->final_status.empty() ? std::string("finished") + : envelope->final_status); + break; + } + const std::string payload = inter_agent_payload_from_content(message.content); + return payload.empty() ? header : header + ":\n" + payload; +} + } // namespace acecode::mesh diff --git a/src/domain/session/inter_agent_message.hpp b/src/domain/session/inter_agent_message.hpp index f04fa6b22..a1642a68c 100644 --- a/src/domain/session/inter_agent_message.hpp +++ b/src/domain/session/inter_agent_message.hpp @@ -44,4 +44,11 @@ std::optional<InterAgentEnvelope> inter_agent_envelope_from_metadata( const nlohmann::json& metadata); bool is_inter_agent_message(const ChatMessage& message); +// The Payload section of a rendered envelope (model content); empty when the +// content is not an envelope body. +std::string inter_agent_payload_from_content(const std::string& content); +// One-line header plus payload for transcript surfaces (TUI rows); never sent +// to the model. Empty for ordinary messages. +std::string inter_agent_display_text(const ChatMessage& message); + } // namespace acecode::mesh diff --git a/src/domain/session/mesh_tree_index.cpp b/src/domain/session/mesh_tree_index.cpp new file mode 100644 index 000000000..9c438c091 --- /dev/null +++ b/src/domain/session/mesh_tree_index.cpp @@ -0,0 +1,63 @@ +#include "mesh_tree_index.hpp" + +#include "agent_path.hpp" +#include "utils/atomic_file.hpp" +#include "utils/utf8_path.hpp" + +#include <nlohmann/json.hpp> + +#include <filesystem> +#include <fstream> +#include <iterator> + +namespace acecode::mesh { + +std::string mesh_tree_index_path(const std::string& project_dir, const std::string& root_id) { + return path_to_utf8(path_from_utf8(project_dir) / path_from_utf8(root_id) / + "mesh_agents.json"); +} + +std::vector<MeshTreeIndexEntry> read_mesh_tree_index(const std::string& project_dir, + const std::string& root_id) { + std::vector<MeshTreeIndexEntry> entries; + if (project_dir.empty() || root_id.empty()) return entries; + std::ifstream in(path_from_utf8(mesh_tree_index_path(project_dir, root_id)), + std::ios::binary); + if (!in.is_open()) return entries; + try { + const std::string text((std::istreambuf_iterator<char>(in)), + std::istreambuf_iterator<char>()); + const auto json = nlohmann::json::parse(text); + if (!json.is_object() || !json.contains("agents") || !json["agents"].is_array()) { + return entries; + } + for (const auto& item : json["agents"]) { + if (!item.is_object()) continue; + MeshTreeIndexEntry entry; + entry.path = item.value("path", std::string{}); + entry.session_id = item.value("session_id", std::string{}); + const auto path = AgentPath::parse(entry.path); + if (!path || path->is_root() || entry.session_id.empty()) continue; + entries.push_back(std::move(entry)); + } + } catch (...) { + entries.clear(); + } + return entries; +} + +bool write_mesh_tree_index(const std::string& project_dir, const std::string& root_id, + const std::vector<MeshTreeIndexEntry>& entries) { + if (project_dir.empty() || root_id.empty()) return false; + nlohmann::json agents = nlohmann::json::array(); + for (const auto& entry : entries) { + agents.push_back({{"path", entry.path}, {"session_id", entry.session_id}}); + } + const nlohmann::json json = {{"version", 1}, {"agents", std::move(agents)}}; + const auto path = mesh_tree_index_path(project_dir, root_id); + std::error_code ec; + std::filesystem::create_directories(path_from_utf8(path).parent_path(), ec); + return atomic_write_file(path, json.dump(2) + '\n'); +} + +} // namespace acecode::mesh diff --git a/src/domain/session/mesh_tree_index.hpp b/src/domain/session/mesh_tree_index.hpp new file mode 100644 index 000000000..e5593133c --- /dev/null +++ b/src/domain/session/mesh_tree_index.hpp @@ -0,0 +1,25 @@ +#pragma once + +// Persistent directory of a mesh agent tree: canonical path -> session id for +// every spawned agent of one root. Stored in the root session's data directory +// (<project_dir>/<root_id>/mesh_agents.json), so purging the root removes it and +// a restarted daemon rebuilds the tree without scanning every session meta. + +#include <string> +#include <vector> + +namespace acecode::mesh { + +struct MeshTreeIndexEntry { + std::string path; + std::string session_id; +}; + +std::string mesh_tree_index_path(const std::string& project_dir, const std::string& root_id); +// Missing or unreadable index -> empty list. Malformed entries are skipped. +std::vector<MeshTreeIndexEntry> read_mesh_tree_index(const std::string& project_dir, + const std::string& root_id); +bool write_mesh_tree_index(const std::string& project_dir, const std::string& root_id, + const std::vector<MeshTreeIndexEntry>& entries); + +} // namespace acecode::mesh diff --git a/src/domain/session/session_client.hpp b/src/domain/session/session_client.hpp index f05f4b45b..85b36688a 100644 --- a/src/domain/session/session_client.hpp +++ b/src/domain/session/session_client.hpp @@ -191,6 +191,12 @@ struct SessionOptions { // 从常规列表隐藏、归入父会话的「后台任务」面板;daemon 重启后依然生效。 std::string parent_session_id; + // 蜂群模式 off / star / mesh;空 = create 时 off、resume 时沿用 meta。 + std::string swarm_mode; + // 网状子 agent 的 canonical 路径(agent_spawn 派生时填,parent_session_id + // 此时恒为树根会话 id);根会话与非网状会话为空。 + std::string agent_path; + // 可选调用方自定的新会话 id(headless -p --session-id:脚本免解析 // stdout 即可确定性地 --resume)。留空 = registry 自动生成。调用方 // 负责保证 id 文件名安全且未被占用;registry 不做碰撞检查。 @@ -261,6 +267,8 @@ struct SessionInfo { bool busy = false; // 是否正在处理当前轮 bool no_workspace = false; std::string parent_session_id; // 非空 = spawn_subagent 子会话(后台任务) + std::string swarm_mode = "off"; // off | star | mesh + std::string agent_path; // 网状子 agent 的 canonical 路径 std::string active_turn_id; // 非空 = 当前可接受 steering 的 regular turn std::string expert_id; std::string expert_member_id; diff --git a/src/domain/session/session_manager.hpp b/src/domain/session/session_manager.hpp index 7d2386b60..695bc24a5 100644 --- a/src/domain/session/session_manager.hpp +++ b/src/domain/session/session_manager.hpp @@ -31,6 +31,8 @@ struct SessionDisplaySnapshot { TokenUsage last_token_usage; TokenUsage session_token_usage; WorktreeSessionInfo worktree; + std::string swarm_mode; // "" = off + std::string agent_path; // mesh child path; empty for roots }; class SessionManager { @@ -216,6 +218,10 @@ class SessionManager { void set_mesh_agent_path(std::string agent_path); std::string current_swarm_mode() const; std::string current_agent_path() const; + // Session cwd (workspace or no-workspace cache dir) and the session-local + // reasoning choice; mesh children inherit both from their spawner. + std::string current_cwd() const; + std::optional<std::string> current_reasoning_effort() const; // Persist the current expert identity selected for this session. void set_expert_binding(std::string expert_id, std::string member_id = {}); diff --git a/src/domain/session/session_manager_agent.cpp b/src/domain/session/session_manager_agent.cpp index cc0d54c39..456447390 100644 --- a/src/domain/session/session_manager_agent.cpp +++ b/src/domain/session/session_manager_agent.cpp @@ -9,6 +9,7 @@ void SessionManager::set_swarm_mode(std::string swarm_mode) { if (swarm_mode == "off") swarm_mode.clear(); if (swarm_mode_ == swarm_mode) return; swarm_mode_ = std::move(swarm_mode); + publish_display_snapshot_locked(); if (created_) update_meta(); } @@ -16,6 +17,7 @@ void SessionManager::set_mesh_agent_path(std::string agent_path) { std::lock_guard<std::mutex> lk(mu_); if (agent_path_ == agent_path) return; agent_path_ = std::move(agent_path); + publish_display_snapshot_locked(); if (created_) update_meta(); } @@ -29,6 +31,16 @@ std::string SessionManager::current_agent_path() const { return agent_path_; } +std::string SessionManager::current_cwd() const { + std::lock_guard<std::mutex> lk(mu_); + return cwd_; +} + +std::optional<std::string> SessionManager::current_reasoning_effort() const { + std::lock_guard<std::mutex> lk(mu_); + return reasoning_effort_; +} + void SessionManager::write_swarm_identity_locked(SessionMeta& meta) const { meta.swarm_mode = swarm_mode_; meta.agent_path = agent_path_; diff --git a/src/domain/session/session_manager_history.cpp b/src/domain/session/session_manager_history.cpp index 5d68637cf..a7529f385 100644 --- a/src/domain/session/session_manager_history.cpp +++ b/src/domain/session/session_manager_history.cpp @@ -9,7 +9,7 @@ namespace fs = std::filesystem; void SessionManager::publish_display_snapshot_locked() { SessionDisplaySnapshot snapshot{ pending_title_, title_source_, last_user_summary_, turn_count_, - last_token_usage_, session_token_usage_, worktree_, + last_token_usage_, session_token_usage_, worktree_, swarm_mode_, agent_path_, }; std::lock_guard<std::mutex> lock(display_mu_); display_snapshot_ = std::move(snapshot); diff --git a/src/engine/agent/request/agent_request.cpp b/src/engine/agent/request/agent_request.cpp index f11a63aa5..a19c571b8 100644 --- a/src/engine/agent/request/agent_request.cpp +++ b/src/engine/agent/request/agent_request.cpp @@ -34,6 +34,16 @@ agent::RequestContextSource AgentLoop::capture_request_source() const { // The provider may acquire the host AppConfig lock. Never call it while // holding this loop's publication mutex. if (prompt_config_provider_) snapshot.prompt_config = prompt_config_provider_(); + if (session_manager_) { + // 蜂群模式与网状路径以会话元数据为准(/swarm、消息接口、resume 只改它)。 + const SwarmMode mode = + parse_swarm_mode(session_manager_->current_swarm_mode()).value_or(SwarmMode::Off); + const std::string path = session_manager_->current_agent_path(); + snapshot.swarm.mode = mode; + snapshot.swarm.agent_path = + mode == SwarmMode::Mesh ? (path.empty() ? std::string("/root") : path) : std::string{}; + snapshot.swarm.mesh.is_root = snapshot.swarm.agent_path == "/root"; + } // 蜂群模式两套协作工具互斥:按本回合捕获的模式隐藏另一套(schema 与执行同一谓词)。 snapshot.tool_policy.hidden_builtin_tools = swarm_mode_hidden_tools(snapshot.swarm.mode); return snapshot; diff --git a/src/engine/agent/request/swarm_context.hpp b/src/engine/agent/request/swarm_context.hpp index cc38e31ea..445fd7ab4 100644 --- a/src/engine/agent/request/swarm_context.hpp +++ b/src/engine/agent/request/swarm_context.hpp @@ -1,5 +1,6 @@ #pragma once +#include "config/config.hpp" #include "prompt/mesh_swarm_prompts.hpp" #include "session/swarm_mode.hpp" @@ -7,9 +8,11 @@ namespace acecode::agent { -// Session swarm mode plus the mesh identity the request builder needs. The -// session host publishes it; each turn captures one value with the rest of -// RequestContextSource, so a mode switch never changes a running turn. +// Session swarm mode plus the mesh identity the request builder needs. A turn +// captures one value with the rest of RequestContextSource, so a mode switch +// never changes a running turn. When the loop has a SessionManager, mode and +// agent path are read from it at capture (session metadata is the source of +// truth); the published value then only supplies the mesh prompt options. struct SwarmContext { SwarmMode mode = SwarmMode::Off; // Canonical mesh path; "/root" for a mesh root, empty when not in mesh. @@ -17,4 +20,17 @@ struct SwarmContext { MeshSwarmPromptOptions mesh; }; +inline SwarmContext make_swarm_context(SwarmMode mode, const std::string& agent_path, + const MeshSwarmConfig& config) { + SwarmContext context; + context.mode = mode; + context.mesh.max_concurrency = config.max_concurrent_agents; + context.mesh.expose_model_overrides = config.expose_model_overrides; + if (mode == SwarmMode::Mesh) { + context.agent_path = agent_path.empty() ? std::string("/root") : agent_path; + context.mesh.is_root = context.agent_path == "/root"; + } + return context; +} + } // namespace acecode::agent diff --git a/src/host/session_host/mesh/mesh_agent_events.cpp b/src/host/session_host/mesh/mesh_agent_events.cpp new file mode 100644 index 000000000..6a3671769 --- /dev/null +++ b/src/host/session_host/mesh/mesh_agent_events.cpp @@ -0,0 +1,141 @@ +// MeshAgentService 的子 agent 事件:状态跟踪与完成回报(Codex +// notify_parent_of_terminal_turn)。监听器跑在子 agent 的 worker 线程上,只捕获 +// weak_ptr 与值(C5);持有服务锁时只做叶子操作(投信入箱),从不 join。 +#include "mesh_agent_state.hpp" + +#include "agent/agent_loop.hpp" +#include "session_host/session_registry.hpp" +#include "utils/logger.hpp" +#include "utils/encoding.hpp" + +namespace acecode::mesh { + +namespace { + +// The final answer of the turn that just ended: the last assistant reply +// without tool calls after the turn's opening input (a real user message or a +// NEW_TASK envelope). Mid-turn MESSAGE / FINAL_ANSWER envelopes do not open a +// turn. Empty when the turn ended without a text answer (Codex Completed(None)). +std::optional<std::string> turn_final_answer(const std::vector<ChatMessage>& history) { + for (auto it = history.rbegin(); it != history.rend(); ++it) { + if (it->role == "assistant") { + const bool has_tool_calls = !it->tool_calls.is_null() && !it->tool_calls.empty(); + if (!has_tool_calls && !it->content.empty()) return it->content; + continue; + } + if (it->role != "user") continue; + const auto envelope = inter_agent_envelope_from_metadata(it->metadata); + if (envelope && envelope->type != InterAgentMessageType::NewTask) continue; + const bool internal = it->metadata.is_object() && + (it->metadata.value("hidden_goal_context", false) || + it->metadata.value("transcript_only", false)); + if (!internal) break; + } + return std::nullopt; +} + +} // namespace + +void MeshAgentService::subscribe_child(const std::string& root_id, const std::string& session_id) { + const std::weak_ptr<MeshAgentService> weak = weak_from_this(); + const auto id = client_.subscribe(session_id, + [weak, root_id, session_id](const SessionEvent& event) { + if (auto self = weak.lock()) self->on_child_event(root_id, session_id, event); + }); + if (id == 0) return; + ScopedSubscription subscription(client_, session_id, id); + ScopedSubscription previous; + { + std::lock_guard<std::mutex> lock(mu_); + auto tree_it = trees_.find(root_id); + if (tree_it != trees_.end() && !stopped_.load()) { + for (auto& [path, record] : tree_it->second->agents) { + if (record.session_id != session_id) continue; + previous = std::move(record.subscription); + record.subscription = std::move(subscription); + break; + } + } + } + // previous / an unused subscription unsubscribe here, outside mu_. +} + +void MeshAgentService::on_child_event(const std::string& root_id, const std::string& session_id, + const SessionEvent& event) { + if (stopped_.load()) return; + if (event.kind == SessionEventKind::BusyChanged) { + if (!event.payload.value("busy", false)) return; + std::lock_guard<std::mutex> lock(mu_); + auto tree_it = trees_.find(root_id); + if (tree_it == trees_.end()) return; + for (auto& [path, record] : tree_it->second->agents) { + if (record.session_id != session_id) continue; + record.started = true; + record.last_outcome.clear(); + } + touch_locked(*tree_it->second, session_id); + return; + } + if (event.kind != SessionEventKind::Done) return; + // Compaction and user shell tasks emit Done without a chat turn id. + const std::string outcome = event.payload.value("outcome", std::string{}); + if (outcome.empty() || !event.payload.contains("turn_id")) return; + route_completion(root_id, session_id, outcome); +} + +void MeshAgentService::route_completion(const std::string& root_id, + const std::string& session_id, + const std::string& outcome) { + std::shared_ptr<SessionEntry> keep; + AgentLoop* child = loop_for(session_id, keep); + std::optional<std::string> final_text; + std::string error_text; + // Done is emitted on the child's own worker, so its history is safe to read. + if (child && outcome == "completed") final_text = turn_final_answer(child->messages()); + if (child && outcome == "error") error_text = child->last_turn_error(); + std::lock_guard<std::mutex> lock(mu_); + auto tree_it = trees_.find(root_id); + if (tree_it == trees_.end()) return; + Tree& tree = *tree_it->second; + Record* record = nullptr; + for (auto& [path, candidate] : tree.agents) { + if (candidate.session_id == session_id) record = &candidate; + } + if (!record) return; + record->started = true; + record->last_outcome = outcome; + record->final_text = final_text; + record->error_text = error_text; + touch_locked(tree, session_id); + // Codex format_inter_agent_completion_message: interrupted turns send nothing. + std::string payload; + if (outcome == "completed") { + payload = final_text.value_or(std::string{}); + } else if (outcome == "error") { + payload = "Agent errored: " + truncate_utf8_prefix(error_text, kCompletionErrorMaxChars) + + "\n\n" + kErrorNextAction; + } else { + return; + } + const auto child_path = AgentPath::parse(record->path); + const auto parent_path = child_path ? child_path->parent() : std::nullopt; + if (!parent_path) return; + UserInput envelope = make_envelope(InterAgentMessageType::FinalAnswer, parent_path->str(), + record->path, session_id, payload, + outcome == "completed" ? "completed" : "errored"); + if (parent_path->is_root()) { + std::shared_ptr<SessionEntry> root_keep; + if (AgentLoop* root = loop_for(root_id, root_keep)) { + root->deliver_inter_agent_message(std::move(envelope), false); + } else { + LOG_WARN("[mesh] root " + root_id + " is not loaded; dropped FINAL_ANSWER from " + + record->path); + } + return; + } + auto parent_it = tree.agents.find(parent_path->str()); + if (parent_it == tree.agents.end()) return; + deliver_envelope_locked(tree, parent_it->second, std::move(envelope), false); +} + +} // namespace acecode::mesh diff --git a/src/host/session_host/mesh/mesh_agent_service.cpp b/src/host/session_host/mesh/mesh_agent_service.cpp new file mode 100644 index 000000000..db0ad524e --- /dev/null +++ b/src/host/session_host/mesh/mesh_agent_service.cpp @@ -0,0 +1,579 @@ +#include "mesh_agent_state.hpp" + +#include "agent/agent_loop.hpp" +#include "config/config.hpp" +#include "session/mesh_tree_index.hpp" +#include "session/session_manager.hpp" +#include "session_host/session_registry.hpp" +#include "tool/tool_executor.hpp" +#include "utils/logger.hpp" + +#include <algorithm> +#include <vector> + +namespace acecode::mesh { + +nlohmann::json agent_status_to_json(const AgentStatus& status) { + switch (status.kind) { + case AgentStatus::Kind::PendingInit: return "pending_init"; + case AgentStatus::Kind::Running: return "running"; + case AgentStatus::Kind::Interrupted: return "interrupted"; + case AgentStatus::Kind::Shutdown: return "shutdown"; + case AgentStatus::Kind::Completed: + return nlohmann::json{{"completed", status.completed + ? nlohmann::json(*status.completed) : nlohmann::json(nullptr)}}; + case AgentStatus::Kind::Errored: + return nlohmann::json{{"errored", status.errored}}; + case AgentStatus::Kind::NotFound: break; + } + return "not_found"; +} + +std::string agent_status_label(const AgentStatus& status) { + switch (status.kind) { + case AgentStatus::Kind::PendingInit: return "pending_init"; + case AgentStatus::Kind::Running: return "running"; + case AgentStatus::Kind::Interrupted: return "interrupted"; + case AgentStatus::Kind::Completed: return "completed"; + case AgentStatus::Kind::Errored: return "errored"; + case AgentStatus::Kind::Shutdown: return "shutdown"; + case AgentStatus::Kind::NotFound: break; + } + return "not_found"; +} + +MeshAgentService::MeshAgentService(Deps deps) + : deps_(std::move(deps)), client_(*deps_.registry) {} + +MeshAgentService::~MeshAgentService() { + shutdown(); +} + +void MeshAgentService::attach() { + const std::weak_ptr<MeshAgentService> weak = weak_from_this(); + deps_.registry->set_swarm_mode_guard( + [weak](const std::string& session_id, SwarmMode from, SwarmMode to) -> std::string { + if (from != SwarmMode::Mesh || to == SwarmMode::Mesh) return {}; + auto self = weak.lock(); + return self ? self->tree_busy_reason(session_id) : std::string{}; + }); +} + +void MeshAgentService::set_on_agent_loaded(AgentLoadedCallback callback) { + std::lock_guard<std::mutex> lock(callback_mu_); + on_agent_loaded_ = std::move(callback); +} + +void MeshAgentService::notify_agent_loaded(const std::string& child_id, + const std::string& root_id) { + AgentLoadedCallback callback; + { + std::lock_guard<std::mutex> lock(callback_mu_); + callback = on_agent_loaded_; + } + if (callback) callback(child_id, root_id); +} + +MeshSwarmConfig MeshAgentService::config_snapshot() const { + if (!deps_.config) return MeshSwarmConfig{}; + std::shared_lock<std::shared_mutex> lock; + if (deps_.config_mutex) lock = std::shared_lock<std::shared_mutex>(*deps_.config_mutex); + return deps_.config->swarm.mesh; +} + +std::vector<std::string> MeshAgentService::saved_model_names() const { + std::vector<std::string> names; + if (!deps_.config) return names; + std::shared_lock<std::shared_mutex> lock; + if (deps_.config_mutex) lock = std::shared_lock<std::shared_mutex>(*deps_.config_mutex); + for (const auto& profile : deps_.config->saved_models) names.push_back(profile.name); + return names; +} + +void MeshAgentService::shutdown() { + if (stopped_.exchange(true)) return; + if (deps_.registry) deps_.registry->set_swarm_mode_guard({}); + set_on_agent_loaded({}); + std::vector<ScopedSubscription> subscriptions; + { + std::lock_guard<std::mutex> lock(mu_); + for (auto& [root, tree] : trees_) { + for (auto& [path, record] : tree->agents) { + subscriptions.push_back(std::move(record.subscription)); + } + } + } + // Waiting for in-flight listeners under mu_ would deadlock them. + subscriptions.clear(); + cv_.notify_all(); +} + +std::optional<MeshAgentService::Caller> MeshAgentService::caller(const ToolContext& ctx, + std::string* error) const { + SessionManager* sm = ctx.session_manager; + if (!sm) { + if (error) *error = "collab manager unavailable"; + return std::nullopt; + } + if (sm->current_swarm_mode() != "mesh") { + if (error) *error = "agent collaboration tools are only available in mesh swarm mode."; + return std::nullopt; + } + Caller caller; + caller.session_id = sm->current_session_id(); + caller.sm = sm; + caller.ctx = &ctx; + caller.project_dir = sm->current_project_dir(); + caller.workspace_cwd = sm->current_cwd(); + caller.no_workspace = sm->is_no_workspace(); + const std::string path = sm->current_agent_path(); + if (path.empty()) { + caller.path = AgentPath::kRoot; + caller.root_id = caller.session_id; + } else { + std::string parse_error; + if (!AgentPath::parse(path, &parse_error)) { + if (error) *error = parse_error; + return std::nullopt; + } + caller.path = path; + caller.root_id = sm->current_parent_session_id(); + } + if (caller.session_id.empty() || caller.root_id.empty()) { + if (error) *error = "collab manager unavailable"; + return std::nullopt; + } + return caller; +} + +MeshAgentService::Tree& MeshAgentService::tree_locked(const Caller& caller) { + auto& slot = trees_[caller.root_id]; + if (slot) return *slot; + slot = std::make_unique<Tree>(); + slot->root_id = caller.root_id; + slot->project_dir = caller.project_dir; + slot->workspace_cwd = caller.workspace_cwd; + slot->no_workspace = caller.no_workspace; + // daemon 重启后按索引重建目录;已在内存里的(TUI 恢复等)标记为驻留。 + for (const auto& entry : read_mesh_tree_index(caller.project_dir, caller.root_id)) { + Record& record = slot->agents[entry.path]; + record.session_id = entry.session_id; + record.path = entry.path; + record.resident = deps_.registry->acquire(entry.session_id) != nullptr; + } + return *slot; +} + +void MeshAgentService::persist_index_locked(const Tree& tree) const { + std::vector<MeshTreeIndexEntry> entries; + for (const auto& [path, record] : tree.agents) { + if (!record.session_id.empty()) entries.push_back({path, record.session_id}); + } + if (!write_mesh_tree_index(tree.project_dir, tree.root_id, entries)) { + LOG_WARN("[mesh] failed to persist agent tree index for root " + tree.root_id); + } +} + +std::string MeshAgentService::resolve_locked(Tree& tree, const Caller& caller, + const std::string& target, Record** out) const { + *out = nullptr; + if (target == tree.root_id) return {}; + for (auto& [path, record] : tree.agents) { + if (!record.session_id.empty() && record.session_id == target) { + *out = &record; + return {}; + } + } + const auto self = AgentPath::parse(caller.path); + std::string error; + const auto resolved = self ? self->resolve(target, &error) : std::nullopt; + if (!resolved) return error.empty() ? std::string("agent path must not be empty") : error; + if (resolved->is_root()) return {}; + const auto it = tree.agents.find(resolved->str()); + if (it == tree.agents.end() || it->second.session_id.empty()) { + return "live agent path `" + resolved->str() + "` not found"; + } + *out = &it->second; + return {}; +} + +AgentLoop* MeshAgentService::loop_for(const std::string& session_id, + std::shared_ptr<SessionEntry>& keep_alive) const { + if (deps_.external_root_id && deps_.external_root_loop && + deps_.external_root_id() == session_id) { + return deps_.external_root_loop(); + } + keep_alive = deps_.registry->acquire(session_id); + return keep_alive && keep_alive->loop ? keep_alive->loop.get() : nullptr; +} + +AgentStatus MeshAgentService::status_locked(const Tree&, const Record& record) const { + AgentStatus status; + if (!record.resident || record.evicting) return status; + std::shared_ptr<SessionEntry> keep; + AgentLoop* loop = loop_for(record.session_id, keep); + if (!loop) return status; + if (loop->is_busy()) { + status.kind = AgentStatus::Kind::Running; + } else if (!record.started) { + status.kind = AgentStatus::Kind::PendingInit; + } else if (record.last_outcome == "error") { + status.kind = AgentStatus::Kind::Errored; + status.errored = record.error_text; + } else if (record.last_outcome == "aborted") { + status.kind = AgentStatus::Kind::Interrupted; + } else if (record.last_outcome == "completed") { + status.kind = AgentStatus::Kind::Completed; + status.completed = record.final_text; + } else { + status.kind = AgentStatus::Kind::Running; + } + return status; +} + +void MeshAgentService::touch_locked(Tree& tree, const std::string& session_id) { + ++activity_clock_; + for (auto& [path, record] : tree.agents) { + if (record.session_id == session_id) record.last_activity = activity_clock_; + } +} + +std::string MeshAgentService::reserve_slot(const std::string& root_id, + const std::string& protected_id) { + while (!stopped_.load()) { + std::string victim_id; + ScopedSubscription victim_subscription; + { + std::lock_guard<std::mutex> lock(mu_); + auto tree_it = trees_.find(root_id); + if (tree_it == trees_.end()) return "collab manager unavailable"; + Tree& tree = *tree_it->second; + const MeshSwarmConfig config = config_snapshot(); + // Codex effective_agent_max_threads(V2): the root holds one slot. + const std::size_t capacity = + static_cast<std::size_t>(std::max(1, config.max_concurrent_agents - 1)); + std::size_t residents = tree.pending_slots; + for (const auto& [path, record] : tree.agents) { + if (record.resident || record.evicting) ++residents; + } + if (residents < capacity) { + ++tree.pending_slots; + return {}; + } + // Codex is_unloadable: finished turn, no active work, empty mailbox. + Record* victim = nullptr; + bool dropped_stale = false; + for (auto& [path, record] : tree.agents) { + if (!record.resident || record.evicting || record.restoring) continue; + std::shared_ptr<SessionEntry> keep; + AgentLoop* loop = loop_for(record.session_id, keep); + if (!loop) { + // Destroyed elsewhere (user purge, shutdown): free its slot. + record.resident = false; + dropped_stale = true; + continue; + } + if (record.session_id == protected_id || !record.held_mail.empty()) continue; + if (!record.started || record.last_outcome.empty()) continue; + if (loop->has_pending_work() || loop->pending_mailbox_count() > 0) continue; + if (!victim || record.last_activity < victim->last_activity) victim = &record; + } + if (dropped_stale) continue; + if (!victim) return kAgentLimitReached; + victim->evicting = true; + victim_id = victim->session_id; + victim_subscription = std::move(victim->subscription); + } + victim_subscription.reset(); + LOG_INFO("[mesh] evicting idle agent " + victim_id + " from tree " + root_id); + deps_.registry->destroy(victim_id); + { + std::lock_guard<std::mutex> lock(mu_); + auto tree_it = trees_.find(root_id); + if (tree_it != trees_.end()) { + for (auto& [path, record] : tree_it->second->agents) { + if (record.session_id != victim_id) continue; + record.evicting = false; + record.resident = false; + } + } + } + cv_.notify_all(); + } + return "collab manager unavailable"; +} + +void MeshAgentService::release_slot(const std::string& root_id) { + std::lock_guard<std::mutex> lock(mu_); + auto it = trees_.find(root_id); + if (it != trees_.end() && it->second->pending_slots > 0) --it->second->pending_slots; +} + +void MeshAgentService::commit_resident(const std::string& root_id, + const std::string& session_id) { + std::lock_guard<std::mutex> lock(mu_); + auto it = trees_.find(root_id); + if (it == trees_.end()) return; + Tree& tree = *it->second; + if (tree.pending_slots > 0) --tree.pending_slots; + for (auto& [path, record] : tree.agents) { + if (record.session_id == session_id) record.resident = true; + } + touch_locked(tree, session_id); +} + +UserInput MeshAgentService::make_envelope(InterAgentMessageType type, const std::string& recipient, + const std::string& sender, + const std::string& sender_session, + const std::string& payload, + const std::string& status) const { + InterAgentEnvelope envelope; + envelope.type = type; + envelope.recipient = recipient; + envelope.sender = sender; + envelope.sender_session_id = sender_session; + envelope.payload = payload; + envelope.final_status = status; + UserInput input; + input.text = render_inter_agent_message(envelope); + input.metadata = nlohmann::json::object(); + input.metadata[kInterAgentMetadataKey] = inter_agent_metadata(envelope); + return input; +} + +bool MeshAgentService::deliver_envelope_locked(Tree& tree, Record& recipient, UserInput envelope, + bool trigger_turn) { + if (!recipient.resident || recipient.evicting) { + recipient.held_mail.emplace_back(std::move(envelope), trigger_turn); + return false; + } + std::shared_ptr<SessionEntry> keep; + AgentLoop* loop = loop_for(recipient.session_id, keep); + if (!loop) { + recipient.resident = false; + recipient.held_mail.emplace_back(std::move(envelope), trigger_turn); + return false; + } + loop->deliver_inter_agent_message(std::move(envelope), trigger_turn); + touch_locked(tree, recipient.session_id); + return true; +} + +std::string MeshAgentService::ensure_loaded(const Caller& caller, const std::string& path) { + std::string session_id; + { + std::unique_lock<std::mutex> lock(mu_); + Tree& tree = tree_locked(caller); + cv_.wait(lock, [&tree, &path, this] { + const auto it = tree.agents.find(path); + return stopped_.load() || it == tree.agents.end() || + (!it->second.evicting && !it->second.restoring); + }); + auto it = tree.agents.find(path); + if (stopped_.load() || it == tree.agents.end()) return "collab manager unavailable"; + Record& record = it->second; + if (record.resident && deps_.registry->acquire(record.session_id)) { + touch_locked(tree, record.session_id); + return {}; + } + record.resident = false; + record.restoring = true; + session_id = record.session_id; + } + auto finish = [this, &caller, &path](bool loaded) { + std::deque<std::pair<UserInput, bool>> held; + { + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(caller); + auto it = tree.agents.find(path); + if (it != tree.agents.end()) { + it->second.restoring = false; + if (loaded) held.swap(it->second.held_mail); + for (auto& [input, trigger] : held) { + deliver_envelope_locked(tree, it->second, std::move(input), trigger); + } + } + } + cv_.notify_all(); + }; + if (auto error = reserve_slot(caller.root_id, caller.session_id); !error.empty()) { + finish(false); + return error == kAgentLimitReached ? "collab tool failed: agent thread limit reached" : error; + } + SessionOptions opts; + opts.cwd = caller.workspace_cwd; + opts.no_workspace = caller.no_workspace; + opts.reuse_no_workspace_cwd = caller.no_workspace; + if (caller.ctx) opts.write_root = caller.ctx->write_root; + bool resumed = false; + try { + resumed = deps_.registry->resume(session_id, opts); + } catch (const std::exception& e) { + LOG_WARN(std::string("[mesh] restoring agent failed: ") + e.what()); + } + if (!resumed) { + release_slot(caller.root_id); + finish(false); + return "collab tool failed: agent `" + path + "` could not be restored"; + } + subscribe_child(caller.root_id, session_id); + commit_resident(caller.root_id, session_id); + LOG_INFO("[mesh] restored agent " + path + " (" + session_id + ")"); + finish(true); + notify_agent_loaded(session_id, caller.root_id); + return {}; +} + +std::string MeshAgentService::deliver(const ToolContext& ctx, const std::string& target, + const std::string& message, bool trigger_turn) { + if (message.find_first_not_of(" \t\r\n") == std::string::npos) { + return "Empty message can't be sent to an agent"; + } + std::string error; + const auto self = caller(ctx, &error); + if (!self) return error; + std::string target_path; + { + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + Record* record = nullptr; + if (auto resolve_error = resolve_locked(tree, *self, target, &record); + !resolve_error.empty()) { + return resolve_error; + } + if (!record) { + if (trigger_turn) return "Follow-up tasks can't target the root agent"; + std::shared_ptr<SessionEntry> keep; + AgentLoop* root = loop_for(tree.root_id, keep); + if (!root) return "collab tool failed: the root agent is not loaded"; + root->deliver_inter_agent_message( + make_envelope(trigger_turn ? InterAgentMessageType::NewTask + : InterAgentMessageType::Message, + AgentPath::kRoot, self->path, self->session_id, message), + false); + return {}; + } + target_path = record->path; + } + if (auto load_error = ensure_loaded(*self, target_path); !load_error.empty()) { + return load_error; + } + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + auto it = tree.agents.find(target_path); + if (it == tree.agents.end()) return "live agent path `" + target_path + "` not found"; + deliver_envelope_locked(tree, it->second, + make_envelope(trigger_turn ? InterAgentMessageType::NewTask : InterAgentMessageType::Message, + target_path, self->path, self->session_id, message), + trigger_turn); + return {}; +} + +std::string MeshAgentService::list(const ToolContext& ctx, const std::string& path_prefix, + std::vector<ListedAgent>& out) { + out.clear(); + std::string error; + const auto self = caller(ctx, &error); + if (!self) return error; + std::optional<AgentPath> prefix; + if (!path_prefix.empty()) { + const auto base = AgentPath::parse(self->path); + prefix = base ? base->resolve(path_prefix, &error) : std::nullopt; + if (!prefix) return error; + } + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + std::shared_ptr<SessionEntry> keep; + if (AgentLoop* root = loop_for(tree.root_id, keep); + root && (!prefix || AgentPath::root().has_prefix(prefix->str()))) { + ListedAgent agent; + agent.path = AgentPath::kRoot; + agent.session_id = tree.root_id; + agent.status.kind = root->is_busy() ? AgentStatus::Kind::Running + : AgentStatus::Kind::Completed; + out.push_back(std::move(agent)); + } + for (const auto& [path, record] : tree.agents) { + if (record.session_id.empty() || !record.resident || record.evicting) continue; + const auto parsed = AgentPath::parse(path); + if (prefix && (!parsed || !parsed->has_prefix(prefix->str()))) continue; + out.push_back({path, record.session_id, status_locked(tree, record)}); + } + return {}; +} + +std::string MeshAgentService::interrupt(const ToolContext& ctx, const std::string& target, + AgentStatus& previous) { + previous = AgentStatus{}; + std::string error; + const auto self = caller(ctx, &error); + if (!self) return error; + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + Record* record = nullptr; + if (auto resolve_error = resolve_locked(tree, *self, target, &record); !resolve_error.empty()) { + return resolve_error; + } + if (!record) return "root is not a spawned agent"; + if (record->session_id == self->session_id) { + return "an agent cannot interrupt itself; return your result and let the parent interrupt " + "you if needed"; + } + previous = status_locked(tree, *record); + if (record->resident && !record->evicting) { + std::shared_ptr<SessionEntry> keep; + if (AgentLoop* loop = loop_for(record->session_id, keep)) loop->abort(); + } + return {}; +} + +std::string MeshAgentService::wait(const ToolContext& ctx, std::chrono::milliseconds timeout, + WaitResult& result) { + std::string error; + const auto self = caller(ctx, &error); + if (!self) return error; + std::shared_ptr<SessionEntry> keep; + AgentLoop* loop = loop_for(self->session_id, keep); + if (!loop) return "collab manager unavailable"; + switch (loop->wait_for_mailbox_activity(timeout, ctx.abort_flag)) { + case AgentLoop::MailboxWaitOutcome::Mailbox: result = WaitResult::Mailbox; break; + case AgentLoop::MailboxWaitOutcome::Steered: result = WaitResult::Steered; break; + case AgentLoop::MailboxWaitOutcome::TimedOut: result = WaitResult::TimedOut; break; + case AgentLoop::MailboxWaitOutcome::Aborted: result = WaitResult::Aborted; break; + } + return {}; +} + +std::string MeshAgentService::tree_busy_reason(const std::string& root_id) { + std::lock_guard<std::mutex> lock(mu_); + const auto it = trees_.find(root_id); + if (it == trees_.end()) return {}; + for (const auto& [path, record] : it->second->agents) { + for (const auto& held : record.held_mail) { + if (held.second) return "agent `" + path + "` has an undelivered follow-up task"; + } + if (!record.resident || record.evicting) continue; + std::shared_ptr<SessionEntry> keep; + AgentLoop* loop = loop_for(record.session_id, keep); + if (!loop) continue; + if (loop->has_pending_work() || loop->has_pending_trigger_mail()) { + return "agent `" + path + "` is still running; wait for it or interrupt it first"; + } + } + return {}; +} + +std::vector<TreeAgentInfo> MeshAgentService::tree_agents(const std::string& root_id) { + std::vector<TreeAgentInfo> out; + std::lock_guard<std::mutex> lock(mu_); + const auto it = trees_.find(root_id); + if (it == trees_.end()) return out; + for (const auto& [path, record] : it->second->agents) { + if (record.session_id.empty()) continue; + out.push_back({record.session_id, path, record.resident && !record.evicting, + status_locked(*it->second, record), record.held_mail.size()}); + } + return out; +} + +} // namespace acecode::mesh diff --git a/src/host/session_host/mesh/mesh_agent_service.hpp b/src/host/session_host/mesh/mesh_agent_service.hpp new file mode 100644 index 000000000..c8ecda368 --- /dev/null +++ b/src/host/session_host/mesh/mesh_agent_service.hpp @@ -0,0 +1,190 @@ +#pragma once + +// MeshAgentService: the mesh swarm (Codex Multi-Agent V2) tree controller. +// +// - Tree directory per root session: canonical path -> session id, persisted +// in the root's data dir (session/mesh_tree_index.hpp). Every mesh child has +// parent_session_id = root id; the real parent is the path's parent. +// - Residency (Codex V2Residency): at most max_concurrent_agents - 1 loaded +// children per tree (the root takes the remaining slot). A full tree evicts +// the least recently active unloadable child (finished, no active work, empty +// mailbox); addressing an evicted child restores it from its transcript. +// - Delivery goes through AgentLoop::deliver_inter_agent_message; mail for an +// unloaded agent waits here and is flushed when the agent is restored. +// - Completion routing: a child's finished turn sends FINAL_ANSWER (completed or +// errored; interrupted turns send nothing) to its parent without waking it. +// +// Lifetime: owned by a composition root as shared_ptr, constructed after the +// SessionRegistry and shut down before it. Tools and event listeners capture +// weak_ptr only. The service mutex is never held across registry create / +// resume / destroy, which may join agent workers. + +#include "session/agent_fork_history.hpp" +#include "session/agent_path.hpp" +#include "session/inter_agent_message.hpp" +#include "session/scoped_subscription.hpp" +#include "session_host/local_session_client.hpp" + +#include <nlohmann/json.hpp> + +#include <atomic> +#include <chrono> +#include <condition_variable> +#include <cstdint> +#include <deque> +#include <functional> +#include <map> +#include <memory> +#include <mutex> +#include <optional> +#include <shared_mutex> +#include <string> +#include <vector> + +namespace acecode { + +struct ToolContext; +class AgentLoop; +class SessionManager; +class ExpertRegistry; +struct AppConfig; +struct MeshSwarmConfig; + +namespace mesh { + +// Codex AgentStatus, serialized like Codex: "pending_init" | "running" | +// "interrupted" | "shutdown" | "not_found" | {"completed": text|null} | +// {"errored": text}. +struct AgentStatus { + enum class Kind { PendingInit, Running, Interrupted, Completed, Errored, Shutdown, NotFound }; + Kind kind = Kind::NotFound; + std::optional<std::string> completed; + std::string errored; +}; +nlohmann::json agent_status_to_json(const AgentStatus& status); +// Short UI label: pending_init | running | completed | errored | interrupted | not_found. +std::string agent_status_label(const AgentStatus& status); + +struct SpawnArgs { + std::string task_name; + std::string message; + std::string agent_type; + std::string model; + std::optional<std::string> reasoning_effort; + ForkTurns fork; +}; + +struct SpawnResult { + std::string error; // non-empty = failure, model-facing text + std::string session_id; + std::string path; +}; + +struct ListedAgent { + std::string path; + std::string session_id; + AgentStatus status; +}; + +enum class WaitResult { Mailbox, Steered, TimedOut, Aborted }; + +// Snapshot for UI surfaces (background-task panel, /tasks). +struct TreeAgentInfo { + std::string session_id; + std::string path; + bool loaded = false; + AgentStatus status; + std::size_t held_mail = 0; +}; + +class MeshAgentService : public std::enable_shared_from_this<MeshAgentService> { +public: + struct Deps { + SessionRegistry* registry = nullptr; // Borrowed; outlives the service. + const ExpertRegistry* experts = nullptr; // Nullable borrowed. + const AppConfig* config = nullptr; // Nullable borrowed; outlives the service. + std::shared_mutex* config_mutex = nullptr; // Nullable; guards *config writers. + // TUI: the root session lives outside the registry. Both return the + // current main session; the loop pointer is borrowed for one call. + std::function<std::string()> external_root_id; + std::function<AgentLoop*()> external_root_loop; + }; + + explicit MeshAgentService(Deps deps); + ~MeshAgentService(); + MeshAgentService(const MeshAgentService&) = delete; + MeshAgentService& operator=(const MeshAgentService&) = delete; + + // Installs the registry swarm-mode guard; call once after make_shared. + void attach(); + // After a mesh child is created or restored (UI event tracking). Install + // before sessions run; the callback is invoked on tool threads. + using AgentLoadedCallback = + std::function<void(const std::string& child_id, const std::string& root_id)>; + void set_on_agent_loaded(AgentLoadedCallback callback); + MeshSwarmConfig config_snapshot() const; + std::vector<std::string> saved_model_names() const; + // Drops subscriptions and the guard. Idempotent; call before the registry + // shuts down. + void shutdown(); + + SpawnResult spawn(const ToolContext& ctx, const SpawnArgs& args); + // send_message (trigger_turn=false) / followup_task (true). Empty = ok. + std::string deliver(const ToolContext& ctx, const std::string& target, + const std::string& message, bool trigger_turn); + std::string list(const ToolContext& ctx, const std::string& path_prefix, + std::vector<ListedAgent>& out); + std::string interrupt(const ToolContext& ctx, const std::string& target, + AgentStatus& previous); + std::string wait(const ToolContext& ctx, std::chrono::milliseconds timeout, + WaitResult& result); + + // Mesh exit guard: non-empty when the root's tree still runs agents or + // holds undelivered follow-up tasks. + std::string tree_busy_reason(const std::string& root_id); + std::vector<TreeAgentInfo> tree_agents(const std::string& root_id); + + struct Caller; + struct Tree; + struct Record; + +private: + std::optional<Caller> caller(const ToolContext& ctx, std::string* error) const; + Tree& tree_locked(const Caller& caller); + std::string resolve_locked(Tree& tree, const Caller& caller, const std::string& target, + Record** out) const; + AgentLoop* loop_for(const std::string& session_id, + std::shared_ptr<SessionEntry>& keep_alive) const; + AgentStatus status_locked(const Tree& tree, const Record& record) const; + std::string reserve_slot(const std::string& root_id, const std::string& protected_id); + void release_slot(const std::string& root_id); + void commit_resident(const std::string& root_id, const std::string& session_id); + void touch_locked(Tree& tree, const std::string& session_id); + std::string ensure_loaded(const Caller& caller, const std::string& path); + void subscribe_child(const std::string& root_id, const std::string& session_id); + void persist_index_locked(const Tree& tree) const; + void on_child_event(const std::string& root_id, const std::string& session_id, + const SessionEvent& event); + void route_completion(const std::string& root_id, const std::string& session_id, + const std::string& outcome); + bool deliver_envelope_locked(Tree& tree, Record& recipient, UserInput envelope, + bool trigger_turn); + UserInput make_envelope(InterAgentMessageType type, const std::string& recipient, + const std::string& sender, const std::string& sender_session, + const std::string& payload, const std::string& status = {}) const; + + Deps deps_; + LocalSessionClient client_; + void notify_agent_loaded(const std::string& child_id, const std::string& root_id); + + mutable std::mutex mu_; + std::condition_variable cv_; // Signals restore / eviction completion. + mutable std::mutex callback_mu_; + AgentLoadedCallback on_agent_loaded_; + std::map<std::string, std::unique_ptr<Tree>> trees_; + std::atomic<bool> stopped_{false}; + std::uint64_t activity_clock_ = 0; // Guarded by mu_. +}; + +} // namespace mesh +} // namespace acecode diff --git a/src/host/session_host/mesh/mesh_agent_spawn.cpp b/src/host/session_host/mesh/mesh_agent_spawn.cpp new file mode 100644 index 000000000..06a9f5ea5 --- /dev/null +++ b/src/host/session_host/mesh/mesh_agent_spawn.cpp @@ -0,0 +1,207 @@ +// MeshAgentService::spawn — agent_spawn(Codex Multi-Agent V2 spawn_agent)。 +#include "mesh_agent_state.hpp" + +#include "agent/agent_loop.hpp" +#include "config/config.hpp" +#include "experts/expert_registry.hpp" +#include "session/compact_checkpoint.hpp" +#include "session/session_manager.hpp" +#include "session_host/session_registry.hpp" +#include "tool/tool_executor.hpp" +#include "utils/logger.hpp" + +#include <algorithm> + +namespace acecode::mesh { + +namespace { + +std::string join_names(const std::vector<std::string>& names) { + std::string out; + for (const auto& name : names) { + if (!out.empty()) out += ", "; + out += name; + } + return out.empty() ? std::string("(none)") : out; +} + +// Child expert binding. agent_type names a member of the caller's team expert +// (Codex agent_type -> ACECode expert member). Omitted: a full-history fork +// inherits the caller's binding (Codex inherits the parent agent type), +// otherwise the child starts without an expert. +std::string resolve_expert(const ExpertRegistry* experts, const std::string& cwd, + SessionManager& caller_sm, const SpawnArgs& args, + SessionOptions& opts) { + const std::string expert_id = caller_sm.current_expert_id(); + if (args.agent_type.empty()) { + if (args.fork.kind == ForkTurns::Kind::All && !expert_id.empty()) { + opts.expert_id = expert_id; + opts.expert_member_id = caller_sm.current_expert_member_id(); + } + return {}; + } + if (expert_id.empty() || !experts) { + return "agent_type is only available when this session is bound to a team expert"; + } + const auto expert = experts->find(cwd, expert_id); + if (!expert || expert->type != ExpertType::Team) { + return "agent_type is only available when this session is bound to a team expert"; + } + if (!expert->is_declared_member(args.agent_type)) { + return "unknown agent_type '" + args.agent_type + "'"; + } + opts.expert_id = expert_id; + opts.expert_member_id = args.agent_type; + return {}; +} + +} // namespace + +SpawnResult MeshAgentService::spawn(const ToolContext& ctx, const SpawnArgs& args) { + SpawnResult result; + if (args.message.find_first_not_of(" \t\r\n") == std::string::npos) { + result.error = "Empty message can't be sent to an agent"; + return result; + } + std::string error; + const auto self = caller(ctx, &error); + if (!self) { + result.error = error; + return result; + } + const auto parent_path = AgentPath::parse(self->path); + const auto child_path = parent_path ? parent_path->join(args.task_name, &error) : std::nullopt; + if (!child_path) { + result.error = error; + return result; + } + SessionOptions opts; + if (!args.model.empty()) { + const auto names = saved_model_names(); + if (std::find(names.begin(), names.end(), args.model) == names.end()) { + result.error = "Unknown model `" + args.model + + "` for agent_spawn. Available models: " + join_names(names); + return result; + } + opts.model_name = args.model; + } else { + // Codex: spawned agents inherit the parent's model and reasoning effort. + opts.model_name = self->sm->current_model_preset(); + } + opts.reasoning_effort = args.reasoning_effort + ? args.reasoning_effort + : (args.model.empty() ? self->sm->current_reasoning_effort() : std::nullopt); + if (auto expert_error = resolve_expert(deps_.experts, self->workspace_cwd, *self->sm, args, opts); + !expert_error.empty()) { + result.error = expert_error; + return result; + } + + // Phase 1: claim the path so a concurrent spawn of the same name fails. + { + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + if (tree.agents.count(child_path->str()) != 0) { + result.error = "agent path `" + child_path->str() + "` already exists"; + return result; + } + Record& record = tree.agents[child_path->str()]; + record.path = child_path->str(); + record.restoring = true; + } + auto abandon = [this, &self, &child_path] { + { + std::lock_guard<std::mutex> lock(mu_); + tree_locked(*self).agents.erase(child_path->str()); + } + cv_.notify_all(); + }; + if (auto slot_error = reserve_slot(self->root_id, self->session_id); !slot_error.empty()) { + abandon(); + result.error = slot_error; + return result; + } + + // Phase 2: create the child outside the service lock. Like spawn_subagent, + // a child shares the caller's worktree and write boundary and runs in the + // caller's workspace scope so the whole tree shares one project dir. + const WorktreeSessionInfo worktree = self->sm->active_worktree(); + opts.cwd = self->workspace_cwd; + opts.no_workspace = self->no_workspace; + opts.reuse_no_workspace_cwd = self->no_workspace; + if (worktree.active()) opts.inherited_worktree = worktree; + opts.write_root = ctx.write_root; + opts.permission_mode = self->sm->current_permission_mode(); + opts.subagent_depth = 1; + opts.parent_session_id = self->root_id; + opts.swarm_mode = "mesh"; + opts.agent_path = child_path->str(); + if (auto caller_entry = deps_.registry->acquire(self->session_id); + caller_entry && caller_entry->loop_execution) { + opts.loop_execution = true; + opts.loop_id = caller_entry->loop_id; + opts.loop_run_id = caller_entry->loop_run_id; + if (caller_entry->loop) { + opts.loop_system_context = caller_entry->loop->loop_execution_policy().system_context; + } + } + std::string child_id; + try { + child_id = deps_.registry->create(opts); + } catch (const std::exception& e) { + release_slot(self->root_id); + abandon(); + result.error = std::string("collab spawn failed: ") + e.what(); + return result; + } + auto child = deps_.registry->acquire(child_id); + if (!child || !child->loop || !child->sm) { + release_slot(self->root_id); + abandon(); + result.error = "collab spawn failed: child session unavailable"; + return result; + } + // fork_turns: inherit the caller's conversation (Codex keep_forked_rollout_item). + if (args.fork.kind != ForkTurns::Kind::None) { + const auto history = build_fork_history( + reconstruct_effective_model_history(self->sm->load_active_messages()), args.fork); + for (const auto& message : history) { + child->loop->push_message(message); + child->sm->on_message(message); + } + } + // Codex shows a child by its task name; no hidden title-model call. + if (child->sm->try_set_generated_session_title(args.task_name)) { + child->loop->events().emit(SessionEventKind::SessionUpdated, + nlohmann::json{{"title", args.task_name}, {"title_source", "generated"}}); + } + + // Phase 3: publish the record, start event routing, then deliver the task. + { + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + Record& record = tree.agents[child_path->str()]; + record.session_id = child_id; + record.restoring = false; + persist_index_locked(tree); + } + cv_.notify_all(); + subscribe_child(self->root_id, child_id); + commit_resident(self->root_id, child_id); + notify_agent_loaded(child_id, self->root_id); + { + std::lock_guard<std::mutex> lock(mu_); + Tree& tree = tree_locked(*self); + Record& record = tree.agents[child_path->str()]; + deliver_envelope_locked(tree, record, + make_envelope(InterAgentMessageType::NewTask, child_path->str(), self->path, + self->session_id, args.message), + true); + } + LOG_INFO("[mesh] spawned " + child_path->str() + " (" + child_id + ") from " + self->path); + result.session_id = child_id; + result.path = child_path->str(); + return result; +} + +} // namespace acecode::mesh diff --git a/src/host/session_host/mesh/mesh_agent_state.hpp b/src/host/session_host/mesh/mesh_agent_state.hpp new file mode 100644 index 000000000..dd470b891 --- /dev/null +++ b/src/host/session_host/mesh/mesh_agent_state.hpp @@ -0,0 +1,57 @@ +#pragma once + +// Private state of MeshAgentService, shared by its translation units only. + +#include "mesh_agent_service.hpp" + +#include <deque> +#include <map> +#include <string> +#include <utility> + +namespace acecode::mesh { + +struct MeshAgentService::Caller { + std::string session_id; + std::string root_id; + std::string path; // canonical; "/root" for the root + std::string project_dir; // tree index location (shared by the whole tree) + std::string workspace_cwd; // session storage cwd used for create / resume + bool no_workspace = false; + SessionManager* sm = nullptr; // Borrowed for one tool call. + const ToolContext* ctx = nullptr; // Borrowed for one tool call. +}; + +struct MeshAgentService::Record { + std::string session_id; // empty while the spawn is still creating it + std::string path; + bool resident = false; // loaded child counted toward the tree capacity + bool evicting = false; // registry destroy in flight + bool restoring = false; // create / resume in flight + bool started = false; // a turn has started since load + std::string last_outcome; // completed | error | aborted + std::optional<std::string> final_text; + std::string error_text; + // Mail for an agent that is not loaded; flushed when it is restored. + std::deque<std::pair<UserInput, bool>> held_mail; + ScopedSubscription subscription; + std::uint64_t last_activity = 0; +}; + +struct MeshAgentService::Tree { + std::string root_id; + std::string project_dir; + std::string workspace_cwd; + bool no_workspace = false; + std::map<std::string, Record> agents; // children by canonical path + std::size_t pending_slots = 0; +}; + +// Codex ERROR_MAX_TOKENS (900) at roughly 4 characters per token. +inline constexpr std::size_t kCompletionErrorMaxChars = 3600; +inline constexpr const char* kErrorNextAction = + "This agent's turn failed. If you still need this agent, use the available collaboration " + "tools to give it another task."; +inline constexpr const char* kAgentLimitReached = "collab spawn failed: agent thread limit reached"; + +} // namespace acecode::mesh diff --git a/src/host/session_host/session_registry.cpp b/src/host/session_host/session_registry.cpp index 5dc2d226b..21a7c0b99 100644 --- a/src/host/session_host/session_registry.cpp +++ b/src/host/session_host/session_registry.cpp @@ -63,11 +63,6 @@ std::string session_dir_name_from_id(const std::string& session_id) { return out.empty() ? "session" : out; } -bool is_llm_role(const std::string& role) { - return role == "user" || role == "assistant" || - role == "system" || role == "tool"; -} - std::string trim_copy(const std::string& value) { std::size_t first = 0; while (first < value.size() && @@ -102,50 +97,6 @@ ExpertCapabilityScopes fail_closed_expert_scopes() { return scopes; } -bool is_transcript_only_message(const ChatMessage& msg) { - return msg.metadata.is_object() && - msg.metadata.value("transcript_only", false); -} - -std::optional<std::pair<std::size_t, CompactCheckpoint>> -latest_valid_compact_checkpoint(const std::vector<ChatMessage>& messages) { - for (std::size_t i = messages.size(); i > 0; --i) { - auto checkpoint = decode_compact_checkpoint(messages[i - 1]); - if (checkpoint.has_value()) { - return std::make_pair(i - 1, std::move(*checkpoint)); - } - } - return std::nullopt; -} - -void append_model_messages_to_loop(AgentLoop& loop, - const std::vector<ChatMessage>& messages) { - for (std::size_t i = 0; i < messages.size(); ++i) { - const auto& msg = messages[i]; - if (is_file_checkpoint_message(msg)) continue; - if (is_content_replacement_message(msg)) continue; - if (is_turn_timing_message(msg)) continue; - if (is_compact_checkpoint_message(msg)) continue; - - const bool is_shell_user = - (msg.role == "user" && !msg.content.empty() && msg.content[0] == '!'); - const bool next_is_result = - (i + 1 < messages.size() && messages[i + 1].role == "tool_result"); - if (is_shell_user && next_is_result) { - loop.inject_shell_turn(msg.content.substr(1), - messages[i + 1].content, - "", - 0); - ++i; - continue; - } - - if (is_llm_role(msg.role) && !is_transcript_only_message(msg)) { - loop.push_message(msg); - } - } -} - std::pair<std::string, std::string> current_provider_model(const SessionRegistryDeps& deps, const std::string& fallback_model) { @@ -1135,6 +1086,7 @@ SessionRegistry::make_entry_locked(const std::string& id, loop_options.inherited_write_root = opts.write_root; loop_options.tool_policy = entry->tool_capability_policy; loop_options.expert_member_id = entry->expert_member_id; + loop_options.swarm = apply_swarm_identity(*entry, opts, resumed_meta); entry->loop = std::make_unique<AgentLoop>(std::move(loop_services), std::move(loop_options)); if (opts.inherited_worktree.active()) entry->loop->set_cwd(opts.inherited_worktree.worktree_path); @@ -1171,26 +1123,6 @@ SessionRegistry::make_entry_locked(const std::string& id, return entry; } -void SessionRegistry::restore_loop_history( - SessionEntry& entry, - const std::vector<ChatMessage>& messages) const { - if (!entry.loop) return; - restore_file_tool_state_from_messages(messages, entry.loop->cwd()); - entry.loop->clear_messages(); - - if (auto checkpoint = latest_valid_compact_checkpoint(messages)) { - append_model_messages_to_loop(*entry.loop, checkpoint->second.replacement_history); - if (checkpoint->first + 1 < messages.size()) { - std::vector<ChatMessage> suffix( - messages.begin() + static_cast<std::ptrdiff_t>(checkpoint->first + 1), - messages.end()); - append_model_messages_to_loop(*entry.loop, suffix); - } - } else { - append_model_messages_to_loop(*entry.loop, messages); - } -} - bool SessionRegistry::resume(const std::string& id, const SessionOptions& opts) { SessionLoadTimer timer("resume", id); if (!begin_creation()) return false; @@ -1329,9 +1261,9 @@ void SessionRegistry::set_external_command_handler(ExternalCommandHandler handle BuiltinCommandResult SessionRegistry::execute_builtin_command( const std::string& id, const BuiltinCommandRequest& request) { - if (request.name != "init" && request.name != "compact" && - request.name != "goal" && request.name != "plan" && - request.name != "lsp" && request.name != "sandbox" && request.name != "memory") { + if (request.name != "init" && request.name != "compact" && request.name != "goal" && + request.name != "plan" && request.name != "lsp" && request.name != "sandbox" && + request.name != "memory" && request.name != "swarm") { // 内置名单之外:先给宿主注册的兜底处理器(daemon 托管 /rc 走这里), // 没有兜底或兜底不认时保持原 UnsupportedCommand 语义。锁外调用, // handler 内部可以安全地回头 acquire()/emit。 @@ -1354,6 +1286,7 @@ BuiltinCommandResult SessionRegistry::execute_builtin_command( return {BuiltinCommandStatus::Accepted, "queued"}; } + if (request.name == "swarm") return execute_swarm_builtin(*entry, request); if (request.name == "goal") { return execute_goal_builtin(*entry, request); } @@ -2292,6 +2225,8 @@ std::vector<SessionInfo> SessionRegistry::list_active() const { info.worktree_path = worktree.worktree_path; info.worktree_name = worktree.worktree_name; info.worktree_branch = worktree.worktree_branch; + if (!display.swarm_mode.empty()) info.swarm_mode = display.swarm_mode; + info.agent_path = display.agent_path; } if (entry->perm) { info.permission_mode = PermissionManager::mode_name(entry->perm->mode()); diff --git a/src/host/session_host/session_registry.hpp b/src/host/session_host/session_registry.hpp index 35128ba91..a7d289634 100644 --- a/src/host/session_host/session_registry.hpp +++ b/src/host/session_host/session_registry.hpp @@ -394,7 +394,23 @@ class SessionRegistry { WebWorktreeResult enter_worktree_for_web(const std::string& id, const std::string& base_branch); + // ---- 蜂群模式(会话级 off / star / mesh,session_registry_swarm.cpp)---- + // Installed by MeshAgentService: a non-empty result refuses the change + // (leaving mesh while the agent tree still has running work). + using SwarmModeGuard = std::function<std::string( + const std::string& session_id, SwarmMode from, SwarmMode to)>; + void set_swarm_mode_guard(SwarmModeGuard guard); + // Persists the mode and publishes it for the session's next turn. + bool set_swarm_mode(const std::string& id, SwarmMode mode, std::string* error = nullptr); + std::optional<SwarmMode> swarm_mode(const std::string& id) const; + // Request-builder view of a mode: mesh roots are /root, children keep path. + agent::SwarmContext swarm_context_for(SwarmMode mode, const std::string& agent_path) const; + private: + agent::SwarmContext apply_swarm_identity(SessionEntry& entry, const SessionOptions& opts, + const SessionMeta* resumed_meta); + BuiltinCommandResult execute_swarm_builtin(SessionEntry& entry, + const BuiltinCommandRequest& request); SessionPromptConfig prompt_config_snapshot() const; std::shared_ptr<SessionEntry> make_entry_locked(const std::string& id, const SessionOptions& opts, @@ -427,6 +443,8 @@ class SessionRegistry { std::mutex resume_inflight_mu_; std::condition_variable resume_inflight_cv_; std::unordered_set<std::string> resume_inflight_; + mutable std::mutex swarm_guard_mu_; + SwarmModeGuard swarm_guard_; ReapingThreadSet title_threads_; ReapingThreadSet lifecycle_threads_; LifetimeToken lifetime_; diff --git a/src/host/session_host/session_registry_history.cpp b/src/host/session_host/session_registry_history.cpp new file mode 100644 index 000000000..938c88d8e --- /dev/null +++ b/src/host/session_host/session_registry_history.cpp @@ -0,0 +1,89 @@ +// SessionRegistry::restore_loop_history: resume 时把持久化消息装回 AgentLoop。 +// 单独成文件让 session_registry.cpp 保持在行数基线内。 +#include "session_registry.hpp" + +#include "session/compact_checkpoint.hpp" +#include "session/session_rewind.hpp" +#include "session/tool_result_storage.hpp" +#include "session/turn_timing.hpp" +#include "tool/file_state_restore.hpp" + +#include <optional> +#include <utility> + +namespace acecode { + +namespace { + +bool is_llm_role(const std::string& role) { + return role == "user" || role == "assistant" || + role == "system" || role == "tool"; +} + +bool is_transcript_only_message(const ChatMessage& msg) { + return msg.metadata.is_object() && + msg.metadata.value("transcript_only", false); +} + +std::optional<std::pair<std::size_t, CompactCheckpoint>> +latest_valid_compact_checkpoint(const std::vector<ChatMessage>& messages) { + for (std::size_t i = messages.size(); i > 0; --i) { + auto checkpoint = decode_compact_checkpoint(messages[i - 1]); + if (checkpoint.has_value()) { + return std::make_pair(i - 1, std::move(*checkpoint)); + } + } + return std::nullopt; +} + +void append_model_messages_to_loop(AgentLoop& loop, + const std::vector<ChatMessage>& messages) { + for (std::size_t i = 0; i < messages.size(); ++i) { + const auto& msg = messages[i]; + if (is_file_checkpoint_message(msg)) continue; + if (is_content_replacement_message(msg)) continue; + if (is_turn_timing_message(msg)) continue; + if (is_compact_checkpoint_message(msg)) continue; + + const bool is_shell_user = + (msg.role == "user" && !msg.content.empty() && msg.content[0] == '!'); + const bool next_is_result = + (i + 1 < messages.size() && messages[i + 1].role == "tool_result"); + if (is_shell_user && next_is_result) { + loop.inject_shell_turn(msg.content.substr(1), + messages[i + 1].content, + "", + 0); + ++i; + continue; + } + + if (is_llm_role(msg.role) && !is_transcript_only_message(msg)) { + loop.push_message(msg); + } + } +} + +} // namespace + +void SessionRegistry::restore_loop_history( + SessionEntry& entry, + const std::vector<ChatMessage>& messages) const { + if (!entry.loop) return; + restore_file_tool_state_from_messages(messages, entry.loop->cwd()); + entry.loop->clear_messages(); + + if (auto checkpoint = latest_valid_compact_checkpoint(messages)) { + append_model_messages_to_loop(*entry.loop, checkpoint->second.replacement_history); + if (checkpoint->first + 1 < messages.size()) { + std::vector<ChatMessage> suffix( + messages.begin() + static_cast<std::ptrdiff_t>(checkpoint->first + 1), + messages.end()); + append_model_messages_to_loop(*entry.loop, suffix); + } + } else { + append_model_messages_to_loop(*entry.loop, messages); + } +} + +} // namespace acecode diff --git a/src/host/session_host/session_registry_swarm.cpp b/src/host/session_host/session_registry_swarm.cpp new file mode 100644 index 000000000..685979c50 --- /dev/null +++ b/src/host/session_host/session_registry_swarm.cpp @@ -0,0 +1,117 @@ +// SessionRegistry 的会话级蜂群模式(add-mesh-swarm-mode):模式与网状身份随会话 +// meta 持久化,经 AgentLoop::set_swarm_context 在下一回合生效;单独成文件让 +// session_registry.cpp 保持在行数基线内。 +#include "session_registry.hpp" + +#include "swarm_command.hpp" +#include "session/agent_path.hpp" +#include "session/system_notice.hpp" +#include "utils/logger.hpp" + +#include <shared_mutex> + +namespace acecode { + +void SessionRegistry::set_swarm_mode_guard(SwarmModeGuard guard) { + std::lock_guard<std::mutex> lock(swarm_guard_mu_); + swarm_guard_ = std::move(guard); +} + +agent::SwarmContext SessionRegistry::swarm_context_for(SwarmMode mode, + const std::string& agent_path) const { + MeshSwarmConfig mesh_config; + if (deps_.config) { + std::shared_lock<std::shared_mutex> lock; + if (deps_.config_mutex) lock = std::shared_lock<std::shared_mutex>(*deps_.config_mutex); + mesh_config = deps_.config->swarm.mesh; + } + return agent::make_swarm_context(mode, agent_path, mesh_config); +} + +agent::SwarmContext SessionRegistry::apply_swarm_identity(SessionEntry& entry, + const SessionOptions& opts, + const SessionMeta* resumed_meta) { + std::string mode_name = opts.swarm_mode; + std::string path = opts.agent_path; + if (resumed_meta) { + if (mode_name.empty()) mode_name = resumed_meta->swarm_mode; + if (path.empty()) path = resumed_meta->agent_path; + } + const SwarmMode mode = parse_swarm_mode(mode_name).value_or(SwarmMode::Off); + if (mode != SwarmMode::Mesh) path.clear(); + if (entry.sm) { + entry.sm->set_swarm_mode(swarm_mode_name(mode)); + entry.sm->set_mesh_agent_path(path); + } + return swarm_context_for(mode, path); +} + +bool SessionRegistry::set_swarm_mode(const std::string& id, SwarmMode mode, std::string* error) { + auto entry = acquire(id); + if (!entry || !entry->loop || !entry->sm) { + if (error) *error = "unknown session"; + return false; + } + const SwarmMode current = + parse_swarm_mode(entry->sm->current_swarm_mode()).value_or(SwarmMode::Off); + if (current == mode) return true; + const std::string path = entry->sm->current_agent_path(); + if (current == SwarmMode::Mesh && !path.empty() && path != mesh::AgentPath::kRoot) { + if (error) *error = "swarm mode cannot be changed for a mesh swarm sub-agent"; + return false; + } + SwarmModeGuard guard; + { + std::lock_guard<std::mutex> lock(swarm_guard_mu_); + guard = swarm_guard_; + } + if (guard) { + const std::string refused = guard(id, current, mode); + if (!refused.empty()) { + if (error) *error = refused; + return false; + } + } + // AgentLoop 每回合从 SessionManager 读模式,写会话元数据即对下一回合生效。 + entry->sm->set_swarm_mode(swarm_mode_name(mode)); + // 输入框的模式芯片跟随服务端:/swarm、其它标签页的切换都经这条事件同步。 + entry->loop->events().emit(SessionEventKind::SessionUpdated, + nlohmann::json{{"swarm_mode", swarm_mode_name(mode)}}); + LOG_INFO(std::string("[registry] session ") + id + " swarm mode -> " + swarm_mode_name(mode)); + return true; +} + +std::optional<SwarmMode> SessionRegistry::swarm_mode(const std::string& id) const { + std::shared_ptr<SessionEntry> entry; + { + std::lock_guard<std::mutex> lock(mu_); + const auto it = entries_.find(id); + if (it != entries_.end()) entry = it->second; + } + if (!entry || !entry->sm) return std::nullopt; + return parse_swarm_mode(entry->sm->current_swarm_mode()).value_or(SwarmMode::Off); +} + +BuiltinCommandResult SessionRegistry::execute_swarm_builtin(SessionEntry& entry, + const BuiltinCommandRequest& request) { + if (!entry.loop || !entry.sm) return {BuiltinCommandStatus::Failed, "session unavailable"}; + const SwarmMode current = + parse_swarm_mode(entry.sm->current_swarm_mode()).value_or(SwarmMode::Off); + const auto parsed = parse_swarm_command(request.args); + std::string text; + bool ok = parsed.error.empty(); + if (!ok) { + text = parsed.error; + } else if (parsed.show || !parsed.mode) { + text = swarm_command_status_text(current); + } else { + std::string error; + ok = set_swarm_mode(entry.id, *parsed.mode, &error); + text = ok ? swarm_command_applied_text(*parsed.mode) : error; + } + entry.loop->emit_system_message(text, make_system_notice_metadata("swarm_status")); + if (!ok) return {BuiltinCommandStatus::Failed, text}; + return {BuiltinCommandStatus::Accepted, "ok"}; +} + +} // namespace acecode diff --git a/src/host/session_host/swarm_command.cpp b/src/host/session_host/swarm_command.cpp new file mode 100644 index 000000000..b47380d05 --- /dev/null +++ b/src/host/session_host/swarm_command.cpp @@ -0,0 +1,54 @@ +#include "swarm_command.hpp" + +#include <cctype> + +namespace acecode { + +namespace { + +std::string trim_lower(const std::string& text) { + std::size_t begin = 0; + std::size_t end = text.size(); + while (begin < end && std::isspace(static_cast<unsigned char>(text[begin]))) ++begin; + while (end > begin && std::isspace(static_cast<unsigned char>(text[end - 1]))) --end; + std::string out = text.substr(begin, end - begin); + for (auto& ch : out) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch))); + return out; +} + +const char* describe(SwarmMode mode) { + switch (mode) { + case SwarmMode::Star: return "star (spawn_subagent fan-out)"; + case SwarmMode::Mesh: return "mesh (agent_* collaboration tree)"; + case SwarmMode::Off: break; + } + return "off"; +} + +} // namespace + +SwarmCommandRequest parse_swarm_command(const std::string& args) { + SwarmCommandRequest request; + const std::string value = trim_lower(args); + if (value.empty()) { + request.show = true; + return request; + } + // 旧布尔字面量只给消息接口兼容用,命令只认三个规范名。 + if (value != "star" && value != "mesh" && value != "off") { + request.error = "Invalid swarm mode: " + value + " (allowed: star, mesh, off)"; + return request; + } + request.mode = parse_swarm_mode(value); + return request; +} + +std::string swarm_command_status_text(SwarmMode current) { + return std::string("Swarm mode: ") + describe(current) + "\nUsage: /swarm [star|mesh|off]"; +} + +std::string swarm_command_applied_text(SwarmMode mode) { + return std::string("Swarm mode set to ") + describe(mode) + "."; +} + +} // namespace acecode diff --git a/src/host/session_host/swarm_command.hpp b/src/host/session_host/swarm_command.hpp new file mode 100644 index 000000000..92b89a8af --- /dev/null +++ b/src/host/session_host/swarm_command.hpp @@ -0,0 +1,25 @@ +#pragma once + +// /swarm [star|mesh|off] — the single text implementation shared by the TUI +// builtin command and the daemon builtin whitelist. + +#include "session/swarm_mode.hpp" + +#include <optional> +#include <string> + +namespace acecode { + +struct SwarmCommandRequest { + // Empty args: show the current mode. + bool show = false; + std::optional<SwarmMode> mode; + // Non-empty: invalid argument, reported verbatim. + std::string error; +}; + +SwarmCommandRequest parse_swarm_command(const std::string& args); +std::string swarm_command_status_text(SwarmMode current); +std::string swarm_command_applied_text(SwarmMode mode); + +} // namespace acecode diff --git a/src/host/session_host/tools/mesh_agent_tools.cpp b/src/host/session_host/tools/mesh_agent_tools.cpp new file mode 100644 index 000000000..8e6e41c98 --- /dev/null +++ b/src/host/session_host/tools/mesh_agent_tools.cpp @@ -0,0 +1,395 @@ +#include "mesh_agent_tools.hpp" + +#include "config/config.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" +#include "utils/encoding.hpp" + +#include <algorithm> +#include <optional> +#include <set> + +namespace acecode { + +namespace { + +using nlohmann::json; +using mesh::MeshAgentService; + +constexpr const char* kUnavailable = "collab manager unavailable"; + +ToolResult failure(const std::string& message) { + ToolResult result; + result.success = false; + result.output = message; + return result; +} + +ToolSummary summary(const std::string& verb, const std::string& object) { + ToolSummary s; + s.icon = "agent"; + s.verb = verb; + s.object = truncate_utf8_prefix(object, 80); + return s; +} + +// Codex deserializes tool arguments with deny_unknown_fields. +std::string parse_object(const std::string& arguments, const std::set<std::string>& allowed, + json& out) { + try { + out = arguments.empty() ? json::object() : json::parse(arguments); + } catch (const std::exception& e) { + return std::string("failed to parse function arguments: ") + e.what(); + } + if (!out.is_object()) return "failed to parse function arguments: expected an object"; + for (const auto& [key, value] : out.items()) { + if (allowed.count(key) == 0) return "unknown field `" + key + "`"; + } + return {}; +} + +bool read_string(const json& args, const char* key, std::string& out, std::string& error, + bool required) { + const auto it = args.find(key); + if (it == args.end() || it->is_null()) { + if (required) error = std::string("missing field `") + key + "`"; + return !required; + } + if (!it->is_string()) { + error = std::string("invalid type for `") + key + "`: expected a string"; + return false; + } + out = it->get<std::string>(); + return true; +} + +json string_property(const std::string& description) { + return json{{"type", "string"}, {"description", description}}; +} + +json object_schema(json properties, json required) { + json schema = {{"type", "object"}, {"properties", std::move(properties)}, + {"additionalProperties", false}}; + if (!required.empty()) schema["required"] = std::move(required); + return schema; +} + +ToolImpl make_tool(std::string name, std::string description, json parameters) { + ToolImpl tool; + tool.definition.name = std::move(name); + tool.definition.description = std::move(description); + tool.definition.parameters = std::move(parameters); + // Like spawn_subagent: coordination itself touches no files; each agent's + // own permission gate governs what it does. + tool.is_read_only = true; + // Codex runs collaboration tools under the exclusive tool lock, never in a + // parallel batch. + tool.requires_serial_execution = true; + return tool; +} + +// Codex spawn_agent_models_description (at most 5 picker-visible models). +std::string models_description(const AppConfig& config) { + std::string lines; + std::size_t count = 0; + for (const auto& profile : config.saved_models) { + if (count++ == 5) break; + lines += "\n- `" + profile.name + "`: " + profile.provider + " " + profile.model; + } + if (lines.empty()) return "No picker-visible model overrides are currently loaded."; + return "Available model overrides (optional; inherited parent model is preferred):" + lines; +} + +std::string spawn_description(const AppConfig& config) { + std::string text; + if (config.swarm.mesh.expose_model_overrides) text = models_description(config) + "\n"; + text += + "Spawns an agent to work on the specified task. If your current task is `/root/task1` and " + "you agent_spawn with task_name \"task_3\" the agent will have canonical task name " + "`/root/task1/task_3`.\n" + "You are then able to refer to this agent as `task_3` or `/root/task1/task_3` " + "interchangeably. However an agent `/root/task2/task_3` would only be able to communicate " + "with this agent via its canonical name `/root/task1/task_3`.\n" + "The spawned agent will have the same tools as you and the ability to spawn its own " + "subagents.\n"; + text += + "It will be able to send you and other running agents messages, and its final answer will " + "be provided to you when it finishes.\n" + "The new agent's canonical task name will be provided to it along with the message.\n" + "\n" + "Note that passing `fork_turns=\"none\"` will not pass any surrounding context to the " + "spawned subagent, which may cause the agent to lack the context it needs to complete its " + "task, whereas `fork_turns=\"all\"` will provide the subagent with all surrounding context."; + return text; +} + +ToolImpl create_spawn_tool(std::weak_ptr<MeshAgentService> weak, const AppConfig& config) { + const bool expose_model_overrides = config.swarm.mesh.expose_model_overrides; + json properties = { + {"message", string_property("Initial plain-text task for the new agent.")}, + {"task_name", string_property( + "Task name for the new agent. Use lowercase letters, digits, and underscores.")}, + {"agent_type", string_property( + "Agent type override for the new agent. Omit unless explicitly asked. The selected " + "role applies regardless of how much parent history is inherited.\n" + "An agent type is a member id of this session's team expert; it is only accepted when " + "the session is bound to a team expert.")}, + {"fork_turns", string_property( + "Optional number of turns to fork. Defaults to `all`. Use `none`, `all`, or a positive " + "integer string such as `3` to fork only the most recent turns.")}, + }; + if (expose_model_overrides) { + properties["model"] = string_property( + "Model override for the new agent: a saved model name. Omit unless an explicit " + "override is needed."); + properties["reasoning_effort"] = string_property( + "Reasoning effort override for the new agent. Omit to inherit the parent effort."); + } + auto tool = make_tool("agent_spawn", spawn_description(config), + object_schema(std::move(properties), json::array({"task_name", "message"}))); + tool.execute = [weak, expose_model_overrides](const std::string& arguments, + const ToolContext& ctx) -> ToolResult { + auto service = weak.lock(); + if (!service) return failure(kUnavailable); + std::set<std::string> allowed = {"message", "task_name", "agent_type", "fork_turns", + "fork_context"}; + if (expose_model_overrides) allowed.insert({"model", "reasoning_effort"}); + json args; + if (auto error = parse_object(arguments, allowed, args); !error.empty()) { + return failure(error); + } + if (args.contains("fork_context")) { + return failure("fork_context is not supported in MultiAgentV2; use fork_turns instead"); + } + mesh::SpawnArgs spawn; + std::string fork_turns; + std::string effort; + std::string error; + if (!read_string(args, "task_name", spawn.task_name, error, true) || + !read_string(args, "message", spawn.message, error, true) || + !read_string(args, "agent_type", spawn.agent_type, error, false) || + !read_string(args, "fork_turns", fork_turns, error, false) || + !read_string(args, "model", spawn.model, error, false) || + !read_string(args, "reasoning_effort", effort, error, false)) { + return failure(error); + } + const auto fork = mesh::parse_fork_turns(fork_turns, &error); + if (!fork) return failure(error); + spawn.fork = *fork; + if (!effort.empty()) spawn.reasoning_effort = effort; + const auto spawned = service->spawn(ctx, spawn); + if (!spawned.error.empty()) return failure(spawned.error); + ToolResult result; + result.success = true; + result.output = json{{"task_name", spawned.path}}.dump(); + result.metadata["subagent_session_id"] = spawned.session_id; + result.metadata["agent_path"] = spawned.path; + result.summary = summary("agent_spawn", spawned.path); + return result; + }; + return tool; +} + +ToolImpl create_message_tool(std::weak_ptr<MeshAgentService> weak, bool trigger_turn) { + const std::string name = trigger_turn ? "agent_followup_task" : "agent_send_message"; + const std::string description = trigger_turn + ? "Send a follow-up task to an existing non-root target agent and trigger a turn if it is " + "idle. If the target is already running, deliver the task promptly at message " + "boundaries while sampling, or after the pending tool call completes." + : "Send a message to an existing agent. The message will be delivered promptly. Does not " + "trigger a new turn."; + const json properties = trigger_turn + ? json{{"target", string_property("Agent id or canonical task name to send a follow-up " + "task to (from agent_spawn).")}, + {"message", string_property("Message text to send to the target agent.")}} + : json{{"target", string_property( + "Relative or canonical task name to message (from agent_spawn).")}, + {"message", string_property("Message text to queue on the target agent.")}}; + auto tool = make_tool(name, description, + object_schema(properties, json::array({"target", "message"}))); + tool.execute = [weak, trigger_turn, name](const std::string& arguments, + const ToolContext& ctx) -> ToolResult { + auto service = weak.lock(); + if (!service) return failure(kUnavailable); + json args; + if (auto error = parse_object(arguments, {"target", "message"}, args); !error.empty()) { + return failure(error); + } + std::string target; + std::string message; + std::string error; + if (!read_string(args, "target", target, error, true) || + !read_string(args, "message", message, error, true)) { + return failure(error); + } + if (auto delivery_error = service->deliver(ctx, target, message, trigger_turn); + !delivery_error.empty()) { + return failure(delivery_error); + } + // Codex returns an empty output on success. + ToolResult result; + result.success = true; + result.metadata["agent_target"] = target; + result.summary = summary(name, target); + return result; + }; + return tool; +} + +ToolImpl create_wait_tool(std::weak_ptr<MeshAgentService> weak, const MeshSwarmConfig& config) { + const int min_ms = config.min_wait_timeout_ms; + const int default_ms = config.default_wait_timeout_ms; + const int max_ms = config.max_wait_timeout_ms; + auto tool = make_tool("agent_wait", + "Wait for a mailbox update from any live agent, including queued messages and " + "final-status notifications. The wait also ends early when new user input is steered " + "into the active turn. Does not return the content; returns either a summary of which " + "agents have updates (if any), an interruption summary for steered input, or a timeout " + "summary if no activity arrives before the deadline.", + object_schema(json{{"timeout_ms", json{{"type", "number"}, + {"description", "Timeout in milliseconds. Defaults to " + std::to_string(default_ms) + + ", min " + std::to_string(min_ms) + ", max " + + std::to_string(max_ms) + "."}}}}, + json::array())); + tool.execute = [weak, min_ms, default_ms, max_ms](const std::string& arguments, + const ToolContext& ctx) -> ToolResult { + auto service = weak.lock(); + if (!service) return failure(kUnavailable); + json args; + if (auto error = parse_object(arguments, {"timeout_ms"}, args); !error.empty()) { + return failure(error); + } + // Codex: above max is an error, anything else (even negative) clamps up to min. + std::optional<long long> requested; + if (args.contains("timeout_ms") && !args["timeout_ms"].is_null()) { + if (!args["timeout_ms"].is_number()) { + return failure("invalid type for `timeout_ms`: expected a number"); + } + requested = args["timeout_ms"].get<long long>(); + if (*requested > max_ms) { + return failure("timeout_ms must be at most " + std::to_string(max_ms)); + } + } + const long long timeout = + requested ? std::max<long long>(*requested, min_ms) : default_ms; + mesh::WaitResult outcome = mesh::WaitResult::TimedOut; + if (auto error = service->wait(ctx, std::chrono::milliseconds(timeout), outcome); + !error.empty()) { + return failure(error); + } + std::string message; + switch (outcome) { + case mesh::WaitResult::Mailbox: message = "Wait completed."; break; + case mesh::WaitResult::Steered: message = "Wait interrupted by new input."; break; + case mesh::WaitResult::TimedOut: message = "Wait timed out."; break; + case mesh::WaitResult::Aborted: message = "Wait aborted."; break; + } + if (requested && *requested < timeout) { + message += "\n\nRequested timeout of " + std::to_string(*requested) + + "ms was clamped to the minimum of " + std::to_string(timeout) + "ms."; + } + ToolResult result; + result.success = true; + result.output = json{{"message", message}, + {"timed_out", outcome == mesh::WaitResult::TimedOut}}.dump(); + result.summary = summary("agent_wait", ""); + return result; + }; + return tool; +} + +ToolImpl create_list_tool(std::weak_ptr<MeshAgentService> weak) { + auto tool = make_tool("agent_list", + "List live agents in the current root thread tree. Optionally filter by task-path prefix.", + object_schema(json{{"path_prefix", string_property( + "Task-path prefix filter without a trailing slash. Omit to list all live agents.")}}, + json::array())); + tool.execute = [weak](const std::string& arguments, const ToolContext& ctx) -> ToolResult { + auto service = weak.lock(); + if (!service) return failure(kUnavailable); + json args; + if (auto error = parse_object(arguments, {"path_prefix"}, args); !error.empty()) { + return failure(error); + } + std::string prefix; + std::string error; + if (!read_string(args, "path_prefix", prefix, error, false)) return failure(error); + std::vector<mesh::ListedAgent> agents; + if (auto list_error = service->list(ctx, prefix, agents); !list_error.empty()) { + return failure(list_error); + } + json listed = json::array(); + for (const auto& agent : agents) { + listed.push_back({{"agent_name", agent.path}, + {"agent_status", mesh::agent_status_to_json(agent.status)}}); + } + ToolResult result; + result.success = true; + result.output = json{{"agents", std::move(listed)}}.dump(); + result.summary = summary("agent_list", prefix); + return result; + }; + return tool; +} + +ToolImpl create_interrupt_tool(std::weak_ptr<MeshAgentService> weak) { + auto tool = make_tool("agent_interrupt", + "Interrupt an agent's current turn, if any, and return its previous status. The agent " + "remains available for messages and follow-up tasks.", + object_schema(json{{"target", string_property( + "Agent id or canonical task name to interrupt (from agent_spawn).")}}, + json::array({"target"}))); + tool.execute = [weak](const std::string& arguments, const ToolContext& ctx) -> ToolResult { + auto service = weak.lock(); + if (!service) return failure(kUnavailable); + json args; + if (auto error = parse_object(arguments, {"target"}, args); !error.empty()) { + return failure(error); + } + std::string target; + std::string error; + if (!read_string(args, "target", target, error, true)) return failure(error); + mesh::AgentStatus previous; + if (auto interrupt_error = service->interrupt(ctx, target, previous); + !interrupt_error.empty()) { + return failure(interrupt_error); + } + ToolResult result; + result.success = true; + result.output = json{{"previous_status", mesh::agent_status_to_json(previous)}}.dump(); + result.metadata["agent_target"] = target; + result.summary = summary("agent_interrupt", target); + return result; + }; + return tool; +} + +} // namespace + +void rebind_mesh_agent_tools(ToolExecutor& tools, std::weak_ptr<mesh::MeshAgentService> service, + const AppConfig& config) { + const std::vector<ToolImpl> fresh = { + create_spawn_tool(service, config), + create_list_tool(service), + create_message_tool(service, false), + create_message_tool(service, true), + create_wait_tool(service, config.swarm.mesh), + create_interrupt_tool(service), + }; + for (const auto& tool : fresh) { + if (!tools.has_tool(tool.definition.name)) continue; + tools.unregister_tool(tool.definition.name); + tools.register_tool(tool); + } +} + +void register_mesh_agent_tools(ToolExecutor& tools, std::weak_ptr<mesh::MeshAgentService> service, + const AppConfig& config) { + tools.register_tool(create_spawn_tool(service, config)); + tools.register_tool(create_list_tool(service)); + tools.register_tool(create_message_tool(service, false)); + tools.register_tool(create_message_tool(service, true)); + tools.register_tool(create_wait_tool(service, config.swarm.mesh)); + tools.register_tool(create_interrupt_tool(service)); +} + +} // namespace acecode diff --git a/src/host/session_host/tools/mesh_agent_tools.hpp b/src/host/session_host/tools/mesh_agent_tools.hpp new file mode 100644 index 000000000..c5903e8df --- /dev/null +++ b/src/host/session_host/tools/mesh_agent_tools.hpp @@ -0,0 +1,31 @@ +#pragma once + +// agent_spawn / agent_list / agent_send_message / agent_followup_task / +// agent_wait / agent_interrupt — the mesh swarm tool family (Codex +// Multi-Agent V2 spawn_agent / list_agents / send_message / followup_task / +// wait_agent / interrupt_agent with ACECode's agent_ prefix). Visible only in +// mesh swarm mode (session/swarm_mode.hpp). Closures capture weak_ptr<Service> +// and are registered after the SessionRegistry exists (ownership rule C12). + +#include "tool/tool_executor.hpp" + +#include <memory> + +namespace acecode { + +struct AppConfig; +namespace mesh { class MeshAgentService; } + +// Descriptions embed swarm.mesh (wait bounds, model-override exposure) and the +// saved model names of `config` at registration time. +void register_mesh_agent_tools(ToolExecutor& tools, + std::weak_ptr<mesh::MeshAgentService> service, + const AppConfig& config); +// Headless registers the tools before its SessionRegistry exists (so +// --list-tools / --disable-tools see them) and binds the live service once the +// registry is built. Tools removed by --disable-tools stay removed. +void rebind_mesh_agent_tools(ToolExecutor& tools, + std::weak_ptr<mesh::MeshAgentService> service, + const AppConfig& config); + +} // namespace acecode diff --git a/tests/agent/agent_loop_mesh_mailbox_test.cpp b/tests/agent/agent_loop_mesh_mailbox_test.cpp new file mode 100644 index 000000000..9e349a0ab --- /dev/null +++ b/tests/agent/agent_loop_mesh_mailbox_test.cpp @@ -0,0 +1,282 @@ +#include "test_support/agent/agent_loop_fixture.hpp" +#include <gtest/gtest.h> + +#include "agent/agent_loop.hpp" +#include "permissions/permissions.hpp" +#include "session/inter_agent_message.hpp" +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" +#include "test_support/agent/stub_provider.hpp" +#include "tool/tool_executor.hpp" + +#include <algorithm> +#include <atomic> +#include <chrono> +#include <condition_variable> +#include <filesystem> +#include <functional> +#include <memory> +#include <mutex> +#include <random> +#include <string> +#include <thread> +#include <vector> + +// 蜂群模式(网状)agent 间邮件在 AgentLoop 里的投递时机(add-mesh-swarm-mode, +// 对齐 Codex InputQueue 的 mailbox 语义): +// - 回合进行中到达的邮件在下一次模型请求前并入同一回合; +// - 空闲时 trigger_turn 邮件(agent_followup_task / NEW_TASK)唤醒一个新回合, +// 非触发邮件(agent_send_message / FINAL_ANSWER)只排队等下一回合; +// - agent_wait 的等待被新邮件或用户插话提前结束。 + +using namespace std::chrono_literals; +namespace fs = std::filesystem; +using acecode::mesh::InterAgentEnvelope; +using acecode::mesh::InterAgentMessageType; + +namespace { + +fs::path mailbox_temp_cwd(const std::string& hint) { + auto dir = fs::temp_directory_path() / + ("acecode_mesh_mailbox_" + hint + "_" + std::to_string(std::random_device{}())); + fs::remove_all(dir); + fs::create_directories(dir); + fs::remove_all(acecode::SessionStorage::get_project_dir(dir.string())); + return dir; +} + +acecode::UserInput envelope_input(InterAgentMessageType type, const std::string& payload) { + InterAgentEnvelope envelope; + envelope.type = type; + envelope.sender = "/root/peer"; + envelope.recipient = "/root/me"; + envelope.payload = payload; + acecode::UserInput input; + input.text = acecode::mesh::render_inter_agent_message(envelope); + input.metadata = nlohmann::json::object(); + input.metadata[acecode::mesh::kInterAgentMetadataKey] = + acecode::mesh::inter_agent_metadata(envelope); + return input; +} + +class MailboxHarness { +public: + explicit MailboxHarness(const std::string& hint) : cwd_(mailbox_temp_cwd(hint)) { + sm_->start_session(cwd_.string(), "stub", "stub-1", "sid-mesh-" + hint); + acecode::ToolImpl probe; + probe.definition.name = "probe"; + probe.definition.description = "Test probe"; + probe.definition.parameters = {{"type", "object"}, {"properties", nlohmann::json::object()}}; + probe.is_read_only = true; + probe.execute = [this](const std::string&, const acecode::ToolContext&) { + std::function<void()> hook; + { + std::lock_guard<std::mutex> lk(mu_); + hook = on_probe_; + } + if (hook) hook(); + return acecode::ToolResult{"probe ok", true}; + }; + tools_.register_tool(std::move(probe)); + + acecode::AgentCallbacks callbacks; + callbacks.on_busy_changed = [this](bool busy) { + std::lock_guard<std::mutex> lk(mu_); + busy_ = busy; + cv_.notify_all(); + }; + auto accessor = [this]() -> std::shared_ptr<acecode::LlmProvider> { return provider_; }; + loop_ = std::make_unique<acecode::AgentLoop>( + acecode_test::AgentLoopFixture::dependencies(accessor, tools_, callbacks, + permissions_, sm_.get()), + acecode_test::AgentLoopFixture::configuration(cwd_.string())); + loop_->start(); + sub_ = loop_->events().subscribe([this](const acecode::SessionEvent& event) { + std::lock_guard<std::mutex> lk(events_mu_); + events_.push_back(event); + }); + } + + ~MailboxHarness() { + if (loop_ && sub_ != 0) loop_->events().unsubscribe(sub_); + loop_.reset(); + sm_.reset(); + fs::remove_all(cwd_); + fs::remove_all(acecode::SessionStorage::get_project_dir(cwd_.string())); + } + + acecode::AgentLoop& loop() { return *loop_; } + acecode_test::StubLlmProvider& provider() { return *provider_; } + acecode::SessionManager& session() { return *sm_; } + + void on_probe(std::function<void()> hook) { + std::lock_guard<std::mutex> lk(mu_); + on_probe_ = std::move(hook); + } + + // 等到 provider 收到 count 次请求且 loop 回到空闲(连续两次观测,排除回合间隙)。 + bool wait_turns_and_idle(int count, std::chrono::milliseconds timeout = 10s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (provider_->turn_count() >= count && !loop_->is_busy()) { + std::this_thread::sleep_for(30ms); + if (provider_->turn_count() >= count && !loop_->is_busy()) return true; + } + std::this_thread::sleep_for(2ms); + } + return false; + } + + std::vector<acecode::SessionEvent> events() const { + std::lock_guard<std::mutex> lk(events_mu_); + return events_; + } + +private: + fs::path cwd_; + std::shared_ptr<acecode_test::StubLlmProvider> provider_ = + std::make_shared<acecode_test::StubLlmProvider>(); + acecode::ToolExecutor tools_; + acecode::PermissionManager permissions_; + std::unique_ptr<acecode::SessionManager> sm_ = std::make_unique<acecode::SessionManager>(); + std::unique_ptr<acecode::AgentLoop> loop_; + acecode::EventDispatcher::SubscriptionId sub_ = 0; + std::mutex mu_; + std::condition_variable cv_; + bool busy_ = false; + std::function<void()> on_probe_; + mutable std::mutex events_mu_; + std::vector<acecode::SessionEvent> events_; +}; + +std::vector<std::string> user_texts(const std::vector<acecode::ChatMessage>& messages) { + std::vector<std::string> texts; + for (const auto& message : messages) { + if (message.role == "user") texts.push_back(message.content); + } + return texts; +} + +bool contains_text(const std::vector<std::string>& texts, const std::string& needle) { + for (const auto& text : texts) { + if (text == needle) return true; + } + return false; +} + +} // namespace + +// 场景:agent 空闲时收到 agent_followup_task(trigger_turn=true)。 +// 期望:邮件本身作为新回合的输入唤醒一次模型请求;落盘消息带 inter_agent 元数据, +// 并发出 role=user 的 Message 事件(Web 据此渲染 agent 间消息行)。 +TEST(AgentLoopMeshMailbox, TriggerMailWakesIdleAgent) { + MailboxHarness h("trigger-idle"); + h.provider().push_text("on it"); + const auto mail = envelope_input(InterAgentMessageType::NewTask, "investigate"); + h.loop().deliver_inter_agent_message(mail, true); + ASSERT_TRUE(h.wait_turns_and_idle(1)); + EXPECT_EQ(h.provider().turn_count(), 1); + + const auto request = user_texts(h.provider().messages_for_turn(0)); + ASSERT_FALSE(request.empty()); + EXPECT_EQ(request.back(), mail.text); + EXPECT_EQ(h.loop().pending_mailbox_count(), 0u); + + const auto persisted = h.session().load_active_messages(); + const auto it = std::find_if(persisted.begin(), persisted.end(), [](const auto& message) { + return acecode::mesh::is_inter_agent_message(message); + }); + ASSERT_NE(it, persisted.end()); + EXPECT_EQ(it->content, mail.text); + + bool saw_event = false; + for (const auto& event : h.events()) { + if (event.kind == acecode::SessionEventKind::Message && + event.payload.value("role", std::string{}) == "user" && + event.payload.contains("metadata") && + event.payload["metadata"].contains(acecode::mesh::kInterAgentMetadataKey)) { + saw_event = true; + } + } + EXPECT_TRUE(saw_event); +} + +// 场景:agent 空闲时收到 agent_send_message(trigger_turn=false)。 +// 期望:不开新回合,邮件留在邮箱;下一次用户输入开回合时,首个模型请求前并入 +// (用户消息在前、信封在后),邮箱清空。回归:若非触发邮件也唤醒回合, +// 子 agent 的每条 FINAL_ANSWER 都会让空闲的父 agent 自动开工(Codex 明确不唤醒)。 +TEST(AgentLoopMeshMailbox, QueueOnlyMailWaitsForNextTurn) { + MailboxHarness h("queue-only"); + const auto mail = envelope_input(InterAgentMessageType::Message, "fyi"); + h.loop().deliver_inter_agent_message(mail, false); + std::this_thread::sleep_for(150ms); + EXPECT_EQ(h.provider().turn_count(), 0); + EXPECT_EQ(h.loop().pending_mailbox_count(), 1u); + EXPECT_FALSE(h.loop().has_pending_trigger_mail()); + + h.provider().push_text("answer"); + h.loop().submit("next question"); + ASSERT_TRUE(h.wait_turns_and_idle(1)); + const auto request = user_texts(h.provider().messages_for_turn(0)); + ASSERT_GE(request.size(), 2u); + EXPECT_EQ(request[request.size() - 2], "next question"); + EXPECT_EQ(request.back(), mail.text); + EXPECT_EQ(h.loop().pending_mailbox_count(), 0u); +} + +// 场景:回合进行中(工具执行期间)收到邮件,触发与非触发各一次。 +// 期望:邮件在工具结果之后、下一次模型请求之前并入同一回合;回合结束后不会因 +// 已被消费的触发邮件再多跑一个回合(provider 总请求数恰好 2)。 +TEST(AgentLoopMeshMailbox, MailArrivingMidTurnJoinsTheNextRequest) { + for (const bool trigger : {false, true}) { + SCOPED_TRACE(trigger ? "trigger" : "queue-only"); + MailboxHarness h(trigger ? "midturn-trigger" : "midturn-queue"); + const auto mail = envelope_input(InterAgentMessageType::Message, "mid-turn note"); + h.on_probe([&h, mail, trigger] { h.loop().deliver_inter_agent_message(mail, trigger); }); + h.provider().push_tool_call("probe", "{}", "call-probe"); + h.provider().push_text("finished"); + h.loop().submit("start"); + ASSERT_TRUE(h.wait_turns_and_idle(2)); + std::this_thread::sleep_for(150ms); + EXPECT_EQ(h.provider().turn_count(), 2); + EXPECT_FALSE(contains_text(user_texts(h.provider().messages_for_turn(0)), mail.text)); + const auto second = h.provider().messages_for_turn(1); + ASSERT_FALSE(second.empty()); + EXPECT_EQ(second.back().role, "user"); + EXPECT_EQ(second.back().content, mail.text); + EXPECT_EQ(h.loop().pending_mailbox_count(), 0u); + } +} + +// 场景:agent_wait 的三种结局。 +// 期望:已有待收邮件立即返回 Mailbox;无活动按超时返回 TimedOut;等待中另一线程 +// 投递邮件返回 Mailbox;中止标志置位返回 Aborted,且远早于截止时间。 +TEST(AgentLoopMeshMailbox, WaitForMailboxActivityOutcomes) { + MailboxHarness h("wait"); + using Outcome = acecode::AgentLoop::MailboxWaitOutcome; + EXPECT_EQ(h.loop().wait_for_mailbox_activity(30ms, nullptr), Outcome::TimedOut); + + std::thread sender([&h] { + std::this_thread::sleep_for(50ms); + h.loop().deliver_inter_agent_message(envelope_input(InterAgentMessageType::Message, "x"), + false); + }); + const auto started = std::chrono::steady_clock::now(); + EXPECT_EQ(h.loop().wait_for_mailbox_activity(10s, nullptr), Outcome::Mailbox); + sender.join(); + EXPECT_LT(std::chrono::steady_clock::now() - started, 5s); + + // 邮件未被消费时,再次等待立即返回(对应 Codex:已有待收邮件不阻塞)。 + EXPECT_EQ(h.loop().wait_for_mailbox_activity(10s, nullptr), Outcome::Mailbox); + + MailboxHarness idle("wait-abort"); + std::atomic<bool> abort{false}; + std::thread aborter([&abort] { + std::this_thread::sleep_for(50ms); + abort.store(true); + }); + const auto abort_started = std::chrono::steady_clock::now(); + EXPECT_EQ(idle.loop().wait_for_mailbox_activity(10s, &abort), Outcome::Aborted); + aborter.join(); + EXPECT_LT(std::chrono::steady_clock::now() - abort_started, 5s); +} diff --git a/tests/agent/agent_mailbox_test.cpp b/tests/agent/agent_mailbox_test.cpp new file mode 100644 index 000000000..e1c7f79d5 --- /dev/null +++ b/tests/agent/agent_mailbox_test.cpp @@ -0,0 +1,97 @@ +#include <gtest/gtest.h> + +#include "agent/mailbox/agent_mailbox.hpp" + +#include <atomic> +#include <chrono> +#include <thread> + +// 蜂群模式(网状)的会话级邮箱(add-mesh-swarm-mode,对应 Codex +// InputQueue::mailbox_pending_mails)。agent_wait 阻塞在 wait() 上, +// agent_send_message / agent_followup_task 经 push() 唤醒它,用户插话经 +// notify_steer() 唤醒它。 + +using acecode::agent::AgentMailbox; +using acecode::agent::MailboxActivity; +using namespace std::chrono_literals; + +namespace { + +AgentMailbox::Mail mail(const std::string& text, bool trigger) { + AgentMailbox::Mail item; + item.input.text = text; + item.trigger_turn = trigger; + return item; +} + +} // namespace + +// 场景:邮件按到达顺序排队,followup_task 带 trigger_turn。 +// 期望:FIFO 出队;has_trigger_turn 只要队里任意一封带触发就为真;take_all 清空。 +TEST(MeshAgentMailbox, QueuesInOrderAndTracksTriggerMail) { + AgentMailbox box; + box.push(mail("a", false)); + EXPECT_FALSE(box.has_trigger_turn()); + box.push(mail("b", true)); + EXPECT_TRUE(box.has_trigger_turn()); + EXPECT_EQ(box.size(), 2u); + + AgentMailbox::Mail first; + ASSERT_TRUE(box.pop_front(first)); + EXPECT_EQ(first.input.text, "a"); + auto rest = box.take_all(); + ASSERT_EQ(rest.size(), 1u); + EXPECT_EQ(rest.front().input.text, "b"); + EXPECT_EQ(box.size(), 0u); + EXPECT_FALSE(box.pop_front(first)); +} + +// 场景:agent_wait 先记下活动序号再等待;等待期间别的 agent 投递了邮件。 +// 期望:wait 立刻以 Mailbox 返回,而不是睡到超时(Codex "Wait completed.")。 +TEST(MeshAgentMailbox, WaitWakesOnNewMail) { + AgentMailbox box; + const auto mark = box.activity_sequence(); + std::thread sender([&box] { + std::this_thread::sleep_for(50ms); + box.push(mail("hello", false)); + }); + const auto started = std::chrono::steady_clock::now(); + const auto activity = box.wait(mark, started + 10s, nullptr); + sender.join(); + EXPECT_EQ(activity, MailboxActivity::Mailbox); + EXPECT_LT(std::chrono::steady_clock::now() - started, 5s); +} + +// 场景:agent_wait 期间用户向这个 agent 插话(steer)。 +// 期望:wait 以 Steer 返回(对应 Codex "Wait interrupted by new input.")。 +TEST(MeshAgentMailbox, WaitReportsSteerActivity) { + AgentMailbox box; + const auto mark = box.activity_sequence(); + box.notify_steer(); + EXPECT_EQ(box.wait(mark, std::chrono::steady_clock::now() + 1s, nullptr), + MailboxActivity::Steer); +} + +// 场景:没有任何活动、回合被中止、会话关闭三种退出路径。 +// 期望:超时 / abort / close 都返回 None 且不会挂死;abort 靠 100ms 轮询观察到, +// 远早于 10s 截止时间(否则停止按钮在 agent_wait 期间失效)。 +TEST(MeshAgentMailbox, WaitReturnsNoneOnTimeoutAbortAndClose) { + AgentMailbox box; + const auto mark = box.activity_sequence(); + EXPECT_EQ(box.wait(mark, std::chrono::steady_clock::now() + 30ms, nullptr), + MailboxActivity::None); + + std::atomic<bool> abort{false}; + std::thread aborter([&abort] { + std::this_thread::sleep_for(50ms); + abort.store(true); + }); + const auto started = std::chrono::steady_clock::now(); + EXPECT_EQ(box.wait(mark, started + 10s, &abort), MailboxActivity::None); + aborter.join(); + EXPECT_LT(std::chrono::steady_clock::now() - started, 5s); + + box.close(); + EXPECT_EQ(box.wait(mark, std::chrono::steady_clock::now() + 10s, nullptr), + MailboxActivity::None); +} diff --git a/tests/agent/characterization_lifecycle_test.cpp b/tests/agent/characterization_lifecycle_test.cpp index 5bdbe937d..5356bcfc2 100644 --- a/tests/agent/characterization_lifecycle_test.cpp +++ b/tests/agent/characterization_lifecycle_test.cpp @@ -196,18 +196,16 @@ TEST(AgentLoopLifecycleGolden, StopHookActiveSurvivesCappedTurnUntilNextStopDeci // 场景:会话级蜂群模式(星型)下跑一个回合,随后把模式切回 off 再跑普通回合;另外 // 用户消息 metadata 里残留旧的 swarm_mode=true 布尔。 // 期望:仅首轮有 swarm 上下文,模式切换只影响下一回合,静态前缀不变;消息 metadata -// 不再驱动模式(add-mesh-swarm-mode 把蜂群模式升级为会话级,由 set_swarm_context -// 发布)。回归会让关掉蜂群后的普通聊天继续错误地启动群体协作。 +// 不再驱动模式(add-mesh-swarm-mode 把蜂群模式升级为会话级,AgentLoop 每回合从 +// SessionManager 读取)。回归会让关掉蜂群后的普通聊天继续错误地启动群体协作。 TEST(AgentLoopLifecycleGolden, SwarmModeFollowsSessionContextBetweenTurns) { Isolation isolation; Harness h(isolation); h.tools.register_tool(h.probe("spawn_subagent", true)); - agent::SwarmContext star; - star.mode = SwarmMode::Star; - h.loop->set_swarm_context(star); + h.session->set_swarm_mode("star"); h.provider->push_text("swarm finished"); ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("coordinate this turn"); })); - h.loop->set_swarm_context(agent::SwarmContext{}); + h.session->set_swarm_mode("off"); h.provider->push_text("ordinary finished"); UserInput stale; stale.text = "ordinary next turn"; diff --git a/tests/config/config_swarm_test.cpp b/tests/config/config_swarm_test.cpp new file mode 100644 index 000000000..b4506c880 --- /dev/null +++ b/tests/config/config_swarm_test.cpp @@ -0,0 +1,75 @@ +// 覆盖 src/base/config/swarm_config.cpp(add-mesh-swarm-mode):蜂群模式(网状)的 +// 调优项,默认值对齐 Codex MultiAgentV2Config —— 并发上限 4(含根 agent)、 +// agent_wait 最短 10s / 默认 30s / 最长 1h、默认向模型展示可选模型。 +// 越界值静默夹取(只是调优值,不值得阻塞启动);写回时只写非默认项(稀疏写)。 + +#include <gtest/gtest.h> + +#include "config/config.hpp" + +#include <nlohmann/json.hpp> + +using namespace acecode; + +// 场景:config.json 没有 swarm 段的机器。 +// 期望:结构体默认值与 Codex 一致,序列化结果为空对象(不往配置里写默认值)。 +TEST(ConfigSwarm, DefaultsMatchCodexAndSerializeSparse) { + SwarmConfig cfg; + EXPECT_EQ(cfg.mesh.max_concurrent_agents, 4); + EXPECT_EQ(cfg.mesh.min_wait_timeout_ms, 10000); + EXPECT_EQ(cfg.mesh.default_wait_timeout_ms, 30000); + EXPECT_EQ(cfg.mesh.max_wait_timeout_ms, 3600000); + EXPECT_TRUE(cfg.mesh.expose_model_overrides); + EXPECT_TRUE(swarm_config_to_json(cfg).empty()); + AppConfig app; + EXPECT_EQ(app.swarm.mesh.max_concurrent_agents, 4); +} + +// 场景:用户调大并发、关掉模型覆盖展示。 +// 期望:读入后原样生效;写回只包含改动的两项,往返一致。 +TEST(ConfigSwarm, LoadsOverridesAndRoundTrips) { + SwarmConfig cfg; + load_swarm_config_json( + {{"mesh", {{"max_concurrent_agents", 8}, {"expose_model_overrides", false}}}}, cfg); + EXPECT_EQ(cfg.mesh.max_concurrent_agents, 8); + EXPECT_FALSE(cfg.mesh.expose_model_overrides); + const auto json = swarm_config_to_json(cfg); + EXPECT_EQ(json, (nlohmann::json{{"mesh", + {{"max_concurrent_agents", 8}, {"expose_model_overrides", false}}}})); + SwarmConfig reloaded; + load_swarm_config_json(json, reloaded); + EXPECT_EQ(reloaded.mesh.max_concurrent_agents, 8); + EXPECT_FALSE(reloaded.mesh.expose_model_overrides); +} + +// 场景:越界或类型错误的调优值。 +// 期望:并发夹到 [2, 64](1 会让根 agent 没有任何子 agent 名额);等待区间保持 +// min <= default <= max;非整数字段忽略,沿用默认值,不抛异常。 +TEST(ConfigSwarm, ClampsOutOfRangeValues) { + SwarmConfig low; + load_swarm_config_json({{"mesh", {{"max_concurrent_agents", 1}, + {"min_wait_timeout_ms", 5}, + {"default_wait_timeout_ms", 1}, + {"max_wait_timeout_ms", 2}}}}, + low); + EXPECT_EQ(low.mesh.max_concurrent_agents, 2); + EXPECT_EQ(low.mesh.min_wait_timeout_ms, 1000); + EXPECT_EQ(low.mesh.max_wait_timeout_ms, 1000); + EXPECT_EQ(low.mesh.default_wait_timeout_ms, 1000); + + SwarmConfig high; + load_swarm_config_json({{"mesh", {{"max_concurrent_agents", 1000}, + {"default_wait_timeout_ms", 99999999}}}}, + high); + EXPECT_EQ(high.mesh.max_concurrent_agents, 64); + EXPECT_EQ(high.mesh.default_wait_timeout_ms, high.mesh.max_wait_timeout_ms); + + SwarmConfig wrong_type; + load_swarm_config_json({{"mesh", {{"max_concurrent_agents", "8"}, + {"expose_model_overrides", 1}}}}, + wrong_type); + EXPECT_EQ(wrong_type.mesh.max_concurrent_agents, 4); + EXPECT_TRUE(wrong_type.mesh.expose_model_overrides); + load_swarm_config_json(nlohmann::json::array(), wrong_type); + EXPECT_EQ(wrong_type.mesh.max_concurrent_agents, 4); +} diff --git a/tests/headless/headless_options_test.cpp b/tests/headless/headless_options_test.cpp index c97c0ae58..fad02a4cc 100644 --- a/tests/headless/headless_options_test.cpp +++ b/tests/headless/headless_options_test.cpp @@ -454,3 +454,24 @@ TEST(HeadlessOptions, SessionIdTokenBoundaries) { EXPECT_FALSE(acecode::headless::is_valid_session_id_token(std::string(65, 'x'))); EXPECT_FALSE(acecode::headless::is_valid_session_id_token("")); } + +// 场景:headless 指定本会话的蜂群模式(add-mesh-swarm-mode),空格与等号两种形式。 +// 期望:star / mesh / off 原样透传;其它值报错并带上非法值;--swarm 属于执行参数, +// 与 --list-tools 等发现模式混用时报用法错误。 +TEST(HeadlessOptions, ParsesAndValidatesSwarmMode) { + for (const char* mode : {"star", "mesh", "off"}) { + auto space = parse_headless_cli_options({"-p", "--swarm", mode, "plan it"}); + EXPECT_TRUE(space.error.empty()) << mode << ": " << space.error; + EXPECT_EQ(space.swarm_mode, mode); + EXPECT_EQ(space.prompt, "plan it"); + auto eq = parse_headless_cli_options({"-p", std::string("--swarm=") + mode, "plan it"}); + EXPECT_TRUE(eq.error.empty()) << mode << ": " << eq.error; + EXPECT_EQ(eq.swarm_mode, mode); + } + auto bad = parse_headless_cli_options({"-p", "--swarm", "grid", "x"}); + EXPECT_NE(bad.error.find("invalid --swarm: grid"), std::string::npos) << bad.error; + auto missing = parse_headless_cli_options({"-p", "x", "--swarm"}); + EXPECT_FALSE(missing.error.empty()); + auto mixed = parse_headless_cli_options({"-p", "--list-tools", "--swarm", "mesh"}); + EXPECT_NE(mixed.error.find("cannot be combined"), std::string::npos) << mixed.error; +} diff --git a/tests/session/mesh_swarm_domain_test.cpp b/tests/session/mesh_swarm_domain_test.cpp new file mode 100644 index 000000000..f009712b4 --- /dev/null +++ b/tests/session/mesh_swarm_domain_test.cpp @@ -0,0 +1,223 @@ +#include <gtest/gtest.h> + +#include "llm/message_predicates.hpp" +#include "session/agent_fork_history.hpp" +#include "session/inter_agent_message.hpp" +#include "session/mesh_tree_index.hpp" +#include "session/swarm_mode.hpp" +#include "utils/utf8_path.hpp" + +#include <nlohmann/json.hpp> + +#include <filesystem> +#include <fstream> +#include <random> +#include <string> +#include <vector> + +// 蜂群模式(网状)domain 层纯逻辑(add-mesh-swarm-mode):模式名解析与工具族互斥、 +// agent 间信封、fork_turns 历史裁剪、agent 树索引落盘。 + +namespace fs = std::filesystem; +using namespace acecode; +using namespace acecode::mesh; + +namespace { + +ChatMessage text_message(const std::string& role, const std::string& content) { + ChatMessage message; + message.role = role; + message.content = content; + message.metadata = nlohmann::json::object(); + return message; +} + +ChatMessage envelope_message(InterAgentMessageType type, const std::string& sender, + const std::string& recipient, const std::string& payload, + const std::string& status = {}) { + InterAgentEnvelope envelope; + envelope.type = type; + envelope.sender = sender; + envelope.recipient = recipient; + envelope.payload = payload; + envelope.final_status = status; + ChatMessage message = text_message("user", render_inter_agent_message(envelope)); + message.metadata[kInterAgentMetadataKey] = inter_agent_metadata(envelope); + return message; +} + +ChatMessage tool_call_message() { + ChatMessage message = text_message("assistant", ""); + message.tool_calls = nlohmann::json::array( + {{{"id", "c1"}, {"type", "function"}, + {"function", {{"name", "bash"}, {"arguments", "{}"}}}}}); + return message; +} + +} // namespace + +// 场景:Web 旧客户端仍发布尔 swarm_mode,新客户端发 "star"/"mesh"/"off"。 +// 期望:true=star、false=off,大小写不敏感;空串与未知值返回 nullopt(由调用方报错)。 +TEST(MeshSwarmMode, ParsesCanonicalNamesAndLegacyBooleans) { + EXPECT_EQ(parse_swarm_mode("mesh"), SwarmMode::Mesh); + EXPECT_EQ(parse_swarm_mode("STAR"), SwarmMode::Star); + EXPECT_EQ(parse_swarm_mode("off"), SwarmMode::Off); + EXPECT_EQ(parse_swarm_mode("true"), SwarmMode::Star); + EXPECT_EQ(parse_swarm_mode("false"), SwarmMode::Off); + EXPECT_FALSE(parse_swarm_mode("").has_value()); + EXPECT_FALSE(parse_swarm_mode("grid").has_value()); + EXPECT_STREQ(swarm_mode_name(SwarmMode::Mesh), "mesh"); + EXPECT_STREQ(swarm_mode_name(SwarmMode::Off), "off"); +} + +// 场景:两套协作工具必须互斥 —— 网状下还能看到 spawn_subagent / 线程工具, +// 模型就有两条互相冲突的派发路径(用户拍板:网状模式隐藏星型与线程工具)。 +// 期望:off/star 隐藏全部 agent_* 工具;mesh 隐藏 spawn/wait_subagent 与线程工具, +// 且 agent_* 本身可见;拒绝文案指向替代工具。 +TEST(MeshSwarmMode, ToolFamiliesAreMutuallyExclusive) { + for (const SwarmMode mode : {SwarmMode::Off, SwarmMode::Star}) { + const auto hidden = swarm_mode_hidden_tools(mode); + for (const auto& name : mesh_agent_tool_names()) { + ASSERT_TRUE(hidden.count(name)) << name; + } + EXPECT_FALSE(hidden.count("spawn_subagent")); + } + const auto mesh_hidden = swarm_mode_hidden_tools(SwarmMode::Mesh); + for (const auto& name : mesh_agent_tool_names()) EXPECT_FALSE(mesh_hidden.count(name)) << name; + EXPECT_EQ(mesh_hidden.at("spawn_subagent"), + "spawn_subagent is not available in mesh swarm mode; use agent_spawn instead."); + EXPECT_TRUE(mesh_hidden.count("wait_subagent")); + EXPECT_TRUE(mesh_hidden.count("send_message_to_thread")); + EXPECT_TRUE(mesh_hidden.count("create_thread")); +} + +// 场景:agent 间信封是 Codex InterAgentMessage 文本外包 <inter_agent_message> +// (有意偏离:Codex 用 assistant 角色,这里用 user 角色避免两条 assistant 相邻)。 +// 期望:正文逐行对齐 Codex;metadata 往返不丢字段;NEW_TASK 算真实用户消息 +// (上下文锚点),MESSAGE / FINAL_ANSWER 是内部上下文。 +TEST(MeshInterAgentMessage, RendersCodexBodyAndClassifiesTypes) { + const auto task = envelope_message(InterAgentMessageType::NewTask, "/root", "/root/worker", + "Investigate the flaky test"); + EXPECT_EQ(task.content, + "<inter_agent_message>\nMessage Type: NEW_TASK\nTask name: /root/worker\n" + "Sender: /root\nPayload:\nInvestigate the flaky test\n</inter_agent_message>"); + const auto parsed = inter_agent_envelope_from_metadata(task.metadata); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->type, InterAgentMessageType::NewTask); + EXPECT_EQ(parsed->sender, "/root"); + EXPECT_EQ(parsed->recipient, "/root/worker"); + EXPECT_TRUE(is_inter_agent_message(task)); + EXPECT_TRUE(is_real_user_message(task)); + + const auto note = envelope_message(InterAgentMessageType::Message, "/root/a", "/root/b", "fyi"); + EXPECT_TRUE(is_inter_agent_message(note)); + EXPECT_FALSE(is_real_user_message(note)); + EXPECT_FALSE(is_inter_agent_message(text_message("user", "plain"))); +} + +// 场景:TUI 把落盘的 user 信封显示成系统行,不能把 <inter_agent_message> 原文糊给用户。 +// 期望:三种类型各有一行标题 + Payload 正文;普通消息返回空串(走原显示路径); +// Payload 里再出现 "Payload:" 字样也不会被截错。 +TEST(MeshInterAgentMessage, DisplayTextShowsHeaderAndPayload) { + EXPECT_EQ(inter_agent_display_text(envelope_message( + InterAgentMessageType::NewTask, "/root", "/root/w", "do it")), + "Task from /root to /root/w:\ndo it"); + EXPECT_EQ(inter_agent_display_text(envelope_message( + InterAgentMessageType::Message, "/root/a", "/root/b", "x\nPayload:\ny")), + "Message from /root/a to /root/b:\nx\nPayload:\ny"); + EXPECT_EQ(inter_agent_display_text(envelope_message( + InterAgentMessageType::FinalAnswer, "/root/w", "/root", "done", "completed")), + "Agent /root/w completed:\ndone"); + EXPECT_EQ(inter_agent_display_text(text_message("user", "hello")), ""); + EXPECT_EQ(inter_agent_payload_from_content("no envelope"), ""); +} + +// 场景:agent_spawn 的 fork_turns 参数(Codex 同款取值与错误文案)。 +// 期望:空/all = 全部,none = 不继承,正整数 = 最近 N 轮;0、负数、小数、文字报错。 +TEST(MeshForkHistory, ParseForkTurnsMatchesCodex) { + EXPECT_EQ(parse_fork_turns("")->kind, ForkTurns::Kind::All); + EXPECT_EQ(parse_fork_turns(" ALL ")->kind, ForkTurns::Kind::All); + EXPECT_EQ(parse_fork_turns("none")->kind, ForkTurns::Kind::None); + const auto last = parse_fork_turns("2"); + ASSERT_TRUE(last.has_value()); + EXPECT_EQ(last->kind, ForkTurns::Kind::LastN); + EXPECT_EQ(last->last_n, 2u); + for (const char* bad : {"0", "-1", "1.5", "some"}) { + std::string error; + EXPECT_FALSE(parse_fork_turns(bad, &error).has_value()) << bad; + EXPECT_EQ(error, "fork_turns must be `none`, `all`, or a positive integer string"); + } +} + +// 场景:子 agent 继承父会话历史时只保留「用户消息 + 最终回答」,工具调用 / 结果、 +// 推理与 agent 间信封全部丢弃(Codex keep_forked_rollout_item)。 +// 期望:all 保留两轮的 user + 最终 assistant 并打 mesh_inherited 标记、清掉推理; +// LastN=1 只从最后一个轮次边界开始;none 为空。NEW_TASK 信封算轮次边界但本身不继承。 +TEST(MeshForkHistory, KeepsUserTurnsAndFinalAnswersOnly) { + std::vector<ChatMessage> history; + history.push_back(text_message("user", "first question")); + history.push_back(tool_call_message()); + history.push_back(text_message("tool", "tool output")); + auto answer = text_message("assistant", "first answer"); + answer.reasoning_content = "hidden reasoning"; + history.push_back(answer); + history.push_back(envelope_message(InterAgentMessageType::NewTask, "/root", "/root/me", "task")); + history.push_back(envelope_message(InterAgentMessageType::Message, "/root/x", "/root/me", "fyi")); + history.push_back(text_message("assistant", "second answer")); + + const auto all = build_fork_history(history, ForkTurns{}); + ASSERT_EQ(all.size(), 3u); + EXPECT_EQ(all[0].content, "first question"); + EXPECT_EQ(all[1].content, "first answer"); + EXPECT_TRUE(all[1].reasoning_content.empty()); + EXPECT_EQ(all[2].content, "second answer"); + for (const auto& message : all) EXPECT_TRUE(message.metadata.value("mesh_inherited", false)); + + ForkTurns last_one; + last_one.kind = ForkTurns::Kind::LastN; + last_one.last_n = 1; + const auto recent = build_fork_history(history, last_one); + ASSERT_EQ(recent.size(), 1u); + EXPECT_EQ(recent[0].content, "second answer"); + + ForkTurns none; + none.kind = ForkTurns::Kind::None; + EXPECT_TRUE(build_fork_history(history, none).empty()); +} + +// 场景:daemon 重启后靠根会话目录里的 mesh_agents.json 重建 agent 树。 +// 期望:写入后原样读回;缺文件返回空;根路径 / 非法路径 / 空 session_id 条目被跳过, +// 不让一条坏数据毁掉整棵树;JSON 损坏时整体返回空而不是抛异常。 +TEST(MeshTreeIndex, RoundTripsAndSkipsMalformedEntries) { + const fs::path dir = fs::temp_directory_path() / + ("acecode_mesh_tree_index_" + std::to_string(std::random_device{}())); + fs::remove_all(dir); + const std::string project_dir = path_to_utf8(dir); + EXPECT_TRUE(read_mesh_tree_index(project_dir, "root-1").empty()); + + ASSERT_TRUE(write_mesh_tree_index(project_dir, "root-1", + {{"/root/a", "s-a"}, {"/root/a/b", "s-b"}})); + const auto entries = read_mesh_tree_index(project_dir, "root-1"); + ASSERT_EQ(entries.size(), 2u); + EXPECT_EQ(entries[1].path, "/root/a/b"); + EXPECT_EQ(entries[1].session_id, "s-b"); + + { + std::ofstream out(path_from_utf8(mesh_tree_index_path(project_dir, "root-1")), + std::ios::binary | std::ios::trunc); + out << R"({"version":1,"agents":[{"path":"/root","session_id":"r"},)" + << R"({"path":"/root/Bad","session_id":"x"},{"path":"/root/ok","session_id":""},)" + << R"({"path":"/root/good","session_id":"g"},7]})"; + } + const auto filtered = read_mesh_tree_index(project_dir, "root-1"); + ASSERT_EQ(filtered.size(), 1u); + EXPECT_EQ(filtered[0].path, "/root/good"); + + { + std::ofstream out(path_from_utf8(mesh_tree_index_path(project_dir, "root-1")), + std::ios::binary | std::ios::trunc); + out << "{not json"; + } + EXPECT_TRUE(read_mesh_tree_index(project_dir, "root-1").empty()); + fs::remove_all(dir); +} diff --git a/tests/session_host/mesh_agent_service_test.cpp b/tests/session_host/mesh_agent_service_test.cpp new file mode 100644 index 000000000..1799d627e --- /dev/null +++ b/tests/session_host/mesh_agent_service_test.cpp @@ -0,0 +1,656 @@ +// 覆盖 src/host/session_host/mesh/ 与 tools/mesh_agent_tools.cpp(add-mesh-swarm-mode): +// 蜂群模式(网状)= Codex Multi-Agent V2 的复刻。这里用真实 SessionRegistry + 脚本化 +// provider 跑完整的 agent 树:spawn → 子 agent 自动开工 → FINAL_ANSWER 回报父 agent; +// send_message 只排队、followup_task 唤醒;并发上限下的 LRU 换出与按需恢复; +// interrupt 不回报;退出网状模式的守卫;重启后按索引重建树。 +// +// provider 规则(按最后一条 user 消息的 Payload 判定):含 BLOCK 时阻塞到 release() +// 或回合被中止;含 FAIL 时返回不可重试的 400;否则回复 "answer:<payload>"。 + +#include <gtest/gtest.h> + +#include "agent/agent_loop.hpp" +#include "config/config.hpp" +#include "permissions/permissions.hpp" +#include "session/inter_agent_message.hpp" +#include "session/mesh_tree_index.hpp" +#include "session/session_manager.hpp" +#include "session_host/mesh/mesh_agent_service.hpp" +#include "session_host/session_registry.hpp" +#include "session_host/tools/mesh_agent_tools.hpp" +#include "tool/tool_executor.hpp" +#include "utils/paths.hpp" + +#include <nlohmann/json.hpp> + +#include <atomic> +#include <chrono> +#include <condition_variable> +#include <cstdlib> +#include <filesystem> +#include <functional> +#include <memory> +#include <mutex> +#include <random> +#include <string> +#include <thread> +#include <vector> + +using namespace std::chrono_literals; +namespace fs = std::filesystem; +using acecode::mesh::AgentStatus; +using acecode::mesh::MeshAgentService; + +namespace { + +class MeshScriptProvider : public acecode::LlmProvider { +public: + acecode::ChatResponse chat(const std::vector<acecode::ChatMessage>&, + const std::vector<acecode::ToolDef>&) override { + acecode::ChatResponse response; + response.content = "unused"; + response.finish_reason = "stop"; + return response; + } + + void chat_stream(const std::vector<acecode::ChatMessage>& messages, + const std::vector<acecode::ToolDef>&, + const acecode::StreamCallback& callback, + std::atomic<bool>* abort_flag = nullptr) override { + std::string payload; + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + if (it->role != "user") continue; + payload = acecode::mesh::inter_agent_payload_from_content(it->content); + if (payload.empty()) payload = it->content; + break; + } + { + std::lock_guard<std::mutex> lock(mu_); + requests_.push_back(messages); + payloads_.push_back(payload); + } + cv_.notify_all(); + acecode::StreamEvent done; + done.type = acecode::StreamEventType::Done; + if (payload.find("BLOCK") != std::string::npos) { + std::unique_lock<std::mutex> lock(mu_); + while (!released_ && !(abort_flag && abort_flag->load())) { + cv_.wait_for(lock, 5ms); + } + if (abort_flag && abort_flag->load()) { + lock.unlock(); + callback(done); + return; + } + } + if (payload.find("FAIL") != std::string::npos) { + acecode::StreamEvent error; + error.type = acecode::StreamEventType::Error; + error.provider_error.kind = acecode::ProviderErrorKind::Http; + error.provider_error.status_code = 400; + error.provider_error.display_message = "bad request boom"; + error.error = "bad request boom"; + callback(error); + return; + } + acecode::StreamEvent delta; + delta.type = acecode::StreamEventType::Delta; + delta.content = "answer:" + payload; + callback(delta); + callback(done); + } + + std::string name() const override { return "mesh-stub"; } + bool is_authenticated() override { return true; } + std::string model() const override { return "mesh-stub-1"; } + void set_model(const std::string&) override {} + + void release() { + { + std::lock_guard<std::mutex> lock(mu_); + released_ = true; + } + cv_.notify_all(); + } + int request_count() const { + std::lock_guard<std::mutex> lock(mu_); + return static_cast<int>(payloads_.size()); + } + std::vector<std::string> payloads() const { + std::lock_guard<std::mutex> lock(mu_); + return payloads_; + } + std::vector<acecode::ChatMessage> request(int index) const { + std::lock_guard<std::mutex> lock(mu_); + return requests_.at(static_cast<std::size_t>(index)); + } + +private: + mutable std::mutex mu_; + std::condition_variable cv_; + std::vector<std::vector<acecode::ChatMessage>> requests_; + std::vector<std::string> payloads_; + bool released_ = false; +}; + +bool wait_until(const std::function<bool()>& predicate, std::chrono::milliseconds timeout = 10s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) return true; + std::this_thread::sleep_for(5ms); + } + return predicate(); +} + +bool any_content_contains(const std::vector<acecode::ChatMessage>& messages, + const std::string& needle) { + for (const auto& message : messages) { + if (message.content.find(needle) != std::string::npos) return true; + } + return false; +} + +const char* home_key() { +#ifdef _WIN32 + return "USERPROFILE"; +#else + return "HOME"; +#endif +} + +void set_env(const char* key, const std::string& value) { +#ifdef _WIN32 + _putenv_s(key, value.c_str()); +#else + setenv(key, value.c_str(), 1); +#endif +} + +class MeshAgentServiceTest : public testing::Test { +protected: + fs::path home; + std::string workspace; + acecode::ToolExecutor tools; + acecode::PermissionManager permissions; + acecode::AppConfig config; + std::shared_ptr<MeshScriptProvider> provider = std::make_shared<MeshScriptProvider>(); + std::unique_ptr<acecode::SessionRegistry> registry; + std::shared_ptr<MeshAgentService> service; + std::string root_id; + + void SetUp() override { + home = fs::temp_directory_path() / + ("acecode_mesh_service_" + std::to_string(std::random_device{}())); + fs::create_directories(home / "ws"); + workspace = (home / "ws").string(); + if (const char* previous = std::getenv(home_key())) { + previous_home_ = previous; + had_home_ = true; + } + set_env(home_key(), home.string()); + previous_mode_ = acecode::override_run_mode_for_test(acecode::RunMode::User); + acecode::reset_data_dir_cache_for_test(); + + acecode::SessionRegistryDeps deps; + deps.provider_accessor = [p = provider] { return std::shared_ptr<acecode::LlmProvider>(p); }; + deps.tools = &tools; + deps.cwd = workspace; + deps.template_permissions = &permissions; + deps.no_workspace_cache_root = (home / "no-workspace").string(); + deps.auto_title_generator = [](const std::string&) { + return std::optional<std::string>{"title"}; + }; + registry = std::make_unique<acecode::SessionRegistry>(std::move(deps)); + } + + void TearDown() override { + provider->release(); + if (service) service->shutdown(); + service.reset(); + if (registry) registry->shutdown_all(); + registry.reset(); + if (had_home_) { + set_env(home_key(), previous_home_); + } else { + set_env(home_key(), ""); + } + acecode::override_run_mode_for_test(previous_mode_); + acecode::reset_data_dir_cache_for_test(); + std::error_code ec; + fs::remove_all(home, ec); + } + + // 配置在服务启动前定好(工具描述与上限都在注册 / 调用时读取)。 + void start_service() { + MeshAgentService::Deps deps; + deps.registry = registry.get(); + deps.config = &config; + service = std::make_shared<MeshAgentService>(std::move(deps)); + service->attach(); + } + + std::string create_root(const std::string& swarm_mode = "mesh") { + acecode::SessionOptions opts; + opts.cwd = workspace; + opts.swarm_mode = swarm_mode; + const std::string id = registry->create(opts); + if (swarm_mode == "mesh") root_id = id; + return id; + } + + acecode::ToolContext ctx_for(const std::string& session_id) { + acecode::ToolContext ctx; + ctx.cwd = workspace; + ctx.session_id = session_id; + auto entry = registry->acquire(session_id); + if (entry) { + ctx.session_manager = entry->sm.get(); + kept_.push_back(entry); + } + return ctx; + } + + acecode::AgentLoop* loop(const std::string& session_id) { + auto entry = registry->acquire(session_id); + if (!entry) return nullptr; + kept_.push_back(entry); + return entry->loop.get(); + } + + std::size_t root_mail() { + auto* root = loop(root_id); + return root ? root->pending_mailbox_count() : 0; + } + + acecode::mesh::SpawnResult spawn(const std::string& caller, const std::string& task, + const std::string& message) { + acecode::mesh::SpawnArgs args; + args.task_name = task; + args.message = message; + return service->spawn(ctx_for(caller), args); + } + + std::vector<acecode::mesh::ListedAgent> list(const std::string& caller, + const std::string& prefix = {}) { + std::vector<acecode::mesh::ListedAgent> agents; + EXPECT_EQ(service->list(ctx_for(caller), prefix, agents), ""); + return agents; + } + + static const acecode::mesh::ListedAgent* find( + const std::vector<acecode::mesh::ListedAgent>& agents, const std::string& path) { + for (const auto& agent : agents) { + if (agent.path == path) return &agent; + } + return nullptr; + } + +private: + std::vector<std::shared_ptr<acecode::SessionEntry>> kept_; + std::string previous_home_; + bool had_home_ = false; + acecode::RunMode previous_mode_ = acecode::RunMode::User; +}; + +} // namespace + +// 场景:根 agent agent_spawn 一个子 agent。 +// 期望:子会话挂在根下(parent_session_id = 根 id,路径 /root/worker,模式 mesh,标题 = 任务名), +// 收到 NEW_TASK 信封后自动开工;结束后 FINAL_ANSWER 进根 agent 邮箱但**不唤醒**空闲的根 +// (Codex:子 agent 完成不唤醒父 agent);根的下一个回合能看到这份最终回答;树索引已落盘。 +TEST_F(MeshAgentServiceTest, SpawnRunsChildAndRoutesFinalAnswerToIdleRoot) { + start_service(); + create_root(); + const auto spawned = spawn(root_id, "worker", "write the report"); + ASSERT_EQ(spawned.error, ""); + EXPECT_EQ(spawned.path, "/root/worker"); + ASSERT_FALSE(spawned.session_id.empty()); + + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + std::this_thread::sleep_for(150ms); + EXPECT_EQ(provider->request_count(), 1); + EXPECT_FALSE(loop(root_id)->is_busy()); + + const auto first = provider->request(0); + ASSERT_FALSE(first.empty()); + EXPECT_EQ(first.back().content, + "<inter_agent_message>\nMessage Type: NEW_TASK\nTask name: /root/worker\n" + "Sender: /root\nPayload:\nwrite the report\n</inter_agent_message>"); + + auto child = registry->acquire(spawned.session_id); + ASSERT_TRUE(child); + EXPECT_EQ(child->sm->current_parent_session_id(), root_id); + EXPECT_EQ(child->sm->current_swarm_mode(), "mesh"); + EXPECT_EQ(child->sm->current_agent_path(), "/root/worker"); + + const auto agents = list(root_id); + ASSERT_EQ(agents.size(), 2u); + EXPECT_EQ(agents[0].path, "/root"); + const auto* worker = find(agents, "/root/worker"); + ASSERT_NE(worker, nullptr); + EXPECT_EQ(acecode::mesh::agent_status_to_json(worker->status), + (nlohmann::json{{"completed", "answer:write the report"}})); + + const auto index = acecode::mesh::read_mesh_tree_index( + child->sm->current_project_dir(), root_id); + ASSERT_EQ(index.size(), 1u); + EXPECT_EQ(index[0].session_id, spawned.session_id); + + // 根的下一个回合:用户消息之后并入子 agent 的 FINAL_ANSWER。 + loop(root_id)->submit("how did it go?"); + ASSERT_TRUE(wait_until([&] { return provider->request_count() == 2; })); + EXPECT_TRUE(any_content_contains(provider->request(1), + "Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/worker\nPayload:\n" + "answer:write the report")); + ASSERT_TRUE(wait_until([&] { return !loop(root_id)->is_busy(); })); +} + +// 场景:对已完成的子 agent 先 agent_send_message 再 agent_followup_task。 +// 期望:send_message 只排队不开回合;followup_task 唤醒它,唤醒回合同时带上之前排队的 +// 消息(先排队的在前),完成后第二份 FINAL_ANSWER 进根邮箱;各类错误文案对齐 Codex。 +TEST_F(MeshAgentServiceTest, FollowupWakesIdleChildWhileSendMessageOnlyQueues) { + start_service(); + create_root(); + const auto spawned = spawn(root_id, "worker", "first task"); + ASSERT_EQ(spawned.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + + const auto root_ctx = ctx_for(root_id); + EXPECT_EQ(service->deliver(root_ctx, "worker", "fyi only", false), ""); + std::this_thread::sleep_for(150ms); + EXPECT_EQ(provider->request_count(), 1); + EXPECT_EQ(loop(spawned.session_id)->pending_mailbox_count(), 1u); + + EXPECT_EQ(service->deliver(root_ctx, "/root/worker", "second task", true), ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 2; })); + ASSERT_EQ(provider->request_count(), 2); + const auto woken = provider->request(1); + EXPECT_TRUE(any_content_contains(woken, "Message Type: MESSAGE\nTask name: /root/worker\n" + "Sender: /root\nPayload:\nfyi only")); + EXPECT_EQ(provider->payloads()[1], "second task"); + + EXPECT_EQ(service->deliver(root_ctx, "/root", "x", true), + "Follow-up tasks can't target the root agent"); + EXPECT_EQ(service->deliver(root_ctx, "nobody", "x", false), + "live agent path `/root/nobody` not found"); + EXPECT_EQ(service->deliver(root_ctx, "worker", " \n", false), + "Empty message can't be sent to an agent"); +} + +// 场景:agent_spawn 的参数与调用方校验。 +// 期望:非网状会话调用被拒;非法任务名、未知模型、无团队专家时的 agent_type、重名路径、 +// 空消息都返回 Codex 同款(或对应改写的)错误,且不创建任何子会话。 +TEST_F(MeshAgentServiceTest, SpawnValidatesCallerAndArguments) { + acecode::ModelProfile fast; + fast.name = "fast"; + fast.provider = "openai"; + fast.model = "gpt-fast"; + config.saved_models.push_back(fast); + start_service(); + const std::string plain = create_root("off"); + EXPECT_EQ(spawn(plain, "worker", "go").error, + "agent collaboration tools are only available in mesh swarm mode."); + create_root(); + EXPECT_EQ(spawn(root_id, "Bad-Name", "go").error, + "agent_name must use only lowercase letters, digits, and underscores"); + EXPECT_EQ(spawn(root_id, "worker", " ").error, "Empty message can't be sent to an agent"); + + acecode::mesh::SpawnArgs args; + args.task_name = "worker"; + args.message = "go"; + args.model = "missing"; + EXPECT_EQ(service->spawn(ctx_for(root_id), args).error, + "Unknown model `missing` for agent_spawn. Available models: fast"); + args.model.clear(); + args.agent_type = "reviewer"; + EXPECT_EQ(service->spawn(ctx_for(root_id), args).error, + "agent_type is only available when this session is bound to a team expert"); + const std::size_t sessions_before = registry->size(); + + ASSERT_EQ(spawn(root_id, "worker", "go").error, ""); + EXPECT_EQ(spawn(root_id, "worker", "again").error, "agent path `/root/worker` already exists"); + EXPECT_EQ(registry->size(), sessions_before + 1); +} + +// 场景:并发上限 3(根占 1 个名额,子 agent 最多驻留 2 个),依次派出 a、b、c。 +// 期望:派 c 时换出最久没有活动且已完成的 a(Codex V2Residency 的 LRU);a 不再出现在 +// agent_list 里,但树里仍有记录;对 a 发 followup_task 会从磁盘恢复它(这次换出 b), +// 恢复后的 a 带着原来的上下文处理新任务。 +TEST_F(MeshAgentServiceTest, FullTreeEvictsLeastRecentlyActiveAgentAndRestoresOnDemand) { + config.swarm.mesh.max_concurrent_agents = 3; + start_service(); + create_root(); + const auto a = spawn(root_id, "a", "task a"); + ASSERT_EQ(a.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + const auto b = spawn(root_id, "b", "task b"); + ASSERT_EQ(b.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 2; })); + const auto c = spawn(root_id, "c", "task c"); + ASSERT_EQ(c.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 3; })); + + EXPECT_EQ(registry->acquire(a.session_id), nullptr); + const auto listed = list(root_id); + EXPECT_EQ(find(listed, "/root/a"), nullptr); + EXPECT_NE(find(listed, "/root/b"), nullptr); + EXPECT_NE(find(listed, "/root/c"), nullptr); + bool a_known_unloaded = false; + for (const auto& info : service->tree_agents(root_id)) { + if (info.path == "/root/a") a_known_unloaded = !info.loaded; + } + EXPECT_TRUE(a_known_unloaded); + + EXPECT_EQ(service->deliver(ctx_for(root_id), "a", "task a2", true), ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 4; })); + EXPECT_NE(registry->acquire(a.session_id), nullptr); + EXPECT_EQ(registry->acquire(b.session_id), nullptr); + const auto restored = provider->request(provider->request_count() - 1); + EXPECT_EQ(provider->payloads().back(), "task a2"); + EXPECT_TRUE(any_content_contains(restored, "answer:task a")); +} + +// 场景:并发上限 2(只能驻留 1 个子 agent),而这个子 agent 正在运行。 +// 期望:再派第二个返回 "collab spawn failed: agent thread limit reached",不会换出运行中的 +// agent;运行中的 agent 结束后名额可以复用。 +TEST_F(MeshAgentServiceTest, ThreadLimitReachedWhenNoAgentCanBeEvicted) { + config.swarm.mesh.max_concurrent_agents = 2; + start_service(); + create_root(); + const auto busy = spawn(root_id, "busy", "BLOCK until released"); + ASSERT_EQ(busy.error, ""); + ASSERT_TRUE(wait_until([&] { return provider->request_count() == 1; })); + EXPECT_EQ(spawn(root_id, "other", "go").error, + "collab spawn failed: agent thread limit reached"); + provider->release(); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + EXPECT_EQ(spawn(root_id, "other", "go").error, ""); +} + +// 场景:agent_interrupt 打断运行中的子 agent;根与自身不可被打断。 +// 期望:返回打断前状态 running;被打断的回合不发 FINAL_ANSWER(Codex:interrupted +// 不回报),之后状态是 interrupted;打断根报 "root is not a spawned agent", +// 打断自己报 Codex 原文。 +TEST_F(MeshAgentServiceTest, InterruptAbortsRunningChildWithoutFinalAnswer) { + start_service(); + create_root(); + const auto slow = spawn(root_id, "slow", "BLOCK please"); + ASSERT_EQ(slow.error, ""); + ASSERT_TRUE(wait_until([&] { return provider->request_count() == 1; })); + ASSERT_TRUE(wait_until([&] { return loop(slow.session_id)->is_busy(); })); + + AgentStatus previous; + EXPECT_EQ(service->interrupt(ctx_for(root_id), "slow", previous), ""); + EXPECT_EQ(previous.kind, AgentStatus::Kind::Running); + ASSERT_TRUE(wait_until([&] { return !loop(slow.session_id)->is_busy(); })); + std::this_thread::sleep_for(200ms); + EXPECT_EQ(root_mail(), 0u); + const auto agents = list(root_id); + const auto* listed = find(agents, "/root/slow"); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(acecode::mesh::agent_status_to_json(listed->status), "interrupted"); + + EXPECT_EQ(service->interrupt(ctx_for(root_id), "/root", previous), + "root is not a spawned agent"); + EXPECT_EQ(service->interrupt(ctx_for(slow.session_id), "/root/slow", previous), + "an agent cannot interrupt itself; return your result and let the parent " + "interrupt you if needed"); +} + +// 场景:子 agent 的回合因 provider 400 失败。 +// 期望:父 agent 收到 FINAL_ANSWER(状态 errored),agent_list 显示 {"errored": ...}; +// 回报正文是 Codex 的 "Agent errored: ..." + 下一步提示。 +TEST_F(MeshAgentServiceTest, ErroredChildReportsAgentErroredToParent) { + start_service(); + create_root(); + const auto broken = spawn(root_id, "broken", "FAIL now"); + ASSERT_EQ(broken.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + const auto agents = list(root_id); + const auto* listed = find(agents, "/root/broken"); + ASSERT_NE(listed, nullptr); + EXPECT_EQ(listed->status.kind, AgentStatus::Kind::Errored); + EXPECT_FALSE(listed->status.errored.empty()); + + loop(root_id)->submit("status?"); + ASSERT_TRUE(wait_until([&] { return provider->request_count() == 2; })); + EXPECT_TRUE(any_content_contains(provider->request(1), + "Sender: /root/broken\nPayload:\nAgent errored: ")); + EXPECT_TRUE(any_content_contains(provider->request(1), + "This agent's turn failed. If you still need this agent, use the available " + "collaboration tools to give it another task.")); + ASSERT_TRUE(wait_until([&] { return !loop(root_id)->is_busy(); })); +} + +// 场景:子 agent /root/lead 再派孙 agent /root/lead/sub(深度不限)。 +// 期望:孙会话的 parent_session_id 仍是根 id(扁平挂在根下,复用后台任务面板); +// 孙的 FINAL_ANSWER 只进 lead 的邮箱,不进根;lead 视角的相对 path_prefix 生效。 +TEST_F(MeshAgentServiceTest, NestedChildReportsToItsParentNotRoot) { + start_service(); + create_root(); + const auto lead = spawn(root_id, "lead", "lead task"); + ASSERT_EQ(lead.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + + const auto sub = spawn(lead.session_id, "sub", "sub task"); + ASSERT_EQ(sub.error, ""); + EXPECT_EQ(sub.path, "/root/lead/sub"); + ASSERT_TRUE(wait_until([&] { return loop(lead.session_id)->pending_mailbox_count() == 1; })); + std::this_thread::sleep_for(150ms); + EXPECT_EQ(root_mail(), 1u); + EXPECT_EQ(registry->acquire(sub.session_id)->sm->current_parent_session_id(), root_id); + + const auto scoped = list(lead.session_id, "sub"); + ASSERT_EQ(scoped.size(), 1u); + EXPECT_EQ(scoped[0].path, "/root/lead/sub"); +} + +// 场景:根会话在子 agent 运行时尝试退出网状模式;子 agent 自己尝试改模式。 +// 期望:前者被守卫拒绝(否则运行中的 agent 失去回报对象),子 agent 结束后允许; +// 后者一律拒绝 —— 网状子 agent 的身份由路径决定,不能切成别的模式。 +TEST_F(MeshAgentServiceTest, LeavingMeshIsRefusedWhileAgentsRun) { + start_service(); + create_root(); + const auto busy = spawn(root_id, "busy", "BLOCK work"); + ASSERT_EQ(busy.error, ""); + ASSERT_TRUE(wait_until([&] { return provider->request_count() == 1; })); + + std::string error; + EXPECT_FALSE(registry->set_swarm_mode(root_id, acecode::SwarmMode::Off, &error)); + EXPECT_NE(error.find("is still running"), std::string::npos) << error; + EXPECT_FALSE(registry->set_swarm_mode(busy.session_id, acecode::SwarmMode::Star, &error)); + EXPECT_EQ(error, "swarm mode cannot be changed for a mesh swarm sub-agent"); + + provider->release(); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + ASSERT_TRUE(wait_until([&] { return !loop(busy.session_id)->is_busy(); })); + EXPECT_TRUE(registry->set_swarm_mode(root_id, acecode::SwarmMode::Off, &error)) << error; + EXPECT_EQ(registry->swarm_mode(root_id), acecode::SwarmMode::Off); +} + +// 场景:模拟 daemon 重启 —— 子会话已卸载、服务实例重建。 +// 期望:新服务按根会话目录里的索引重建树,子 agent 显示为未加载;对它发 followup_task +// 会经 SessionRegistry::resume 恢复并继续工作。 +TEST_F(MeshAgentServiceTest, RestartRebuildsTreeFromIndexAndRestoresOnDemand) { + start_service(); + create_root(); + const auto worker = spawn(root_id, "worker", "first"); + ASSERT_EQ(worker.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + ASSERT_TRUE(wait_until([&] { return !loop(worker.session_id)->is_busy(); })); + + service->shutdown(); + service.reset(); + registry->destroy(worker.session_id); + start_service(); + + const auto agents = list(root_id); + EXPECT_EQ(find(agents, "/root/worker"), nullptr); + bool known = false; + for (const auto& info : service->tree_agents(root_id)) { + if (info.path == "/root/worker") known = !info.loaded; + } + EXPECT_TRUE(known); + + EXPECT_EQ(service->deliver(ctx_for(root_id), "worker", "second", true), ""); + ASSERT_TRUE(wait_until([&] { return provider->payloads().back() == "second"; })); + EXPECT_NE(registry->acquire(worker.session_id), nullptr); + EXPECT_TRUE(any_content_contains(provider->request(provider->request_count() - 1), + "answer:first")); +} + +// 场景:模型经工具调用整套协作工具(工具层的参数解析与输出格式)。 +// 期望:输出逐字对齐 Codex —— spawn 回 {"task_name": 路径};send_message 成功输出为空; +// wait 回 {"message","timed_out"} 且按最小值夹取并附说明;超出最大值报错; +// 未知字段与 fork_context 被拒;list 返回 agents 数组。 +TEST_F(MeshAgentServiceTest, ToolsMatchCodexArgumentAndOutputContracts) { + config.swarm.mesh.min_wait_timeout_ms = 1000; + start_service(); + acecode::ToolExecutor mesh_tools; + acecode::register_mesh_agent_tools(mesh_tools, service, config); + create_root(); + const auto ctx = ctx_for(root_id); + + auto waited = mesh_tools.execute("agent_wait", R"({"timeout_ms":5})", ctx); + ASSERT_TRUE(waited.success) << waited.output; + EXPECT_EQ(nlohmann::json::parse(waited.output), + (nlohmann::json{{"message", "Wait timed out.\n\nRequested timeout of 5ms was " + "clamped to the minimum of 1000ms."}, + {"timed_out", true}})); + auto too_long = mesh_tools.execute("agent_wait", R"({"timeout_ms":99999999})", ctx); + EXPECT_FALSE(too_long.success); + EXPECT_EQ(too_long.output, "timeout_ms must be at most 3600000"); + + auto unknown = mesh_tools.execute( + "agent_spawn", R"({"task_name":"w","message":"go","bogus":1})", ctx); + EXPECT_FALSE(unknown.success); + EXPECT_EQ(unknown.output, "unknown field `bogus`"); + auto fork_context = mesh_tools.execute( + "agent_spawn", R"({"task_name":"w","message":"go","fork_context":true})", ctx); + EXPECT_EQ(fork_context.output, + "fork_context is not supported in MultiAgentV2; use fork_turns instead"); + auto bad_fork = mesh_tools.execute( + "agent_spawn", R"({"task_name":"w","message":"go","fork_turns":"0"})", ctx); + EXPECT_EQ(bad_fork.output, "fork_turns must be `none`, `all`, or a positive integer string"); + + auto spawned = mesh_tools.execute("agent_spawn", R"({"task_name":"w","message":"go"})", ctx); + ASSERT_TRUE(spawned.success) << spawned.output; + EXPECT_EQ(spawned.output, R"({"task_name":"/root/w"})"); + EXPECT_FALSE(spawned.metadata.value("subagent_session_id", std::string{}).empty()); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + + auto sent = mesh_tools.execute("agent_send_message", R"({"target":"w","message":"hi"})", ctx); + ASSERT_TRUE(sent.success) << sent.output; + EXPECT_EQ(sent.output, ""); + + auto listed = mesh_tools.execute("agent_list", "{}", ctx); + ASSERT_TRUE(listed.success) << listed.output; + const auto agents = nlohmann::json::parse(listed.output).at("agents"); + ASSERT_EQ(agents.size(), 2u); + EXPECT_EQ(agents[1].at("agent_name"), "/root/w"); + EXPECT_EQ(agents[1].at("agent_status"), (nlohmann::json{{"completed", "answer:go"}})); + + auto root_interrupt = mesh_tools.execute("agent_interrupt", R"({"target":"/root"})", ctx); + EXPECT_FALSE(root_interrupt.success); + EXPECT_EQ(root_interrupt.output, "root is not a spawned agent"); +} diff --git a/tests/session_host/swarm_command_test.cpp b/tests/session_host/swarm_command_test.cpp new file mode 100644 index 000000000..6de2edfd6 --- /dev/null +++ b/tests/session_host/swarm_command_test.cpp @@ -0,0 +1,42 @@ +#include <gtest/gtest.h> + +#include "session_host/swarm_command.hpp" + +// /swarm [star|mesh|off] 的文本实现(add-mesh-swarm-mode):TUI 命令与 daemon +// 内置命令共用,保证两端提示文案一致。 + +using namespace acecode; + +// 场景:不带参数 / 带空白。 +// 期望:视为查看当前模式,状态文本带用法提示。 +TEST(SwarmCommand, EmptyArgsShowCurrentMode) { + const auto request = parse_swarm_command(" "); + EXPECT_TRUE(request.show); + EXPECT_FALSE(request.mode.has_value()); + EXPECT_TRUE(request.error.empty()); + EXPECT_EQ(swarm_command_status_text(SwarmMode::Mesh), + "Swarm mode: mesh (agent_* collaboration tree)\nUsage: /swarm [star|mesh|off]"); +} + +// 场景:三个规范名,大小写与首尾空白不敏感。 +// 期望:解析成对应模式;切换成功文案点明工具族。 +TEST(SwarmCommand, ParsesCanonicalModes) { + EXPECT_EQ(parse_swarm_command("star").mode, SwarmMode::Star); + EXPECT_EQ(parse_swarm_command(" MESH ").mode, SwarmMode::Mesh); + EXPECT_EQ(parse_swarm_command("off").mode, SwarmMode::Off); + EXPECT_EQ(swarm_command_applied_text(SwarmMode::Star), + "Swarm mode set to star (spawn_subagent fan-out)."); + EXPECT_EQ(swarm_command_applied_text(SwarmMode::Off), "Swarm mode set to off."); +} + +// 场景:旧消息接口的布尔字面量与拼错的模式名。 +// 期望:命令只认三个规范名,其余都报错且不带模式 —— "/swarm true" 这种写法 +// 不会被静默当成星型(布尔兼容只属于 Web 消息接口)。 +TEST(SwarmCommand, RejectsUnknownWordsIncludingLegacyBooleans) { + for (const char* bad : {"true", "false", "grid", "mesh please"}) { + const auto request = parse_swarm_command(bad); + EXPECT_FALSE(request.mode.has_value()) << bad; + EXPECT_FALSE(request.show) << bad; + EXPECT_NE(request.error.find("(allowed: star, mesh, off)"), std::string::npos) << bad; + } +} diff --git a/tests/web/commands_handler_test.cpp b/tests/web/commands_handler_test.cpp index 102c7773d..13034373b 100644 --- a/tests/web/commands_handler_test.cpp +++ b/tests/web/commands_handler_test.cpp @@ -94,7 +94,7 @@ TEST_F(CommandsHandlerTest, NoWorkspaceCwdOmitsSkillsField) { EXPECT_FALSE(payload.contains("skills")) << "缺 workspace_cwd 不应输出 skills 字段"; EXPECT_FALSE(payload.contains("commands")) << "缺 workspace_cwd 不应输出 commands 字段"; - ASSERT_EQ(payload["builtins"].size(), 10u); + ASSERT_EQ(payload["builtins"].size(), 11u); EXPECT_EQ(payload["builtins"][0]["name"].get<std::string>(), "init"); EXPECT_EQ(payload["builtins"][1]["name"].get<std::string>(), "compact"); EXPECT_EQ(payload["builtins"][2]["name"].get<std::string>(), "feedback"); @@ -107,8 +107,10 @@ TEST_F(CommandsHandlerTest, NoWorkspaceCwdOmitsSkillsField) { EXPECT_EQ(payload["builtins"][6]["name"].get<std::string>(), "sandbox"); // /memory 与 TUI 同一份文本实现,网页斜杠下拉也要列出(openspec unify-memory-system 6.3)。 EXPECT_EQ(payload["builtins"][7]["name"].get<std::string>(), "memory"); - EXPECT_EQ(payload["builtins"][8]["name"].get<std::string>(), "rc"); - EXPECT_EQ(payload["builtins"][9]["name"].get<std::string>(), "remote-control"); + // /swarm 切换会话级蜂群模式(openspec add-mesh-swarm-mode),三端同一份实现。 + EXPECT_EQ(payload["builtins"][8]["name"].get<std::string>(), "swarm"); + EXPECT_EQ(payload["builtins"][9]["name"].get<std::string>(), "rc"); + EXPECT_EQ(payload["builtins"][10]["name"].get<std::string>(), "remote-control"); for (const auto& builtin : payload["builtins"]) { EXPECT_FALSE(builtin["description"].get<std::string>().empty()); } @@ -236,9 +238,12 @@ TEST_F(CommandsHandlerTest, BuiltinDescriptionsMatchTuiRegistration) { // /memory 与 src/apps/tui/commands/memory_command.cpp 的 TUI 注册文案一致。 EXPECT_EQ(payload["builtins"][7]["description"].get<std::string>(), "List, view, edit or forget memory; flush memory summarization; turn memory off/on for this session"); + // /swarm 与 TUI 注册文案一致(src/apps/tui/commands/swarm_mode_command.cpp)。 EXPECT_EQ(payload["builtins"][8]["description"].get<std::string>(), - "Alias for /remote-control"); + "Show or switch swarm mode: /swarm star|mesh|off"); EXPECT_EQ(payload["builtins"][9]["description"].get<std::string>(), + "Alias for /remote-control"); + EXPECT_EQ(payload["builtins"][10]["description"].get<std::string>(), "Activate a configured channel plugin or manage manual remote-control webhooks"); } diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 62ca56716..32f1e0489 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -6214,9 +6214,10 @@ TEST(WebServerHttp, PostMessageQueuesInputInDaemonSession) { cpr::Header{{"Content-Type", "application/json"}}, cpr::Body{R"({"text":"invalid swarm","swarm_mode":"true"})"}); ASSERT_EQ(invalid.status_code, 400) << invalid.text; + // add-mesh-swarm-mode:swarm_mode 升级为 "star" | "mesh" | "off",旧客户端的布尔仍兼容。 EXPECT_EQ( json::parse(invalid.text)["error"], - "swarm_mode must be a boolean"); + R"(swarm_mode must be "star", "mesh" or false)"); auto queued = cpr::Post(cpr::Url{fx.url("/api/sessions/" + sid + "/messages")}, cpr::Header{{"Content-Type", "application/json"}}, @@ -6239,7 +6240,7 @@ TEST(WebServerHttp, PostMessageQueuesInputInDaemonSession) { m.value("content", "") == "hello from http submit" && m.contains("metadata") && m["metadata"].is_object() && m["metadata"].value("client_message_id", "") == "queued-test-1" && - m["metadata"].value("swarm_mode", false)) { + m["metadata"].value("swarm_mode", std::string{}) == "star") { found = true; break; } @@ -6248,6 +6249,17 @@ TEST(WebServerHttp, PostMessageQueuesInputInDaemonSession) { } EXPECT_TRUE(found) << "HTTP submit should be owned by daemon session"; + // 蜂群模式是会话级状态:旧客户端的 true 等价星型,会话列表随之报告。 + bool listed_star = false; + auto sessions = cpr::Get(cpr::Url{fx.url("/api/sessions")}); + ASSERT_EQ(sessions.status_code, 200) << sessions.text; + for (const auto& s : json::parse(sessions.text)) { + if (s.value("id", std::string{}) == sid) { + listed_star = s.value("swarm_mode", std::string{}) == "star"; + } + } + EXPECT_TRUE(listed_star) << sessions.text; + auto ordinary = cpr::Post( cpr::Url{fx.url("/api/sessions/" + sid + "/messages")}, cpr::Header{{"Content-Type", "application/json"}}, diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 8e1eed934..4fcbfbe66 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -985,9 +985,13 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '批注 {{p0}}': 'Annotation {{p0}}', '{{p0}} · 原文已变化': '{{p0}} · Source text changed', '添加能力或上下文': 'Add capability or context', - '蜂群模式': 'Swarm mode', - '已开启蜂群模式': 'Swarm mode enabled', - '下一条普通消息将积极派遣子 Agent': 'The next regular message will proactively dispatch subagents', + // 蜂群模式分星型 / 网状(openspec add-mesh-swarm-mode) + '蜂群模式(星型)': 'Swarm mode (Star)', + '蜂群模式(网状)': 'Swarm mode (Mesh)', + '已开启蜂群模式(星型)': 'Swarm mode (Star) enabled', + '已开启蜂群模式(网状)': 'Swarm mode (Mesh) enabled', + '主 Agent 积极派遣子 Agent 并汇总结果': 'The main agent proactively dispatches subagents and aggregates their results', + 'Agent 之间可以互相派任务、发消息,组成协作网络': 'Agents can assign tasks and send messages to each other as a collaboration network', '关闭蜂群模式': 'Disable swarm mode', '{{p0}}失败:{{p1}}': '{{p0}} failed: {{p1}}', '编辑目标': 'Edit goal', diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 10db2b539..40ff83b55 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -124,6 +124,12 @@ import { retryQueuedInput, updateQueuedInputContent, } from '../lib/chatInputQueue.js'; +import { + effectiveSwarmMode, + normalizeSwarmMode, + reconcileSwarmChoice, + swarmModeForSubmission, +} from '../lib/swarmMode.js'; import { findStickyUserContext, sameStickyUserContext, scrollTopForStickySourceRow } from '../lib/stickyUserContext.js'; import { fetchCompletedTurnHistory, loadTranscriptHistory, useSessionTranscript } from '../lib/sessionTranscript.js'; import { sessionJumpMessagePosition } from '../lib/sessionJump.js'; @@ -379,7 +385,8 @@ function fileToBase64(file) { }); } -function normalizeComposerPayload(text, attachments = [], contexts = [], swarmMode = false, composerContent = null) { +// swarmMode: 要随这条消息写给服务端的会话级蜂群模式(swarmModeForSubmission),null = 不写。 +function normalizeComposerPayload(text, attachments = [], contexts = [], swarmMode = null, composerContent = null) { const sessionReferences = extractSessionReferences(String(text || '')); const content = reconcileComposerContentAttachments(composerContent, attachments); const payload = { @@ -399,7 +406,7 @@ function normalizeComposerPayload(text, attachments = [], contexts = [], swarmMo if (sessionReferences.references.length > 0) { payload.session_references = sessionReferences.references; } - if (swarmMode) payload.swarm_mode = true; + if (swarmMode) payload.swarm_mode = swarmMode; if (content) payload.composer_content = content; return payload; } @@ -945,7 +952,18 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setComposerContent((previous) => composerContentSignature(previous) === composerContentSignature(normalized) ? previous : normalized); }, []); const [composerContexts, setComposerContexts] = useState([]); - const [composerSwarmMode, setComposerSwarmMode] = useState(false); + // 蜂群模式是会话级状态(add-mesh-swarm-mode):芯片 = 服务端值(transcript 的 + // swarmMode,来自 messages 快照与 session_updated)+ 尚未随消息提交的本地选择。 + const transcriptSwarmMode = normalizeSwarmMode(transcript.swarmMode); + const [composerSwarmChoice, setComposerSwarmChoice] = useState(null); + const composerSwarmMode = effectiveSwarmMode(composerSwarmChoice, transcriptSwarmMode); + const changeComposerSwarmMode = useCallback((mode) => { + setComposerSwarmChoice(reconcileSwarmChoice(normalizeSwarmMode(mode), transcriptSwarmMode)); + }, [transcriptSwarmMode]); + // 服务端确认(或别处切到同一模式)后,本地选择清空,重新跟随服务端。 + useEffect(() => { + setComposerSwarmChoice((choice) => reconcileSwarmChoice(choice, transcriptSwarmMode)); + }, [transcriptSwarmMode]); const [selectionPreview, setSelectionPreview] = useState(null); const [selectionAction, setSelectionAction] = useState(null); const [composerSubmitting, setComposerSubmitting] = useState(false); @@ -1464,7 +1482,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const resetComposerContextSelections = useCallback(() => { clearComposerExtras(); - setComposerSwarmMode(false); + setComposerSwarmChoice(null); }, [clearComposerExtras]); const createHomeComposerSession = useCallback(async (text, { @@ -2226,7 +2244,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onCommitDeferredPastes: commitDeferredPastes, attachmentTextLoader: api.readAttachmentText, swarmMode: composerSwarmMode, - onSwarmModeChange: setComposerSwarmMode, + onSwarmModeChange: changeComposerSwarmMode, }), [ api, commitDeferredPastes, @@ -2234,6 +2252,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses composerContent, composerContexts, composerSwarmMode, + changeComposerSwarmMode, handleLargeTextPaste, handleMediaFiles, pinSelectionContext, @@ -3461,7 +3480,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses text, activeAttachments, composerContexts, - composerSwarmMode, + swarmModeForSubmission(composerSwarmChoice, transcriptSwarmMode), submittedContent, ); // 提交那一刻输入框里的原文。发送回执回来时拿它比对,用户在等待窗口里 @@ -3469,7 +3488,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const submittedComposerText = composerValueRef.current; const submittedComposerContent = composerContentRef.current; const hasExtras = payloadHasExtras(payload) || hasPendingAttachments; - const hasSwarmMode = payload.swarm_mode === true; + const hasSwarmMode = typeof payload.swarm_mode === 'string'; if (!payload.text.trim() && !hasExtras) { if (!retryUserMessageId || composerSubmitting || retrySubmissionRef.current) return; const latest = transcript.getState(); @@ -3802,7 +3821,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses applyEvent({ type: 'busy_changed', payload: { busy: false } }, { emitEffects: false }); }) .finally(() => setComposerSubmitting(false)); - }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, updateQueueState, applyEvent, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, homeComposerDrafts, onHomeComposerDraftAccepted, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash, sessionRuntimeUnavailable, retryUserMessageId, transcript.getState, transcriptLoadState, readOnlyExternalSession, commitDeferredPastes, materializeWorkspaceDraftPastes, uploadPasteReservations]); + }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, updateQueueState, applyEvent, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmChoice, transcriptSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, homeComposerDrafts, onHomeComposerDraftAccepted, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash, sessionRuntimeUnavailable, retryUserMessageId, transcript.getState, transcriptLoadState, readOnlyExternalSession, commitDeferredPastes, materializeWorkspaceDraftPastes, uploadPasteReservations]); const drainQueuedInput = useCallback(() => { const targetSid = sidRef.current; diff --git a/web/src/components/ComposerSessionControls.jsx b/web/src/components/ComposerSessionControls.jsx index 1d72fdc91..764aa8f0e 100644 --- a/web/src/components/ComposerSessionControls.jsx +++ b/web/src/components/ComposerSessionControls.jsx @@ -200,7 +200,7 @@ export function ComposerSessionControls({ permissionMode = 'default', permissionSwitching = false, onPermissionModeChange, - swarmMode = false, + swarmMode = 'off', onDisableSwarm, expertId = '', expertName = '', @@ -221,6 +221,20 @@ export function ComposerSessionControls({ reasoningCaretRestoreRef.current = null; if (restore) window.requestAnimationFrame(restore); }; + // 蜂群模式芯片:星型 / 网状各一套文案(add-mesh-swarm-mode),关闭时不显示。 + const swarmTag = swarmMode === 'mesh' + ? { + label: '蜂群模式(网状)', + status: '已开启蜂群模式(网状)', + title: 'Agent 之间可以互相派任务、发消息,组成协作网络', + } + : swarmMode === 'star' + ? { + label: '蜂群模式(星型)', + status: '已开启蜂群模式(星型)', + title: '主 Agent 积极派遣子 Agent 并汇总结果', + } + : null; const compactControls = useAdaptiveComposerControls( rootRef, `${goalMode}|${swarmMode}|${expertName}|${pendingExpertName}|${permissionMode}|${selectedModelName}|${model}|${reasoningOptions?.label || ''}`, @@ -312,17 +326,18 @@ export function ComposerSessionControls({ </ComposerSelectionTag> )} - {swarmMode && ( + {swarmTag && ( <ComposerSelectionTag data-composer-control="swarm-mode" + data-swarm-mode={swarmMode} compact={compactControls.has('swarm-mode')} icon={<SwarmModeIcon size={16} />} onClick={onDisableSwarm} - status="已开启蜂群模式" + status={swarmTag.status} aria-label="关闭蜂群模式" - title="下一条普通消息将积极派遣子 Agent" + title={swarmTag.title} > - 蜂群模式 + {swarmTag.label} </ComposerSelectionTag> )} diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index 950443d76..197706ad3 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -20,6 +20,7 @@ import { ExpertAvatar, compactExpertSummary } from './ExpertCatalog.jsx'; import { GoalStatusBar } from './GoalStatusBar.jsx'; import { ImageLightbox } from './ImageLightbox.jsx'; import { SwarmModeIcon } from './SwarmModeIcon.jsx'; +import { normalizeSwarmMode, pickSwarmMode, swarmModeLabel } from '../lib/swarmMode.js'; import { RichComposer } from './RichComposer.jsx'; import { PastedTextCard } from './PastedTextCard.jsx'; import { PastedTextDialog } from './PastedTextDialog.jsx'; @@ -217,7 +218,7 @@ export const InputBar = forwardRef(function InputBar({ onMediaFiles, onRemoveAttachment, onRemoveContext, onLargeTextPaste, onReplacePasteBlock, onRetryPasteUpload, onCommitDeferredPastes, attachmentTextLoader, - swarmMode = false, onSwarmModeChange, + swarmMode = 'off', onSwarmModeChange, expertOptions = [], selectedExpertId = '', selectedExpertName = '', @@ -1156,16 +1157,18 @@ export const InputBar = forwardRef(function InputBar({ } }, []); - const toggleSwarmMode = useCallback(() => { + // 蜂群模式(星型)/(网状)两项互斥;再点已选中的一项即关闭。 + const activeSwarmMode = normalizeSwarmMode(swarmMode); + const chooseSwarmMode = useCallback((mode) => { setCapabilityOpen(false); closeExpertSubmenu(false); - onSwarmModeChange?.(!swarmMode); + onSwarmModeChange?.(pickSwarmMode(activeSwarmMode, mode)); requestComposerCaretRestore(); }, [ + activeSwarmMode, closeExpertSubmenu, onSwarmModeChange, requestComposerCaretRestore, - swarmMode, ]); const handleExpertSubmenuKeyDown = useCallback((event) => { @@ -1352,21 +1355,25 @@ export const InputBar = forwardRef(function InputBar({ <VsIcon name="Goal" size={15} className="shrink-0" /> <span className="min-w-0 flex-1 truncate">目标</span> </button> - <button - type="button" - role="menuitemcheckbox" - aria-checked={swarmMode} - className={clsx( - 'w-full h-8 px-2 flex items-center gap-2 text-left text-[13px] hover:bg-surface-hi', - swarmMode ? 'bg-accent-bg text-accent' : 'text-fg', - )} - onPointerEnter={() => closeExpertSubmenu(false)} - onClick={toggleSwarmMode} - > - <SwarmModeIcon size={15} className="shrink-0" /> - <span className="min-w-0 flex-1 truncate">蜂群模式</span> - {swarmMode && <VsIcon name="check" size={12} className="shrink-0 opacity-70" />} - </button> + {['star', 'mesh'].map((mode) => ( + <button + key={mode} + type="button" + role="menuitemradio" + data-swarm-mode={mode} + aria-checked={activeSwarmMode === mode} + className={clsx( + 'w-full h-8 px-2 flex items-center gap-2 text-left text-[13px] hover:bg-surface-hi', + activeSwarmMode === mode ? 'bg-accent-bg text-accent' : 'text-fg', + )} + onPointerEnter={() => closeExpertSubmenu(false)} + onClick={() => chooseSwarmMode(mode)} + > + <SwarmModeIcon size={15} className="shrink-0" /> + <span className="min-w-0 flex-1 truncate">{swarmModeLabel(mode)}</span> + {activeSwarmMode === mode && <VsIcon name="check" size={12} className="shrink-0 opacity-70" />} + </button> + ))} <div className="my-1 border-t border-border" aria-hidden="true" /> @@ -1800,8 +1807,8 @@ export const InputBar = forwardRef(function InputBar({ expertId={selectedExpertId} expertName={selectedExpertName} expertType={selectedExpertType} - swarmMode={swarmMode} - onDisableSwarm={() => onSwarmModeChange?.(false)} + swarmMode={activeSwarmMode} + onDisableSwarm={() => onSwarmModeChange?.('off')} expertRemoving={expertRemoving} onRemoveExpert={onRemoveExpert} pendingExpertName={pendingExpertName} diff --git a/web/src/components/SubagentPanel.jsx b/web/src/components/SubagentPanel.jsx index 208e8c099..b22c3439c 100644 --- a/web/src/components/SubagentPanel.jsx +++ b/web/src/components/SubagentPanel.jsx @@ -69,8 +69,10 @@ function TaskCard({ task, nowMs, onAbort, onOpenTranscript }) { </button> )} </div> - <div className="flex items-center gap-1.5 text-[11px] text-fg-2"> - <span>Agent</span> + <div className="flex items-center gap-1.5 text-[11px] text-fg-2 min-w-0"> + {task.agentPath + ? <span className="font-mono truncate" title={task.agentPath}>{task.agentPath}</span> + : <span>Agent</span>} {running ? ( <> <span className="w-1.5 h-1.5 rounded-full bg-ok shadow-[0_0_5px_var(--ace-ok)]" /> diff --git a/web/src/i18n/catalogs/systemNotice.js b/web/src/i18n/catalogs/systemNotice.js index f17568033..f5627fc44 100644 --- a/web/src/i18n/catalogs/systemNotice.js +++ b/web/src/i18n/catalogs/systemNotice.js @@ -28,6 +28,8 @@ export const systemNoticeZhCN = { init_started: '正在初始化项目说明', init_exists: '项目说明已存在', init_created: '项目说明已创建', init_failed: '项目说明创建失败', model_profile_reload_failed: '模型配置刷新失败', model_profile_warning: '模型配置警告', + inter_agent_task: '{{sender}} 派来新任务', inter_agent_message: '{{sender}} 发来消息', + inter_agent_final: '{{sender}} 已完成', inter_agent_error: '{{sender}} 执行出错', }, details: { goal_missing: '尚未设置目标。使用 /goal <目标> 创建目标。', @@ -110,6 +112,8 @@ export const systemNoticeEnUS = { init_started: 'Initializing project instructions', init_exists: 'Project instructions already exist', init_created: 'Project instructions created', init_failed: 'Could not create project instructions', model_profile_reload_failed: 'Model configuration reload failed', model_profile_warning: 'Model configuration warning', + inter_agent_task: 'New task from {{sender}}', inter_agent_message: 'Message from {{sender}}', + inter_agent_final: '{{sender}} finished', inter_agent_error: '{{sender}} failed', }, details: { goal_missing: 'No goal set. Use /goal <objective> to create one.', diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 32292fe55..9ab72e753 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -331,6 +331,7 @@ export const sourceCatalogs = { "s_21493a0f78021d9b": "选择当前文件夹", "s_2156709a76aa43e6": "输入消息…", "s_21a08761b9914419": "一行一条。~ 表示家目录,:workspace_roots 表示当前工作区根,:tmpdir 表示沙箱临时目录,其余必须是绝对路径。黑名单支持 **/.env 这类通配。", + "s_21a9727afd60df42": "已开启蜂群模式(星型)", "s_21cab4f2e52fbfef": "未命名对话", "s_21cc0ba839811c78": "新路径与当前路径相同", "s_21dde55cd99a68a2": "当前后台服务不支持工具重写,请更新 ACECode 并重启后重试", @@ -447,6 +448,7 @@ export const sourceCatalogs = { "s_2f5f1d6fbfb061ed": "未设置", "s_2f80fc3f9ffba0a9": "模型列表已发生变化,请刷新后重新排序", "s_2f9daa828907b93f": "删除", + "s_2fa4d0408e5c43b0": "蜂群模式(星型)", "s_2fbee191632d47c5": "本地服务", "s_2fd1fcda22e20ff7": "思考深度", "s_2fed5113fddb55fd": "选中的目录会写入这项设置。", @@ -508,7 +510,6 @@ export const sourceCatalogs = { "s_3625f9fa2f9c629a": "拒绝执行命令", "s_364970d8168ca5fe": "发送中…", "s_369078855fa122f7": "只读工具调用", - "s_3698a7daa728953c": "下一条普通消息将积极派遣子 Agent", "s_36f33adaf0942634": "确认", "s_3703e47860f64a12": "文件不存在", "s_3705412564839843": "正在恢复权限请求", @@ -645,6 +646,7 @@ export const sourceCatalogs = { "s_443cb29217829285": "查看擅长领域后派遣到新任务,或维护自己的专家和专家团。", "s_44455611b9108b91": "高级设置", "s_445fad5b93c9ad4f": "保存{{p0}}失败", + "s_44803cfe269f3c73": "主 Agent 积极派遣子 Agent 并汇总结果", "s_448654de73aa99c2": "已取消全部回答", "s_449ede901389ec0c": "等待权限确认", "s_44b3bfad152dc3a5": "迁移完成,重启后使用新路径", @@ -976,6 +978,7 @@ export const sourceCatalogs = { "s_682d211b7142758a": "已修改", "s_6830ded77d5f970f": "正在执行工具", "s_6846219d906e71df": "规则与授权", + "s_685070b1210fa4bb": "蜂群模式(网状)", "s_685ec214de488c28": "已取消 {{p0}} 个会话的归档", "s_68618c5150cc8603": "渲染 Markdown", "s_68675d4b37dc5bc1": "未自动找到 Git Bash。请输入", @@ -1018,6 +1021,7 @@ export const sourceCatalogs = { "s_6c17550d1e5907ec": "Enter 排队 · Shift+Enter 换行 · 上下键切换历史消息", "s_6c3df58b0549b3c4": "暂无运行记录", "s_6c927491f75c08fe": "复制验证码失败:{{p0}}", + "s_6c9d4a908a310007": "已开启蜂群模式(网状)", "s_6cc617b39d0c9bbe": "原生文件系统不可用", "s_6cc930e12ea9ce92": "无法连接 xAI 授权服务", "s_6ce5b90be906a6cc": "保存专家团", @@ -1325,7 +1329,6 @@ export const sourceCatalogs = { "s_8ae96d747e59609e": "无描述", "s_8afd3eb9d4a894bf": "此 Markdown 结构按原文保留", "s_8b2106ca13719cb2": "反馈", - "s_8b36ad0259afb45c": "蜂群模式", "s_8b47380e110ab22e": "主题包尚未准备好,请重新导出", "s_8b4eb61f2c4876ba": "模型要执行一条危险命令", "s_8b5f2a61b79721aa": "最低", @@ -1960,7 +1963,6 @@ export const sourceCatalogs = { "s_d0ca08d604551151": "队列已暂停", "s_d0f680208ed8796c": "git 超时,点击重试", "s_d0fb5bbca8184bdd": "跳转到当前问题:{{p0}}", - "s_d1900c3b84a2557d": "已开启蜂群模式", "s_d1aeba9123ecd734": "工作区目录", "s_d1cd931be84cce03": "信任", "s_d1d044826a45ef65": "加载失败", @@ -2123,6 +2125,7 @@ export const sourceCatalogs = { "s_e1c91d496192ca39": "只支持 ~ 或 ~/路径,不支持 ~用户名:{{p0}}", "s_e1dd05dd35264366": "全部提交完成", "s_e1f4e47353387da0": "已允许并写入规则", + "s_e1ff86341359e6b2": "Agent 之间可以互相派任务、发消息,组成协作网络", "s_e20397ac53ca18f6": "全局(所有项目)", "s_e204b68338464c50": "先配置一个模型", "s_e230e085131898d2": "拖动调整后台任务面板宽度", @@ -2752,6 +2755,7 @@ export const sourceCatalogs = { "s_21493a0f78021d9b": "Select current folder", "s_2156709a76aa43e6": "Enter message…", "s_21a08761b9914419": "One entry per line. ~ is the home directory,:workspace_roots the current workspace root,:tmpdir the sandbox temp directory; everything else must be an absolute path. The deny list accepts wildcards such as **/.env.", + "s_21a9727afd60df42": "Swarm mode (Star) enabled", "s_21cab4f2e52fbfef": "Unnamed conversation", "s_21cc0ba839811c78": "The new path is the same as the current path", "s_21dde55cd99a68a2": "The current backend does not support tool rewrite. Update ACECode, restart, and try again", @@ -2868,6 +2872,7 @@ export const sourceCatalogs = { "s_2f5f1d6fbfb061ed": "Not set", "s_2f80fc3f9ffba0a9": "The model list has changed. Refresh it and reorder again.", "s_2f9daa828907b93f": "delete", + "s_2fa4d0408e5c43b0": "Swarm mode (Star)", "s_2fbee191632d47c5": "Local services", "s_2fd1fcda22e20ff7": "Reasoning depth", "s_2fed5113fddb55fd": "The selected folder will be saved to this setting.", @@ -2929,7 +2934,6 @@ export const sourceCatalogs = { "s_3625f9fa2f9c629a": "Command denied", "s_364970d8168ca5fe": "Sending…", "s_369078855fa122f7": "Read-only tool calls", - "s_3698a7daa728953c": "The next regular message will proactively dispatch subagents", "s_36f33adaf0942634": "Confirm", "s_3703e47860f64a12": "File does not exist", "s_3705412564839843": "Restoring permission request", @@ -3066,6 +3070,7 @@ export const sourceCatalogs = { "s_443cb29217829285": "Review specialties and dispatch to a new task, or manage your own experts and expert teams.", "s_44455611b9108b91": "Advanced settings", "s_445fad5b93c9ad4f": "Failed to save {{p0}}", + "s_44803cfe269f3c73": "The main agent proactively dispatches subagents and aggregates their results", "s_448654de73aa99c2": "All answers cancelled", "s_449ede901389ec0c": "Waiting for permission confirmation", "s_44b3bfad152dc3a5": "Migration complete. Restart to use the new path.", @@ -3397,6 +3402,7 @@ export const sourceCatalogs = { "s_682d211b7142758a": "Modified", "s_6830ded77d5f970f": "Executing tool", "s_6846219d906e71df": "Rules and grants", + "s_685070b1210fa4bb": "Swarm mode (Mesh)", "s_685ec214de488c28": "Unarchived {{p0}} sessions", "s_68618c5150cc8603": "Rendering Markdown", "s_68675d4b37dc5bc1": "Git Bash was not found automatically. Please enter", @@ -3439,6 +3445,7 @@ export const sourceCatalogs = { "s_6c17550d1e5907ec": "Enter queue · Shift+Enter line break · up and down keys to switch historical messages", "s_6c3df58b0549b3c4": "No running records yet", "s_6c927491f75c08fe": "Failed to copy the verification code: {{p0}}", + "s_6c9d4a908a310007": "Swarm mode (Mesh) enabled", "s_6cc617b39d0c9bbe": "Native filesystem unavailable", "s_6cc930e12ea9ce92": "Could not reach the xAI authorization service", "s_6ce5b90be906a6cc": "Save expert team", @@ -3746,7 +3753,6 @@ export const sourceCatalogs = { "s_8ae96d747e59609e": "No description", "s_8afd3eb9d4a894bf": "This Markdown structure is preserved as source", "s_8b2106ca13719cb2": "Feedback", - "s_8b36ad0259afb45c": "Swarm mode", "s_8b47380e110ab22e": "The theme package is not ready. Export it again.", "s_8b4eb61f2c4876ba": "The model wants to run a dangerous command", "s_8b5f2a61b79721aa": "Minimal", @@ -4381,7 +4387,6 @@ export const sourceCatalogs = { "s_d0ca08d604551151": "Queue paused", "s_d0f680208ed8796c": "git times out, click to try again", "s_d0fb5bbca8184bdd": "Jump to current question:{{p0}}", - "s_d1900c3b84a2557d": "Swarm mode enabled", "s_d1aeba9123ecd734": "Workspaces folder", "s_d1cd931be84cce03": "trust", "s_d1d044826a45ef65": "Loading failed", @@ -4544,6 +4549,7 @@ export const sourceCatalogs = { "s_e1c91d496192ca39": "Only ~ or ~/path is supported, not ~user: {{p0}}", "s_e1dd05dd35264366": "All answers submitted", "s_e1f4e47353387da0": "Allowed and saved to rules", + "s_e1ff86341359e6b2": "Agents can assign tasks and send messages to each other as a collaboration network", "s_e20397ac53ca18f6": "Global (all projects)", "s_e204b68338464c50": "Configure a model first", "s_e230e085131898d2": "Drag to resize background task panel", diff --git a/web/src/lib/chatInputQueue.js b/web/src/lib/chatInputQueue.js index bc5e8db82..3b57be146 100644 --- a/web/src/lib/chatInputQueue.js +++ b/web/src/lib/chatInputQueue.js @@ -31,7 +31,10 @@ function normalizePayload({ text, payload } = {}) { attachments: Array.isArray(payload.attachments) ? payload.attachments : [], contexts: Array.isArray(payload.contexts) ? payload.contexts : [], }; - if (payload.swarm_mode === true) normalized.swarm_mode = true; + // 会话级蜂群模式随这条消息写给服务端:'star' | 'mesh' | 'off';旧排队项的 true = 星型。 + if (payload.swarm_mode === true || ['star', 'mesh', 'off'].includes(payload.swarm_mode)) { + normalized.swarm_mode = payload.swarm_mode; + } const content = normalizeComposerContent(payload.composer_content); if (content) normalized.composer_content = content; if (Array.isArray(payload.session_references)) normalized.session_references = payload.session_references; diff --git a/web/src/lib/chatInputQueue.test.js b/web/src/lib/chatInputQueue.test.js index ffb7eb771..ea518828e 100644 --- a/web/src/lib/chatInputQueue.test.js +++ b/web/src/lib/chatInputQueue.test.js @@ -182,6 +182,23 @@ run('蜂群模式在排队、重试和请求重建时保持,普通消息不产 assert.equal(Object.hasOwn(queuedInputRequestPayload(ordinary), 'swarm_mode'), false); }); +// 场景:蜂群模式升级为会话级的 off | star | mesh(add-mesh-swarm-mode),排队消息携带 +// 要写给服务端的模式;关闭也是一次显式切换。 +// 期望:三个字符串原样保留到请求里;无法识别的值丢弃,不把垃圾值发给服务端。 +run('排队消息保留字符串形式的蜂群模式,未知值丢弃', () => { + let state = createChatInputQueueState(); + for (const [index, mode] of ['mesh', 'off', 'grid'].entries()) { + state = enqueueQueuedInput(state, { + sessionId: `m${index}`, + payload: { text: `模式 ${mode}`, attachments: [], contexts: [], swarm_mode: mode }, + now: 100 + index, + }); + } + assert.equal(queuedInputRequestPayload(nextQueuedInput(state, 'm0')).swarm_mode, 'mesh'); + assert.equal(queuedInputRequestPayload(nextQueuedInput(state, 'm1')).swarm_mode, 'off'); + assert.equal(Object.hasOwn(queuedInputRequestPayload(nextQueuedInput(state, 'm2')), 'swarm_mode'), false); +}); + run('cancelled 项不会出现在可见队列也不会被发送', () => { let state = createChatInputQueueState(); state = enqueueQueuedInput(state, { sessionId: 's1', text: 'one' }); diff --git a/web/src/lib/composerSessionControlsArchitecture.test.js b/web/src/lib/composerSessionControlsArchitecture.test.js index b79d82da5..5a0ea625d 100644 --- a/web/src/lib/composerSessionControlsArchitecture.test.js +++ b/web/src/lib/composerSessionControlsArchitecture.test.js @@ -102,8 +102,11 @@ run('composer footer preserves required left-to-right control order', () => { 'data-composer-control="submit"', ]); assert.match(footer, /\{expertName && \(/); - assert.match(footer, /\{swarmMode && \(/); - assert.match(footer, /data-composer-control="swarm-mode"[\s\S]*status="已开启蜂群模式"/); + // 蜂群模式分星型 / 网状两种(add-mesh-swarm-mode),芯片文案按模式给出。 + assert.match(footer, /\{swarmTag && \(/); + assert.match(footer, /data-composer-control="swarm-mode"[\s\S]*status=\{swarmTag\.status\}/); + assert.match(component, /status: '已开启蜂群模式(网状)'/); + assert.match(component, /status: '已开启蜂群模式(星型)'/); assert.match(footer, /<SwarmModeIcon size=\{16\}/); assert.match(footer, /aria-label="关闭蜂群模式"/); assert.match(footer, /data-composer-control="expert"[\s\S]*status=\{`已派遣/); @@ -117,14 +120,20 @@ run('swarm and expert selections survive submission until explicitly changed', ( const icon = source('components/SwarmModeIcon.jsx'); assert.match(inputBar, /role="menuitemcheckbox"/); - assert.match(inputBar, /aria-checked=\{swarmMode\}/); + // 两个互斥的菜单项:蜂群模式(星型)/(网状)。 + assert.match(inputBar, /\['star', 'mesh'\]\.map\(\(mode\) =>/); + assert.match(inputBar, /role="menuitemradio"/); + assert.match(inputBar, /aria-checked=\{activeSwarmMode === mode\}/); assert.match(inputBar, /<SwarmModeIcon size=\{15\}/); - assert.match(inputBar, />蜂群模式</); + assert.match(inputBar, /\{swarmModeLabel\(mode\)\}/); assert.match(icon, /<VsIcon name="Swarm" size=\{size\} className=\{className\}/); assert.doesNotMatch(icon, /<svg|<polygon|strokeWidth/); - assert.match(chatView, /const \[composerSwarmMode, setComposerSwarmMode\] = useState\(false\)/); - assert.match(chatView, /if \(swarmMode\) payload\.swarm_mode = true/); + // 会话级模式:芯片 = 服务端值 + 未提交的本地选择,只有不同才随消息提交。 + assert.match(chatView, /const \[composerSwarmChoice, setComposerSwarmChoice\] = useState\(null\)/); + assert.match(chatView, /effectiveSwarmMode\(composerSwarmChoice, transcriptSwarmMode\)/); + assert.match(chatView, /swarmModeForSubmission\(composerSwarmChoice, transcriptSwarmMode\)/); + assert.match(chatView, /if \(swarmMode\) payload\.swarm_mode = swarmMode/); assert.match(chatView, /swarmMode: composerSwarmMode/); assert.match(chatView, /const explicitHomeSend = !isBuiltin && \(hasExtras \|\| hasSwarmMode/); assert.match(chatView, /preserveExtras: hasExtras \|\| hasSwarmMode/); @@ -133,7 +142,7 @@ run('swarm and expert selections survive submission until explicitly changed', ( /const expertOptions = homeExpertId \? \{ expert_id: homeExpertId, expertId: homeExpertId \} : \{\}/, ); assert.match(chatView, /if \(sid\) \{[\s\S]{0,80}setSessionExpertId\(expertId\)/); - assert.match(inputBar, /onDisableSwarm=\{\(\) => onSwarmModeChange\?\.\(false\)\}/); + assert.match(inputBar, /onDisableSwarm=\{\(\) => onSwarmModeChange\?\.\('off'\)\}/); const cleanupStart = chatView.indexOf('const clearComposerExtras = useCallback'); const resetStart = chatView.indexOf('const resetComposerContextSelections = useCallback'); @@ -142,11 +151,11 @@ run('swarm and expert selections survive submission until explicitly changed', ( const submissionCleanup = chatView.slice(cleanupStart, resetStart); assert.doesNotMatch( submissionCleanup, - /setComposerSwarmMode|setHomeExpertId|setSessionExpertId|onSessionExpertChanged/, + /setComposerSwarmChoice|setHomeExpertId|setSessionExpertId|onSessionExpertChanged/, ); const contextReset = chatView.slice(resetStart, createStart); assert.match(contextReset, /clearComposerExtras\(\)/); - assert.match(contextReset, /setComposerSwarmMode\(false\)/); + assert.match(contextReset, /setComposerSwarmChoice\(null\)/); assert.match(chatView, /resetComposerContextSelections\(\)/); assert.doesNotMatch(chatView, /clearComposerExtras\(\{[^)]*preserveSwarm/); diff --git a/web/src/lib/expertComponentsArchitecture.test.js b/web/src/lib/expertComponentsArchitecture.test.js index 21a21a16c..00ce7e535 100644 --- a/web/src/lib/expertComponentsArchitecture.test.js +++ b/web/src/lib/expertComponentsArchitecture.test.js @@ -207,7 +207,7 @@ test('all real composers host the picker in place and opening prompts use atomic assert.match(input, /compactExpertSummary\(expert\)/); assert.match(input, /role="menuitemcheckbox"/); assert.match(input, /<SwarmModeIcon size=\{15\}/); - assert.match(input, />蜂群模式</); + assert.match(input, /\{swarmModeLabel\(mode\)\}/); assert.match(input, />更多专家</); assert.match(input, />文件或文件夹</); assert.doesNotMatch(input, /暂无最近使用的专家|没有最近的专家|onAddBrowserContext|>浏览器</); diff --git a/web/src/lib/interAgentMessage.js b/web/src/lib/interAgentMessage.js new file mode 100644 index 000000000..5200caf5d --- /dev/null +++ b/web/src/lib/interAgentMessage.js @@ -0,0 +1,71 @@ +// 蜂群模式(网状)的 agent 间消息(add-mesh-swarm-mode)。 +// +// daemon 把 agent 间信封落盘成 user 角色的消息(正文是给模型看的 +// <inter_agent_message> 文本,metadata.inter_agent 带类型 / 发送方 / 接收方), +// 这样任何 provider 都不会遇到两条相邻的 assistant 消息。界面上它们不是用户说的话: +// 当成用户气泡会开出一个假回合(回合滚动条、吸顶条、「重发末尾用户消息」都会认它)。 +// 所以进 transcript 之前统一转成系统提示行,标题写清是谁发来的,展开看正文。 + +const NOTICE_CODES = { + NEW_TASK: 'inter_agent_task', + MESSAGE: 'inter_agent_message', +}; + +export function interAgentEnvelope(metadata) { + const value = metadata && typeof metadata === 'object' ? metadata.inter_agent : null; + if (!value || typeof value !== 'object') return null; + const type = String(value.type || ''); + if (type !== 'NEW_TASK' && type !== 'MESSAGE' && type !== 'FINAL_ANSWER') return null; + return { + type, + sender: String(value.sender || ''), + recipient: String(value.recipient || ''), + status: String(value.status || ''), + senderSessionId: String(value.sender_session_id || ''), + }; +} + +// 信封正文里 "Payload:" 之后的部分(去掉结尾的闭合标签);不是信封时原样返回。 +export function interAgentPayload(content) { + const text = String(content ?? ''); + const marker = '\nPayload:\n'; + const start = text.indexOf(marker); + if (start < 0) return text; + let payload = text.slice(start + marker.length); + const close = '\n</inter_agent_message>'; + if (payload.endsWith(close)) payload = payload.slice(0, -close.length); + return payload; +} + +export function interAgentNoticeCode(envelope) { + if (!envelope) return ''; + if (envelope.type === 'FINAL_ANSWER') { + return envelope.status === 'errored' ? 'inter_agent_error' : 'inter_agent_final'; + } + return NOTICE_CODES[envelope.type] || ''; +} + +// user 角色的信封 → 系统提示行(system_notice);其它消息原样返回。 +export function presentInterAgentMessage(message) { + if (!message || typeof message !== 'object' || message.role !== 'user') return message; + const envelope = interAgentEnvelope(message.metadata); + if (!envelope) return message; + const payload = interAgentPayload(message.content); + return { + ...message, + role: 'system', + content: payload, + metadata: { + ...message.metadata, + system_notice: { + version: 1, + code: interAgentNoticeCode(envelope), + params: { + sender: envelope.sender, + recipient: envelope.recipient, + text: payload, + }, + }, + }, + }; +} diff --git a/web/src/lib/interAgentMessage.test.js b/web/src/lib/interAgentMessage.test.js new file mode 100644 index 000000000..eb6b6ac1f --- /dev/null +++ b/web/src/lib/interAgentMessage.test.js @@ -0,0 +1,98 @@ +// interAgentMessage.js 的单元测试(add-mesh-swarm-mode)。 +// +// agent 间信封以 user 角色落盘,界面上必须显示成系统提示行,否则会开出假回合。 +// 覆盖: +// - 元数据解析与正文 Payload 提取 +// - 三种类型 / errored 状态各自的提示代码 +// - 普通 user 消息与非 user 消息原样返回 +// - 提示行经 presentSystemNotice 得到带发送方的标题与正文 + +import assert from 'node:assert/strict'; +import { + interAgentEnvelope, + interAgentNoticeCode, + interAgentPayload, + presentInterAgentMessage, +} from './interAgentMessage.js'; +import { presentSystemNotice } from './systemNotice.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +function envelopeMessage(type, payload, status = '') { + return { + role: 'user', + id: 'u-1', + content: `<inter_agent_message>\nMessage Type: ${type}\nTask name: /root\nSender: /root/worker\nPayload:\n${payload}\n</inter_agent_message>`, + metadata: { + inter_agent: { + type, sender: '/root/worker', recipient: '/root', sender_session_id: 's-w', + ...(status ? { status } : {}), + }, + }, + }; +} + +// 场景:daemon 落盘的信封(正文 + metadata.inter_agent)。 +// 期望:解析出类型与路径;Payload 去掉头部与闭合标签;Payload 正文里再出现 +// "Payload:" 也不截错;非信封文本原样返回。 +run('解析信封元数据并提取 Payload 正文', () => { + const message = envelopeMessage('MESSAGE', 'line 1\nPayload:\nline 2'); + assert.deepEqual(interAgentEnvelope(message.metadata), { + type: 'MESSAGE', sender: '/root/worker', recipient: '/root', status: '', senderSessionId: 's-w', + }); + assert.equal(interAgentPayload(message.content), 'line 1\nPayload:\nline 2'); + assert.equal(interAgentPayload('plain text'), 'plain text'); + assert.equal(interAgentEnvelope({ inter_agent: { type: 'OTHER' } }), null); + assert.equal(interAgentEnvelope(null), null); +}); + +// 场景:四种提示 —— 派任务 / 发消息 / 完成 / 出错。 +// 期望:FINAL_ANSWER 按 status 区分完成与出错,其余按类型。 +run('按类型与状态选择提示代码', () => { + assert.equal(interAgentNoticeCode(interAgentEnvelope(envelopeMessage('NEW_TASK', 'x').metadata)), 'inter_agent_task'); + assert.equal(interAgentNoticeCode(interAgentEnvelope(envelopeMessage('MESSAGE', 'x').metadata)), 'inter_agent_message'); + assert.equal(interAgentNoticeCode(interAgentEnvelope(envelopeMessage('FINAL_ANSWER', 'x', 'completed').metadata)), 'inter_agent_final'); + assert.equal(interAgentNoticeCode(interAgentEnvelope(envelopeMessage('FINAL_ANSWER', 'x', 'errored').metadata)), 'inter_agent_error'); +}); + +// 场景:transcript 摄入一条信封、一条普通用户消息、一条 assistant 消息。 +// 期望:只有信封被转成 system 行(保留 id 用于去重),正文换成 Payload; +// 其它消息引用不变。回归:若保持 user 角色,父 agent 的会话里每条子 agent +// 回报都会变成一个用户气泡并开出新回合。 +run('只有信封被转成系统提示行', () => { + const converted = presentInterAgentMessage(envelopeMessage('FINAL_ANSWER', 'report done', 'completed')); + assert.equal(converted.role, 'system'); + assert.equal(converted.id, 'u-1'); + assert.equal(converted.content, 'report done'); + assert.equal(converted.metadata.system_notice.code, 'inter_agent_final'); + assert.equal(converted.metadata.system_notice.params.sender, '/root/worker'); + const ordinary = { role: 'user', content: 'hi', metadata: {} }; + assert.equal(presentInterAgentMessage(ordinary), ordinary); + const assistant = { role: 'assistant', content: 'ok', metadata: { inter_agent: { type: 'MESSAGE' } } }; + assert.equal(presentInterAgentMessage(assistant), assistant); +}); + +// 场景:提示行交给 SystemRow 渲染。 +// 期望:标题带上发送方路径(中文目录),展开正文就是 Payload。 +run('提示行标题带发送方,正文为 Payload', () => { + const converted = presentInterAgentMessage(envelopeMessage('FINAL_ANSWER', 'report done', 'completed')); + const t = (key, options = {}) => { + const templates = { + 'systemNotice.titles.inter_agent_final': '{{sender}} 已完成', + }; + const template = templates[key]; + if (template == null) return options.defaultValue ?? key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name] ?? '')); + }; + const presented = presentSystemNotice(converted, t); + assert.equal(presented.title, '/root/worker 已完成'); + assert.equal(presented.text, 'report done'); +}); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 40c00aada..86301c7fa 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -108,6 +108,8 @@ import './inactiveSelection.test.js'; import './goalControl.test.js'; import './goalStatusBarArchitecture.test.js'; import './chatInputQueue.test.js'; +import './swarmMode.test.js'; +import './interAgentMessage.test.js'; import './chatInputQueueIntegrity.test.js'; import './turnInterruptionArchitecture.test.js'; import './composerEditabilityArchitecture.test.js'; diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index 3176800b9..6bde86c07 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -4,6 +4,8 @@ import { ensureSessionOpen, reportSessionOpen } from './sessionOpenDiagnostics.j import { connection } from './connection.js'; import { attachmentsFromContentParts, normalizeAttachmentList } from './messageAttachments.js'; import { sessionDisplayTitle } from './sessionTitle.js'; +import { normalizeSwarmMode } from './swarmMode.js'; +import { presentInterAgentMessage } from './interAgentMessage.js'; import { transcriptTimestampMs } from './timestamps.js'; import { fallbackToolSummary } from './toolSummaryFallback.js'; import { normalizeToolInvocationItems } from './transcriptProjection.js'; @@ -135,6 +137,9 @@ function sessionTitleFieldsFromRef(ref) { title: typeof s.title === 'string' ? s.title : '', titleSource: String(s.title_source ?? s.titleSource ?? ''), summary: typeof s.summary === 'string' ? s.summary : '', + // 会话级蜂群模式(add-mesh-swarm-mode)同理:侧栏对象给初值,之后由 + // messages 快照与 session_updated 刷新;输入框芯片跟随它。 + swarmMode: normalizeSwarmMode(s.swarm_mode ?? s.swarmMode), }; } @@ -883,7 +888,9 @@ function visibleTranscriptMessages(messages) { if (!Array.isArray(messages)) return []; return messages .map((m, index) => (m && typeof m === 'object' ? { ...m, __messageOrdinal: m.message_ordinal ?? m.__messageOrdinal ?? (m.message_position == null ? index : null) } : m)) - .filter((m) => !m?.is_meta && !m?.metadata?.hidden_goal_context); + .filter((m) => !m?.is_meta && !m?.metadata?.hidden_goal_context) + // 蜂群模式(网状)的 agent 间信封落盘为 user 角色,界面上显示成系统提示行。 + .map(presentInterAgentMessage); } function splitTranscriptMessages(messages) { @@ -1214,6 +1221,8 @@ export function createTranscriptState(overrides = {}) { title: '', titleSource: '', summary: '', + // 服务端会话的蜂群模式 off | star | mesh。 + swarmMode: 'off', status: 'idle', lastSeq: 0, isLive: false, @@ -1368,7 +1377,8 @@ export function reduceTranscriptEvent(state, msg) { } case 'message': { // 工具前言:assistant 正文里的 <text_preamble> 标签不进 transcript。 - const p = withVisibleAssistantContent(msg?.payload || {}); + // agent 间信封(user 角色)与历史一样转成系统提示行。 + const p = presentInterAgentMessage(withVisibleAssistantContent(msg?.payload || {})); const role = p.role || 'system'; if (role === 'assistant' && p.metadata?.transcript_only === true && p.metadata?.interrupted_output === true) { @@ -1695,6 +1705,9 @@ export function reduceTranscriptEvent(state, msg) { if (Object.prototype.hasOwnProperty.call(p, 'summary')) { next.summary = typeof p.summary === 'string' ? p.summary : ''; } + if (Object.prototype.hasOwnProperty.call(p, 'swarm_mode')) { + next.swarmMode = normalizeSwarmMode(p.swarm_mode); + } break; } case 'busy_changed': { @@ -1851,6 +1864,7 @@ export function loadTranscriptHistory(state, data = {}) { title: current.title || '', titleSource: current.titleSource || '', summary: current.summary || '', + swarmMode: normalizeSwarmMode(current.swarmMode), status: current.status || 'idle', isLive: !!current.isLive, lastSeq: 0, @@ -1875,6 +1889,7 @@ export function loadTranscriptHistory(state, data = {}) { next.titleSource = typeof data.title_source === 'string' ? data.title_source : ''; } if (typeof data.summary === 'string') next.summary = data.summary; + if (typeof data.swarm_mode === 'string') next.swarmMode = normalizeSwarmMode(data.swarm_mode); const seenMessages = new Set(msgs.map((m) => messageKey(m.role || 'system', m.content || ''))); let pendingStreamEvents = []; diff --git a/web/src/lib/subagentPanelSplitArchitecture.test.js b/web/src/lib/subagentPanelSplitArchitecture.test.js index 5971fa77b..aafe956b9 100644 --- a/web/src/lib/subagentPanelSplitArchitecture.test.js +++ b/web/src/lib/subagentPanelSplitArchitecture.test.js @@ -65,7 +65,11 @@ run('live spawn_subagent tool_start opens the panel through the task hook', () = assert.match(taskState, /export function isSubagentSpawnStartEvent\(parentSessionId, msg\)/); assert.match(taskState, /msg\?\.type !== 'tool_start'/); - assert.match(taskState, /eventSessionId === parentId && payload\.tool === 'spawn_subagent'/); + // 星型 spawn_subagent 与网状 agent_spawn(add-mesh-swarm-mode)都是面板打开信号。 + assert.match( + taskState, + /eventSessionId === parentId &&\s*\(payload\.tool === 'spawn_subagent' \|\| payload\.tool === 'agent_spawn'\)/, + ); assert.match(taskHook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart \} = \{\}\)/); assert.match(taskHook, /isSubagentSpawnStartEvent\(parentSessionId, msg\)/); assert.match(taskHook, /onSpawnStartRef\.current\?\.\(msg\)/); diff --git a/web/src/lib/subagentTasks.js b/web/src/lib/subagentTasks.js index b64217fd1..1706cf6a3 100644 --- a/web/src/lib/subagentTasks.js +++ b/web/src/lib/subagentTasks.js @@ -45,6 +45,8 @@ export function normalizeSubagentTask(session) { updatedAtMs: parseIsoMs(session.updated_at), tokens: readTokens(session), turnCount: Math.max(0, Number(session.turn_count) || 0), + // 蜂群模式(网状)的子 agent 都扁平挂在根会话下,真实层级看路径(/root/a/b)。 + agentPath: String(session.agent_path || ''), // 实时聚合字段:REST 快照没有工具级数据,从 0 起由 WS 事件累积。 toolCount: 0, lastTool: '', @@ -94,7 +96,7 @@ export function shouldRefreshSubagentTasksFromStatus( return !(knownTaskIds instanceof Set && knownTaskIds.has(sessionId)); } -// 只有当前父会话实时开始执行 spawn_subagent 才是面板自动打开信号。 +// 只有当前父会话实时开始执行 spawn_subagent / agent_spawn 才是面板自动打开信号。 // 初始 REST 快照、子会话状态和 tool_end 都不经过这条路径,避免打开旧会话 // 或用户手动关闭后被同一次任务的后续事件强制重开。 export function isSubagentSpawnStartEvent(parentSessionId, msg) { @@ -102,7 +104,8 @@ export function isSubagentSpawnStartEvent(parentSessionId, msg) { if (!parentId || msg?.type !== 'tool_start') return false; const payload = msg?.payload || {}; const eventSessionId = String(msg?.session_id || payload.session_id || '').trim(); - return eventSessionId === parentId && payload.tool === 'spawn_subagent'; + return eventSessionId === parentId && + (payload.tool === 'spawn_subagent' || payload.tool === 'agent_spawn'); } // 子会话自己的 WS 事件 → 任务增量。返回新数组;无关事件返回原引用 diff --git a/web/src/lib/subagentTasks.test.js b/web/src/lib/subagentTasks.test.js index 16955ff0d..7c735c799 100644 --- a/web/src/lib/subagentTasks.test.js +++ b/web/src/lib/subagentTasks.test.js @@ -183,6 +183,24 @@ run('只有当前父会话的 spawn_subagent tool_start 触发面板自动打开 }), false); }); +// 场景:蜂群模式(网状)用 agent_spawn 派子 agent(add-mesh-swarm-mode),子会话扁平挂在 +// 根会话下,真实层级在 agent_path 里。 +// 期望:agent_spawn 同样触发面板自动打开;任务对象带上 agentPath 供卡片显示层级; +// 星型子任务没有路径,字段为空串。 +run('网状 agent_spawn 同样打开面板,任务带 agent 路径', () => { + assert.equal(isSubagentSpawnStartEvent('parent-1', { + type: 'tool_start', + session_id: 'parent-1', + payload: { tool: 'agent_spawn', args: { task_name: 'worker', message: 'go' } }, + }), true); + assert.equal(isSubagentSpawnStartEvent('parent-1', { + type: 'tool_start', session_id: 'parent-1', payload: { tool: 'agent_send_message' }, + }), false); + assert.equal(normalizeSubagentTask(makeSession({ agent_path: '/root/lead/sub' })).agentPath, + '/root/lead/sub'); + assert.equal(normalizeSubagentTask(makeSession()).agentPath, ''); +}); + run('已知 child 的 session_status 可直接完成任务状态', () => { let tasks = mergeSubagentTaskList([], [makeSession({ busy: true })]); tasks = applySubagentSessionEvent(tasks, { diff --git a/web/src/lib/swarmMode.js b/web/src/lib/swarmMode.js new file mode 100644 index 000000000..814c55f3f --- /dev/null +++ b/web/src/lib/swarmMode.js @@ -0,0 +1,60 @@ +// 会话级蜂群模式(add-mesh-swarm-mode):off | star | mesh。 +// +// 模式存在服务端会话上(SessionMeta.swarm_mode),输入框只是它的镜像: +// - 服务端值来自 messages 快照与 session_updated 事件(transcript store 的 swarmMode); +// - 用户在菜单里改选时先记成「本地选择」,随下一条消息写给服务端,服务端确认 +// (session_updated)后本地选择清空,重新跟随服务端。 +// 只有本地选择与服务端不同才随消息提交 —— 否则 /swarm 命令或别的标签页刚切过的 +// 模式会被一条普通消息悄悄改回去,网状树里还有子 agent 在跑时还会直接 409。 + +export const SWARM_MODES = Object.freeze(['off', 'star', 'mesh']); + +// 旧客户端 / 旧会话里的布尔 true 等价星型;其它无法识别的值一律按关闭处理。 +export function normalizeSwarmMode(value) { + if (value === true) return 'star'; + if (typeof value !== 'string') return 'off'; + const mode = value.trim().toLowerCase(); + return SWARM_MODES.includes(mode) ? mode : 'off'; +} + +// 输入框显示的模式:未提交的本地选择优先,否则跟随服务端。 +export function effectiveSwarmMode(localChoice, serverMode) { + return localChoice == null ? normalizeSwarmMode(serverMode) : normalizeSwarmMode(localChoice); +} + +// 随消息提交的 swarm_mode;null = 不提交(与服务端一致或没有本地选择)。 +export function swarmModeForSubmission(localChoice, serverMode) { + if (localChoice == null) return null; + const local = normalizeSwarmMode(localChoice); + return local === normalizeSwarmMode(serverMode) ? null : local; +} + +// 服务端已经是本地选择的值 → 清空本地选择,重新跟随服务端。 +export function reconcileSwarmChoice(localChoice, serverMode) { + if (localChoice == null) return null; + return normalizeSwarmMode(localChoice) === normalizeSwarmMode(serverMode) ? null : localChoice; +} + +// 菜单里点一个模式:点已选中的那个 = 关闭,否则切到它(两种蜂群互斥)。 +export function pickSwarmMode(current, picked) { + const target = normalizeSwarmMode(picked); + return normalizeSwarmMode(current) === target ? 'off' : target; +} + +const LABELS = { + star: '蜂群模式(星型)', + mesh: '蜂群模式(网状)', +}; + +const TITLES = { + star: '主 Agent 积极派遣子 Agent 并汇总结果', + mesh: 'Agent 之间可以互相派任务、发消息,组成协作网络', +}; + +export function swarmModeLabel(mode) { + return LABELS[normalizeSwarmMode(mode)] || ''; +} + +export function swarmModeTitle(mode) { + return TITLES[normalizeSwarmMode(mode)] || ''; +} diff --git a/web/src/lib/swarmMode.test.js b/web/src/lib/swarmMode.test.js new file mode 100644 index 000000000..a4f357667 --- /dev/null +++ b/web/src/lib/swarmMode.test.js @@ -0,0 +1,72 @@ +// swarmMode.js 的单元测试(add-mesh-swarm-mode)。 +// +// 蜂群模式是会话级状态,输入框芯片 = 服务端值的镜像 + 未提交的本地选择。 +// 覆盖: +// - 模式名归一(旧客户端的 true = 星型,未知值 = 关闭) +// - 本地选择优先显示,只有与服务端不同才随消息提交 +// - 服务端确认后本地选择清空 +// - 菜单两项互斥,点已选中的那项 = 关闭 + +import assert from 'node:assert/strict'; +import { + effectiveSwarmMode, + normalizeSwarmMode, + pickSwarmMode, + reconcileSwarmChoice, + swarmModeForSubmission, + swarmModeLabel, + swarmModeTitle, +} from './swarmMode.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// 场景:服务端快照、旧会话 meta、旧客户端排队项里可能出现的各种取值。 +// 期望:规范名原样、大小写不敏感;布尔 true 是星型;其它一律关闭。 +run('蜂群模式名归一:true 兼容星型,未知值按关闭', () => { + assert.equal(normalizeSwarmMode('mesh'), 'mesh'); + assert.equal(normalizeSwarmMode(' STAR '), 'star'); + assert.equal(normalizeSwarmMode(true), 'star'); + assert.equal(normalizeSwarmMode(false), 'off'); + assert.equal(normalizeSwarmMode(undefined), 'off'); + assert.equal(normalizeSwarmMode('grid'), 'off'); +}); + +// 场景:会话在服务端是网状,用户在菜单里改成关闭但还没发消息。 +// 期望:芯片显示本地选择;提交时只带与服务端不同的值;没有本地选择时不提交 —— +// 否则 /swarm 刚切过的模式会被一条普通消息悄悄改回去。 +run('只有本地选择与服务端不同时才随消息提交', () => { + assert.equal(effectiveSwarmMode(null, 'mesh'), 'mesh'); + assert.equal(effectiveSwarmMode('off', 'mesh'), 'off'); + assert.equal(swarmModeForSubmission(null, 'mesh'), null); + assert.equal(swarmModeForSubmission('off', 'mesh'), 'off'); + assert.equal(swarmModeForSubmission('mesh', 'mesh'), null); + assert.equal(swarmModeForSubmission('star', undefined), 'star'); +}); + +// 场景:消息带着 swarm_mode 发出后,服务端广播 session_updated。 +// 期望:服务端已是本地选择的值时清空本地选择(重新跟随服务端),否则保留。 +run('服务端确认后清空本地选择', () => { + assert.equal(reconcileSwarmChoice('mesh', 'mesh'), null); + assert.equal(reconcileSwarmChoice('mesh', 'off'), 'mesh'); + assert.equal(reconcileSwarmChoice(null, 'star'), null); +}); + +// 场景:菜单里「蜂群模式(星型)」「蜂群模式(网状)」两项。 +// 期望:两项互斥;点当前已选中的那项等于关闭;文案与提示按模式给出。 +run('菜单两项互斥,再点已选中的一项即关闭', () => { + assert.equal(pickSwarmMode('off', 'mesh'), 'mesh'); + assert.equal(pickSwarmMode('star', 'mesh'), 'mesh'); + assert.equal(pickSwarmMode('mesh', 'mesh'), 'off'); + assert.equal(swarmModeLabel('star'), '蜂群模式(星型)'); + assert.equal(swarmModeLabel('mesh'), '蜂群模式(网状)'); + assert.equal(swarmModeLabel('off'), ''); + assert.notEqual(swarmModeTitle('mesh'), ''); +}); diff --git a/web/src/lib/systemNotice.js b/web/src/lib/systemNotice.js index ea47872be..3383bfbeb 100644 --- a/web/src/lib/systemNotice.js +++ b/web/src/lib/systemNotice.js @@ -129,7 +129,12 @@ function legacyDetails(text, t) { export function presentSystemNotice(message = {}, t = tr) { const { code, params, translated, text, opaque } = decodeSystemNotice(message); const roleCode = message.role === 'tool_call' ? 'tool_call' : ['tool', 'tool_result'].includes(message.role) ? 'tool_result' : ''; - const localizedTitle = t(`systemNotice.titles.${code || roleCode}`, { defaultValue: '' }); + // 标题只插值 agent 路径(蜂群模式(网状)的 agent 间消息):其它参数可能带 count 之类 + // 会改变 i18next 取词规则的键,不往标题里传。 + const titleValues = translated && params.sender != null + ? { sender: asText(params.sender), recipient: asText(params.recipient) } + : {}; + const localizedTitle = t(`systemNotice.titles.${code || roleCode}`, { ...titleValues, defaultValue: '' }); const custom = message.metadata?.compact_label; const fallback = custom && custom !== 'Context compacted' ? asText(custom) : text.trim().split(/\r?\n/)[0]; const title = localizedTitle || (fallback ? Array.from(fallback).slice(0, 80).join('') : t('systemNotice.titles.notice')); diff --git a/web/src/lib/useSubagentTasks.js b/web/src/lib/useSubagentTasks.js index 567d75f3e..5b02ccdf0 100644 --- a/web/src/lib/useSubagentTasks.js +++ b/web/src/lib/useSubagentTasks.js @@ -62,8 +62,9 @@ export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { } if (msg.type === 'tool_end') { const p = msg.payload || {}; - if ((p.tool === 'spawn_subagent' && p.metadata?.subagent_session_id) || - p.tool === 'wait_subagent') { + if (((p.tool === 'spawn_subagent' || p.tool === 'agent_spawn') && + p.metadata?.subagent_session_id) || + p.tool === 'wait_subagent' || p.tool === 'agent_wait') { refresh(); } }