# 子代理文件变更归集修复计划 ## 1. 问题背景 主任务执行需求开发时,可以通过 `delegate` 或 `delegate_followup` 调用子代理。子代理与主任务共享工作区,因此子代理新增或编辑的文件同样属于本次主任务的交付结果。 当前主任务完成后,主任务轮次下方的“文件变更”仅显示主代理直接通过 `write_file`、`edit_file` 修改的文件。子代理修改的文件只能在对应子代理会话中看到,未归集到主任务,导致用户看到的任务变更清单不完整。 ## 2. 现状与根因 ### 2.1 文件变更采集 `write_file` 和 `edit_file` 的工具结果包含 `file_change` 与内部 diff 信息。`HarnessService.saveFileChanges()` 将其写入 `message_file_change`,并关联: - 当前 ASSISTANT 消息 ID; - 当前执行会话 ID; - 文件路径、变更类型、增删行数和 diff 内容。 主代理执行时,记录使用主会话 ID;子代理通过独立的持久化回调执行,记录使用子代理的 `childSessionId`。因此子代理文件变更已正常采集,没有数据丢失。 相关代码: - `backend/src/main/java/cn/etarch/mao/harness/core/HarnessService.java` - `backend/src/main/java/cn/etarch/mao/harness/delegate/SubAgentVisibilityService.java` - `backend/src/main/java/cn/etarch/mao/session/entity/FileChange.java` ### 2.2 主会话历史查询 主会话消息接口按主会话 ID 和当前页消息 ID 查询 `message_file_change`: ```text message_file_change.session_id = 主会话 ID message_file_change.message_id IN 当前页消息 ID ``` 子代理变更的 `session_id` 和 `message_id` 均属于子会话,因此不会出现在主会话接口响应中。 相关代码: - `backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` - `backend/src/main/java/cn/etarch/mao/session/controller/SessionController.java` ### 2.3 前端轮次汇总 前端只汇总主会话当前轮次消息携带的 `fileChanges`: ```ts const fileChanges = [...steps, ...(reply ? [reply] : [])] .flatMap(message => message.fileChanges || []) ``` 由于主会话消息接口未返回子代理变更,前端无法展示完整清单。 相关代码: - `desktop/src/composables/useMessageRounds.ts` - `desktop/src/utils/chatMessage.ts` - `desktop/src/components/chat/FileChangePanel.vue` ## 3. 修复目标 1. 主任务完成后,对应主任务轮次展示主代理及本轮子代理产生的全部文件变更。 2. 子代理会话内继续展示其自身文件变更。 3. 支持同一轮并行调用多个子代理,并准确归属到各自的父 `delegate` 工具调用。 4. 支持 `delegate_followup`,且只归集本次追问执行新增的文件变更。 5. 同一路径被主代理或多个子代理反复修改时,列表按路径合并并保留可用 diff。 6. 不将独立边路任务 `SIDE_TASK` 的文件变更自动归入主任务。 7. 子代理失败或取消前已经成功完成的文件修改仍应展示。 8. 不改变 LLM 上下文和工具结果内容,仅调整任务结果的文件变更归集与展示。 ## 4. 总体方案 采用“执行级精确关联 + 后端归集”的方案: 1. 在 `subagent_execution` 中记录触发本次子代理执行的父工具调用 ID。 2. 在每次子代理执行开始和结束时记录子会话消息边界。 3. 主会话消息接口解析当前页主消息中的 `delegate` / `delegate_followup` 工具调用 ID。 4. 根据工具调用 ID 找到对应 `subagent_execution`。 5. 根据执行记录中的子会话和消息边界查询文件变更。 6. 将子代理变更附加到触发委派的父 ASSISTANT 消息上。 7. 前端沿用现有轮次汇总和按路径合并逻辑,只补充来源字段支持。 不采用按创建时间推断归属的方案。并行工具调用、长时间子代理执行和后续追问都会使时间范围不可靠,可能把变更挂到错误轮次。 ## 5. 数据库调整 新增 Flyway 迁移,为 `subagent_execution` 增加: ```sql ALTER TABLE subagent_execution ADD COLUMN parent_tool_call_id VARCHAR(128) NULL COMMENT '触发本次执行的父会话 delegate/delegate_followup 工具调用 ID', ADD COLUMN start_message_id BIGINT NULL COMMENT '本次执行开始前子会话最后一条消息 ID', ADD COLUMN end_message_id BIGINT NULL COMMENT '本次执行结束后子会话最后一条消息 ID'; CREATE INDEX idx_sae_parent_tool_call ON subagent_execution(parent_session_id, parent_tool_call_id); ``` 字段语义: - `parent_tool_call_id`:将父消息中的工具调用与子代理执行精确关联。 - `start_message_id`:本次执行开始前的子会话消息边界;首次委派可为初始 USER 消息之前的最后消息 ID,若不存在则按 `0` 处理。 - `end_message_id`:本次执行结束后的最后消息 ID。 - 本次执行文件变更范围为 `(start_message_id, end_message_id]`。 不修改 `message_file_change` 表结构。现有 `session_id` 和 `message_id` 足以定位执行范围内的变更。 ## 6. 后端实施步骤 ### 6.1 扩展子代理执行实体 修改: - `backend/src/main/java/cn/etarch/mao/harness/delegate/entity/SubagentExecution.java` 增加字段: ```java private String parentToolCallId; private Long startMessageId; private Long endMessageId; ``` ### 6.2 首次委派记录执行关联 修改: - `backend/src/main/java/cn/etarch/mao/harness/tool/impl/DelegateTool.java` 创建 `SubagentExecution` 时: 1. 读取 `ToolCallContext.getToolCallId()`。 2. 写入 `parentToolCallId`。 3. 保存子代理初始 USER 消息后,确定本次执行起始边界。 4. 执行结束后查询子会话最后一条消息 ID,写入 `endMessageId`。 5. 无论完成、失败还是取消,只要子会话已创建,都尽可能写入结束边界。 执行边界更新应与现有 `markExecutionTerminal()` 集中处理,避免不同终态路径遗漏。 ### 6.3 追问执行记录关联和边界 修改: - `backend/src/main/java/cn/etarch/mao/harness/tool/impl/DelegateFollowupTool.java` 在保存本次追问 USER 消息前: 1. 查询子会话当前最后一条消息 ID并保存为 `startMessageId`。 2. 创建执行记录时写入当前 `ToolCallContext.getToolCallId()`。 3. 执行结束后写入 `endMessageId`。 这样同一个子代理会话的多次 `delegate_followup` 可以分别归集,不会重复返回该子会话的全部历史变更。 ### 6.4 增加执行范围文件变更查询 修改: - `backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` 新增服务方法,输入父会话 ID和当前页父工具调用 ID集合,返回按父工具调用 ID分组的子代理文件变更。 建议返回结构: ```java Map> ``` 查询步骤: 1. 查询 `parent_session_id = 当前主会话` 且 `parent_tool_call_id IN (...)` 的执行记录。 2. 对每条执行记录按以下条件查询文件变更: - `message_file_change.session_id = child_session_id` - `message_file_change.message_id > start_message_id` - `message_file_change.message_id <= end_message_id` 3. 将结果按 `parent_tool_call_id` 分组。 4. 为每项补充子代理来源信息。 首期可按执行记录逐条查询以保证实现清晰;如果查询数量成为问题,再增加 Mapper 联表查询。由于消息接口默认只返回少量轮次,执行记录数量有限,不需要提前引入复杂 SQL。 ### 6.5 主消息接口归集 修改: - `backend/src/main/java/cn/etarch/mao/session/controller/SessionController.java` 扩展 `toMessageVOList()`: 1. 保留现有主会话文件变更查询。 2. 解析当前页所有 ASSISTANT 消息的 `toolCalls`。 3. 收集工具名为 `delegate` 或 `delegate_followup` 的工具调用 ID。 4. 批量查询这些调用对应的子代理文件变更。 5. 将子代理变更追加到包含该工具调用的父 ASSISTANT 消息 `fileChanges`。 主消息上的 `fileChanges` 顺序建议保持: 1. 主代理直接变更; 2. 按父消息工具调用顺序追加子代理变更; 3. 每个子代理内部按 `message_file_change.id` 升序。 控制器不负责按文件路径最终合并,继续由现有前端 `FileChangePanel` 完成展示层合并,避免改变单条消息的审计信息。 ### 6.6 文件变更来源字段 扩展 `FileChangeVO`: ```java private Long sourceSessionId; private String sourceType; private String sourceAgentType; ``` 字段值: - 主代理直接变更: - `sourceSessionId = 当前主会话 ID` - `sourceType = MAIN` - `sourceAgentType = null` - 子代理变更: - `sourceSessionId = childSessionId` - `sourceType = SUBAGENT` - `sourceAgentType = subagent_execution.agent_type` 子代理自身消息接口返回变更时,也可将来源标记为 `SUBAGENT`,但不得再次归集其父会话或其他子会话的变更。 ### 6.7 管理后台一致性 检查: - `backend/src/main/java/cn/etarch/mao/session/controller/AdminSessionController.java` 管理后台若也需要在主会话历史中看到完整任务变更,应复用同一服务层归集逻辑。不要在两个控制器中各自实现关联算法。 如果本次范围只覆盖用户端,应保证管理端原有查询不受影响,并将管理端归集列为后续事项。 ## 7. 前端实施步骤 ### 7.1 扩展文件变更类型 修改: - `desktop/src/types/chat.ts` 增加: ```ts sourceSessionId?: string sourceType?: 'MAIN' | 'SUBAGENT' sourceAgentType?: string ``` ### 7.2 映射接口来源字段 修改: - `desktop/src/utils/chatMessage.ts` 在 `mapMessagesWithFileChanges()` 中映射: ```ts sourceSessionId sourceType sourceAgentType ``` ### 7.3 保持现有轮次汇总 `useMessageRounds()` 无需额外拉取子代理会话。后端将子代理变更附加到父 ASSISTANT 消息后,现有轮次汇总逻辑会自动包含完整变更。 ### 7.4 文件路径合并规则 检查并补充: - `desktop/src/components/chat/FileChangePanel.vue` - `desktop/src/stores/session.ts` 同一路径合并规则保持: 1. 累加 `linesAdded` 和 `linesDeleted`。 2. 任一变更为 `CREATED` 时,最终类型保留 `CREATED`。 3. 两个 `SNAPSHOT` 合并时保留最早 `beforeContent` 和最新 `afterContent`。 4. 出现 `PATCH` 时沿用现有 patch 拼接逻辑。 5. 来源不同时可保留来源集合,但首期不要求修改 UI 展示。 不要仅按 `toolCallId` 去重。子代理内部文件工具调用 ID与父 `delegate` 调用 ID不是同一个概念。 ### 7.5 实时与终态行为 保留当前实时策略: - 子代理执行中,`file_change` WS 事件写入子会话缓存,在子代理 Tab 中可见。 - 不把子代理实时事件直接复制到父会话缓存,避免重复累加和未完成轮次提前展示。 - 主任务进入终态后,现有 phase watcher 重新拉取主会话消息;接口返回归集后的完整列表。 相关代码: - `desktop/src/composables/useStreamWS.ts` - `desktop/src/composables/useChat.ts` ## 8. 合并与 Diff 语义 文件变更列表展示的是任务执行期间的操作累计,不是最终 Git diff。归集时应遵循现有语义: - 主代理新增文件,子代理随后修改:显示 `CREATED`,累计新增和删除行数。 - 主代理修改文件,子代理再次修改:显示 `MODIFIED`,合并 diff。 - 多个子代理修改同一文件:列表只显示一个路径,累计各次变更。 - 文件先修改后恢复原状:仍可能显示操作累计;本次修复不改为 Git 工作区最终快照。 如果后续产品要求展示“任务结束时相对任务开始时的最终净变化”,应另行设计任务级工作区快照,不能与本次消息工具变更归集混在一起。 ## 9. 边界场景 ### 9.1 并行委派 同一父 ASSISTANT 消息可包含多个 `delegate` 调用。必须使用 `parent_tool_call_id` 分别关联,不得只按父会话或创建时间匹配。 ### 9.2 子代理失败或取消 只要文件工具已经成功执行并落库,无论子代理最终状态为 `FAILED` 或 `CANCELLED`,其变更都属于本次任务,应归集展示。 ### 9.3 委派工具本身失败 如果子会话未创建或没有执行记录,不返回子代理变更。主消息仍正常显示主代理直接变更。 ### 9.4 多次追问 每次 `delegate_followup` 都产生独立 `subagent_execution` 和消息边界,只归集该次执行范围内的变更。 ### 9.5 消息分页 仅解析当前页主消息中的工具调用 ID并查询对应执行记录。历史轮次翻页时按同样逻辑返回,避免首屏加载所有子代理文件变更。 ### 9.6 子代理 Tab 请求子代理会话自身消息时,仅返回自身消息直接关联的文件变更,不应再次根据其父关系做聚合。 ### 9.7 边路任务 `SIDE_TASK` 是独立任务,不自动归集进主任务。边路任务内部触发的子代理变更应归集到边路任务自己的轮次,而不是主任务。 因此归集逻辑应以“当前请求会话作为 `subagent_execution.parent_session_id`”查询,不能使用主任务的树形全量子代理列表。 ### 9.8 历史数据 迁移前的 `subagent_execution.parent_tool_call_id` 和消息边界为空。禁止按时间猜测归属,以免把历史变更挂到错误轮次。 历史行为: - 主任务仍只显示原有直接变更。 - 子代理 Tab 仍能看到子代理自身变更。 - 新版本部署后产生的执行记录支持完整归集。 项目当前无需兼容存量数据,因此也可以选择在非生产环境清理旧执行记录后验证。 ## 10. 测试计划 ### 10.1 后端单元测试 新增或扩展测试覆盖: 1. 主代理直接修改一个文件、子代理修改另一个文件,主消息返回两项。 2. 主代理与子代理修改同一路径,父消息返回两条审计项,前端可合并。 3. 同一轮并行两个 `delegate`,分别归属到正确工具调用。 4. 子代理多轮工具调用产生多个文件变更,全部归入对应父调用。 5. `delegate_followup` 只返回本次追问消息边界内的变更。 6. 同一子会话连续两次 followup,不重复上一轮变更。 7. 子代理 `FAILED`,已落库文件变更仍返回。 8. 子代理 `CANCELLED`,已落库文件变更仍返回。 9. 委派未创建子会话时不产生额外变更。 10. 请求子代理自身消息时不发生父子递归归集。 11. 边路任务触发子代理时,只归集到边路任务,不归集到主任务。 12. 消息分页只查询并返回当前页工具调用关联的变更。 13. 旧执行记录关联字段为空时安全跳过。 重点验证文件: - `DelegateToolTest` - `DelegateFollowupToolTest` - `SessionService` 新增聚合服务测试 - `SessionController` 消息接口测试 ### 10.2 前端测试 补充测试覆盖: 1. `mapMessagesWithFileChanges()` 正确映射来源字段。 2. 一个轮次中主代理与子代理变更均进入 `round.fileChanges`。 3. `FileChangePanel` 按路径合并主代理与子代理的同文件变更。 4. 两个 `SNAPSHOT` 保留最早 before 和最新 after。 5. 主任务完成后历史回拉不会与子代理实时缓存重复累计。 ### 10.3 构建验证 ```bash cd backend && mvn test cd desktop && npm run build ``` 不得自动重启 Mao 后端服务。后端改动部署后由用户自行重启服务。 ## 11. 预计修改文件 后端: - `backend/src/main/resources/db/migration/Vxxx__add_subagent_execution_file_change_scope.sql` - `backend/src/main/java/cn/etarch/mao/harness/delegate/entity/SubagentExecution.java` - `backend/src/main/java/cn/etarch/mao/harness/tool/impl/DelegateTool.java` - `backend/src/main/java/cn/etarch/mao/harness/tool/impl/DelegateFollowupTool.java` - `backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` - `backend/src/main/java/cn/etarch/mao/session/controller/SessionController.java` - 对应后端测试文件 前端: - `desktop/src/types/chat.ts` - `desktop/src/utils/chatMessage.ts` - 必要时调整 `desktop/src/components/chat/FileChangePanel.vue` - 对应前端测试文件 发版说明: - `CHANGELOG.md` ## 12. 实施顺序 1. 新增数据库迁移和实体字段。 2. 为 `delegate` 记录父工具调用 ID与消息边界。 3. 为 `delegate_followup` 记录父工具调用 ID与消息边界。 4. 实现执行范围内的文件变更查询服务。 5. 在主会话消息接口中按父工具调用归集子代理变更。 6. 增加来源字段并完成前端映射。 7. 补充后端和前端测试。 8. 执行后端测试与前端构建。 9. 更新 `CHANGELOG.md`。 ## 13. 验收标准 使用主任务执行以下流程: 1. 主代理直接编辑文件 A。 2. 主代理调用 coder 子代理编辑文件 B、C。 3. 主代理再次编辑文件 B。 4. 主任务完成。 主任务对应轮次下方应显示: - 文件 A; - 文件 B; - 文件 C。 其中: - 文件 B 只显示一次,增删行数和 diff 覆盖主代理与子代理的全部操作。 - 打开 coder 子代理 Tab,仍能看到子代理自身对 B、C 的变更。 - 刷新页面后结果一致。 - 并行调用多个子代理时不遗漏、不串轮次。 - 后续 `delegate_followup` 的新变更只出现在对应追问所在的父任务轮次中。