From eb5cf8090c2db387c07ea546a44bfbd57d07728a Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Mon, 18 May 2026 20:16:27 +0800 Subject: [PATCH 01/14] confirm the first version of the code --- docs/MULTI_AGENT_MVP_PLAN.md | 326 ++++++++++++++++++++++++++ docs/THE_FRONT_PAGE_OF_MULTI_AGENT.md | 100 ++++++++ 2 files changed, 426 insertions(+) create mode 100644 docs/MULTI_AGENT_MVP_PLAN.md create mode 100644 docs/THE_FRONT_PAGE_OF_MULTI_AGENT.md diff --git a/docs/MULTI_AGENT_MVP_PLAN.md b/docs/MULTI_AGENT_MVP_PLAN.md new file mode 100644 index 0000000..93c4b97 --- /dev/null +++ b/docs/MULTI_AGENT_MVP_PLAN.md @@ -0,0 +1,326 @@ +# Multi-Agent MVP 开发计划 + +本文档用于收敛 issue `#2`「support Agent Team collaboration」的第一版实现范围。 + +当前约束以仓库总纲和最新沟通为准: + +- 遵守 `AGENTS.md` 中“小步推进、逐步完成、优先最小可运行链路”的原则。 +- 第一版**不引入角色定义**;子 Agent 与主 Agent 使用**相同能力、相同工具、相同默认执行方式**。 +- 第一版目标是补齐最小的 Multi-Agent 协作闭环,而不是一次性实现技能系统、事件系统、定时调度、环境监控等完整能力。 + +## 1. 目标 + +在当前单 Agent 主链路稳定的前提下,为仓库增加最小 Multi-Agent 协同能力,支持: + +1. 主 Agent 创建子 Agent。 +2. 主 Agent 向子 Agent 分配任务。 +3. 子 Agent 基于当前已有执行能力独立完成任务。 +4. 子 Agent 将结果返回给主 Agent。 +5. 主 Agent 汇总子 Agent 结果并继续推进当前任务。 + +第一版不要求: + +- 不要求子 Agent 拥有独立角色。 +- 不要求子 Agent 拥有差异化工具集。 +- 不要求完整共享全部上下文历史。 +- 不要求事件总线、cron、环境监控、自动恢复。 +- 不要求动态技能加载与版本兼容。 + +## 2. 范围边界 + +### 2.1 本次要做 + +- 定义最小 Team 协作模型。 +- 定义主 Agent 与子 Agent 的任务委派协议。 +- 在现有单 Agent 执行链路之上增加 Team 协调层。 +- 复用现有 `AgentCoordinator` / `AbstractAgentExecutor` / 工具注册机制构建子 Agent。 +- 为多 Agent 任务流增加最小测试覆盖。 + +### 2.2 本次不做 + +- `AgentRole`、角色权限控制、差异化能力配置。 +- 中央技能注册中心与运行时热加载。 +- 通用事件总线、触发器引擎、环境监控器。 +- 跨进程 / 跨节点调度。 +- 分布式共享状态。 +- 为未来扩展预埋复杂抽象框架。 + +## 3. 设计原则 + +1. **单 Agent 主链路不回退** + Multi-Agent 作为新增能力接入,不破坏当前单 Agent 默认执行链路。 + +2. **协调层与执行层分离** + `AgentCoordinator` 继续负责单 Agent ReAct/CodeAct 执行闭环;Multi-Agent 调度逻辑不直接塞进单 Agent 执行器内部。 + +3. **先任务委派,再考虑复杂通信** + 第一版优先打通“创建子 Agent -> 分配任务 -> 返回结果 -> 主 Agent 汇总”的闭环。 + +4. **先传递任务上下文切片,不做全量上下文共享** + 子 Agent 接收的是主 Agent 明确下发的任务描述与必要上下文,而不是直接共享完整会话记忆。 + +5. **复用现有能力,不平行重造** + 子 Agent 默认复用现有模型、工具注册与执行器配置,避免新造一套运行时。 + +## 4. 建议的最小模型 + +第一版建议只定义以下核心模型,保持精简: + +- `TeamTask` + - 表示一个被委派给子 Agent 的任务。 + - 关键字段建议包括:`taskId`、`parentSessionId`、`delegatorAgentName`、`assigneeAgentName`、`taskPrompt`、`status`、`resultSummary`、`createdAt`。 + +- `TeamTaskStatus` + - 建议使用简单枚举:`PENDING`、`RUNNING`、`SUCCEEDED`、`FAILED`。 + +- `SubAgentExecutionRequest` + - 表示主 Agent 发起的一次子任务执行请求。 + - 关键字段建议包括:`taskPrompt`、`contextSummary`、`conversationId/sessionId`、`parentTaskId`。 + +- `SubAgentExecutionResult` + - 表示子 Agent 的执行结果。 + - 关键字段建议包括:`status`、`finalAnswer`、`errorMessage`、`toolTraceSummary`。 + +第一版暂不引入: + +- `AgentRole` +- `PermissionPolicy` +- `SkillDefinition` +- `EventBusMessage` + +这些能力如果后续需要,可以在 Multi-Agent MVP 跑通后再增量补齐。 + +## 5. 模块拆分建议 + +### 5.1 Domain 层 + +职责: + +- 定义 Team 任务与执行结果的领域模型。 +- 定义最小调度 port,不承载 Spring 或运行时细节。 + +建议新增内容: + +- `TeamTask` +- `TeamTaskStatus` +- `SubAgentExecutionRequest` +- `SubAgentExecutionResult` +- `TeamExecutionPort` 或 `SubAgentExecutionPort` + +### 5.2 Agent 层 + +职责: + +- 持有 Multi-Agent 协调逻辑。 +- 负责主 Agent 如何创建与调用子 Agent。 +- 控制上下文切片、委派顺序与结果汇总。 + +建议新增内容: + +- `TeamCoordinator` 或 `MultiAgentCoordinator` + - 负责任务拆分、子 Agent 调用、结果汇总。 +- `SubAgentFactory` + - 负责基于现有 `AgentCoordinator` 配置构造子 Agent 实例。 +- `SubAgentInvocationService` + - 封装一次子 Agent 执行流程,隔离调度层与底层执行器。 + +### 5.3 Infra 层 + +职责: + +- 提供默认内存态实现与 Spring 装配。 +- 对外暴露运行时装配入口。 + +建议新增内容: + +- `DefaultSubAgentExecutionService` +- `InMemoryTeamTaskStore` 或最小内存记录器 +- 对应 `@Configuration` 装配 + +### 5.4 aiframework 层 + +第一版原则: + +- 尽量不改,或只做非常小的复用型改动。 +- 如果 Team 协作不需要通用框架抽象,就不要把逻辑下沉到 `aiframework`。 + +## 6. 推荐实施阶段 + +## 阶段 A:设计收敛 + +目标: + +- 明确 Multi-Agent MVP 范围。 +- 确认第一版不做角色与权限分化。 +- 确认子 Agent 复用主 Agent 相同工具集与能力配置。 + +产出: + +- 本文档。 +- 必要时补充到 `docs/TECHNICAL_IMPLEMENTATION.md` 与 `docs/DEVELOPMENT_PROGRESS.md`。 + +完成标准: + +- 团队对第一版边界达成一致。 +- 不再把 cron / event / monitor / skills 一次性并入第一版实现。 + +## 阶段 B:最小模型与接口 + +目标: + +- 新增 Team 协作最小模型与接口。 +- 不动主链路行为,只补充结构。 + +建议工作项: + +1. 定义 `TeamTask`、`TeamTaskStatus`。 +2. 定义 `SubAgentExecutionRequest`、`SubAgentExecutionResult`。 +3. 定义 `SubAgentExecutionPort`。 + +完成标准: + +- 结构清晰。 +- 分层不回退。 +- 不出现为了未来扩展而过度抽象的通用 orchestrator 框架。 + +## 阶段 C:子 Agent 构造与执行复用 + +目标: + +- 能创建与主 Agent 同能力的子 Agent。 +- 子 Agent 可独立执行一个任务 prompt 并返回结果。 + +建议工作项: + +1. 提供 `SubAgentFactory`,复用现有模型、memory provider、工具注册配置。 +2. 明确子 Agent 的 system prompt 策略。 + - 第一版可以与主 Agent 基本一致。 + - 只补充“你是被主 Agent 委派的执行单元”这类最小约束。 +3. 封装单次子 Agent 执行入口。 + +完成标准: + +- 给定一个任务 prompt,可以成功执行子 Agent 并返回文本结果。 +- 不要求并发,不要求复杂状态同步。 + +## 阶段 D:主 Agent 委派闭环 + +目标: + +- 打通主 Agent 调用子 Agent 的最小闭环。 + +建议工作项: + +1. 设计委派入口。 + - 可以是新增内部服务调用。 + - 也可以是新增一个给主 Agent 使用的 team/delegate 工具。 +2. 主 Agent 创建 `TeamTask`。 +3. 调用子 Agent 完成任务。 +4. 将子 Agent 结果结构化回传给主 Agent。 +5. 主 Agent 基于返回结果继续回答。 + +完成标准: + +- 至少支持单个子 Agent 委派。 +- 最好补到支持顺序委派多个子 Agent,但不强求第一刀就做并发。 + +## 阶段 E:最小可观测性与测试 + +目标: + +- 为 Multi-Agent MVP 补最小验证闭环。 + +建议工作项: + +1. 增加单元测试: + - 模型与状态流转测试。 + - `SubAgentFactory` / `SubAgentExecutionService` 测试。 +2. 增加 smoke test: + - 主 Agent 委派子 Agent 后成功汇总结果。 +3. 如现有事件流足够复用,可补最小执行事件: + - 子任务开始 + - 子任务结束 + - 子任务失败 + +完成标准: + +- `compile` 通过。 +- 默认测试通过。 +- Multi-Agent 最小协作闭环有稳定自动化验证。 + +## 7. 推荐的开发顺序 + +建议按下面顺序推进: + +1. 写清楚文档与边界。 +2. 新增最小领域模型与 port。 +3. 实现子 Agent 工厂与单次执行服务。 +4. 实现主 Agent 委派闭环。 +5. 补测试与最小事件。 +6. 再评估是否进入下一阶段增强。 + +不建议的顺序: + +- 先做角色系统。 +- 先做技能中心。 +- 先做事件总线。 +- 先做 cron / trigger / 环境监控。 + +这些都容易把第一版范围拉爆。 + +## 8. 第一版建议验收口径 + +功能验收建议: + +1. 主 Agent 可以发起一次子任务委派。 +2. 子 Agent 可以复用现有工具完成任务。 +3. 子 Agent 能返回结构化执行结果。 +4. 主 Agent 能读取并整合结果。 +5. 失败路径可控,至少能返回失败状态与错误摘要。 + +测试验收建议: + +1. `./scripts/mvnw-local.sh -q -DskipTests compile` +2. `./scripts/mvnw-local.sh -q -DskipITs test` +3. 新增 Multi-Agent 相关 smoke test 通过 + +架构验收建议: + +- `domain -> port -> infra adapter` 分层不回退。 +- `agent` 层不混入 Spring 装配语义。 +- Multi-Agent 逻辑不污染单 Agent 默认运行面。 + +## 9. 第二版以后再评估的增强项 + +当 Multi-Agent MVP 稳定后,再考虑以下增强: + +1. 角色系统 + - 不同 Agent 拥有不同 system prompt、不同工具集、不同职责。 + +2. 技能系统 + - 统一 skills registry + - skill discovery + - 动态加载 + +3. 协作通信增强 + - 多子 Agent 并行 + - 更细粒度的任务状态同步 + - 更明确的消息协议 + +4. 事件与触发系统 + - 内部事件总线 + - 条件触发 + - 定时任务 + +5. 主动能力 + - cron + - 环境监控 + - 自动恢复工作流 + +## 10. 当前结论 + +对于 issue `#2`,第一版最合理的实现目标不是“完整 Agent Team 平台”,而是: + +**在不引入角色差异和复杂系统能力的前提下,先完成主 Agent 到子 Agent 的最小任务委派与结果汇总闭环。** + +这是当前最符合仓库阶段目标、代码边界和开发节奏的实现路径。 diff --git a/docs/THE_FRONT_PAGE_OF_MULTI_AGENT.md b/docs/THE_FRONT_PAGE_OF_MULTI_AGENT.md new file mode 100644 index 0000000..aaf22ff --- /dev/null +++ b/docs/THE_FRONT_PAGE_OF_MULTI_AGENT.md @@ -0,0 +1,100 @@ +# Multi-Agent MVP 第一版开发计划 + +**核心链路:** + +1. 前端把一个大任务发给主 agent。 +2. 主 agent 先做一次“任务分析与拆分”。 +3. 拆出来的子任务必须满足“可以独立执行、没有前后依赖、可以并行跑”。 +4. 主 agent 把这些子任务放进任务池。 +5. 多个子 agent 从任务池里领取任务。 +6. 子 agent 异步执行,执行结果回写到任务池。 +7. 主 agent 等所有子任务结束后,汇总结果,再给前端最终答复。 + +**任务池核心模型建议**: + +先直接存内存,不做持久化? + +TaskGroup +表示一次大任务拆分出来的一组并行子任务。 + +建议字段: + +- groupId +- sessionId +- parentPrompt +- status +- totalCount +- completedCount +- failedCount +- createdAt + +SubTask +表示一个具体子任务。 + +建议字段: + +- taskId +- groupId +- title +- prompt +- contextSummary +- status +- assigneeAgentId +- createdAt +- claimedAt +- finishedAt +- resultSummary +- errorMessage +- retryCount + +TaskStatus +建议枚举: + +- PENDING +- CLAIMED +- RUNNING +- SUCCEEDED +- FAILED + +对应 `待领取 -> 已领取 -> 执行中 -> 成功/失败` 五个状态 + +**子 agent** + +第一版先不真的为每个子任务 new 一个线程直接跑,或让每个子任务都 new 一个独立 agent runtime 长驻等待。 +目前方式是: + +- 启动固定数量的 SubAgentWorker +- 每个 worker 在后台轮询任务池 +- 抢到任务后,通过 SubAgentExecutionService 执行 +- 执行完后回写结果 +- 然后继续轮询下一个任务 + +**主agent** + +轮询查看子任务状态是否都为SUCCEEDED 或 FAILED + +**代码结构:** + +domain + +- TeamTaskGroup +- TeamSubTask +- TeamTaskStatus +- TaskDecompositionPlan +- SubAgentExecutionResult +- TaskPoolPort + +agent + +- MasterAgentOrchestrator +- TaskDecompositionService +- SubAgentFactory +- SubAgentExecutionService +- TaskResultAggregationService + +infra + +- InMemoryTaskPool +- SubAgentWorker +- SubAgentWorkerManager +- MultiAgentConfig From f88a4a5366256e249a85f7d29461e77afd3e96c6 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Tue, 19 May 2026 21:13:52 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=80?= =?UTF-8?q?=E5=B0=8Fagent=20team=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- frontend/src/App.tsx | 3 +- frontend/src/api/agentApi.ts | 9 +- frontend/src/types/api.ts | 1 + .../AgentTeamApplicationService.java | 17 + ...entTeamConversationApplicationService.java | 169 +++++++++ ...mExecutionStreamingApplicationService.java | 243 +++++++++++++ .../application/AgentTeamPromptProvider.java | 11 + .../application/MasterAgentOrchestrator.java | 160 +++++++++ .../application/PromptTemplateRenderer.java | 25 ++ .../application/SubAgentExecutionService.java | 72 ++++ .../application/SubTaskExecutionOutput.java | 12 + .../application/TaskDecompositionService.java | 333 ++++++++++++++++++ .../agentteam/domain/model/AgentMessage.java | 36 ++ .../domain/model/DecompositionPlan.java | 18 + .../agentteam/domain/model/MessageType.java | 12 + .../agentteam/domain/model/SubTask.java | 58 +++ .../domain/model/SubTaskFailure.java | 11 + .../agentteam/domain/model/SubTaskPlan.java | 12 + .../agentteam/domain/model/SubTaskResult.java | 12 + .../agentteam/domain/model/TaskGroup.java | 56 +++ .../domain/model/TaskGroupResult.java | 19 + .../domain/model/TaskGroupSnapshot.java | 20 ++ .../domain/model/TaskGroupStatus.java | 12 + .../agentteam/domain/model/TaskStatus.java | 16 + .../domain/port/AgentMessageBusPort.java | 14 + .../domain/port/TaskGroupRepositoryPort.java | 20 ++ .../agentteam/domain/port/TaskPoolPort.java | 23 ++ .../DefaultResultAggregationService.java | 52 +++ .../service/DefaultTaskGroupManager.java | 70 ++++ .../service/ResultAggregationService.java | 12 + .../domain/service/TaskGroupManager.java | 16 + .../service/TaskGroupStatusCalculator.java | 69 ++++ .../ClasspathAgentTeamPromptProvider.java | 46 +++ .../infra/InMemoryAgentMessageBus.java | 64 ++++ .../infra/InMemoryTaskGroupRepository.java | 62 ++++ .../agentteam/infra/InMemoryTaskPool.java | 144 ++++++++ .../agentteam/infra/SubAgentWorker.java | 132 +++++++ .../infra/SubAgentWorkerManager.java | 72 ++++ src/main/java/com/openmanus/agentteam/plan.md | 236 +++++++++++++ .../infra/config/AgentTeamConfig.java | 132 +++++++ .../infra/config/AgentTeamProperties.java | 18 + .../infra/config/DomainServiceConfig.java | 33 ++ .../openmanus/infra/web/AgentController.java | 45 ++- src/main/resources/application-example.yml | 7 + .../agentteam/subagent-execution.prompt.md | 20 ++ .../agentteam/task-decomposition.prompt.md | 15 + .../MasterAgentOrchestratorTest.java | 232 ++++++++++++ .../TaskDecompositionServiceTest.java | 163 +++++++++ .../DefaultResultAggregationServiceTest.java | 60 ++++ .../service/DefaultTaskGroupManagerTest.java | 87 +++++ .../TaskGroupStatusCalculatorTest.java | 81 +++++ .../infra/InMemoryAgentMessageBusTest.java | 59 ++++ .../agentteam/infra/InMemoryTaskPoolTest.java | 70 ++++ ...WebSocketExecutionStreamPublisherTest.java | 2 +- ...gentControllerSessionSandboxStartTest.java | 13 +- 56 files changed, 3394 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamApplicationService.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java create mode 100644 src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java create mode 100644 src/main/java/com/openmanus/agentteam/application/PromptTemplateRenderer.java create mode 100644 src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java create mode 100644 src/main/java/com/openmanus/agentteam/application/SubTaskExecutionOutput.java create mode 100644 src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/AgentMessage.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/DecompositionPlan.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/MessageType.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/SubTask.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/SubTaskFailure.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/SubTaskResult.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/TaskGroup.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/TaskGroupResult.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/TaskGroupSnapshot.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/TaskGroupStatus.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/TaskStatus.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/port/AgentMessageBusPort.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/port/TaskGroupRepositoryPort.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationService.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManager.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/service/ResultAggregationService.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/service/TaskGroupManager.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculator.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBus.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/InMemoryTaskGroupRepository.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/SubAgentWorkerManager.java create mode 100644 src/main/java/com/openmanus/agentteam/plan.md create mode 100644 src/main/java/com/openmanus/infra/config/AgentTeamConfig.java create mode 100644 src/main/java/com/openmanus/infra/config/AgentTeamProperties.java create mode 100644 src/main/resources/prompts/agentteam/subagent-execution.prompt.md create mode 100644 src/main/resources/prompts/agentteam/task-decomposition.prompt.md create mode 100644 src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java create mode 100644 src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java create mode 100644 src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java create mode 100644 src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java create mode 100644 src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java create mode 100644 src/test/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBusTest.java create mode 100644 src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java diff --git a/.gitignore b/.gitignore index 515df1a..ea96dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -229,4 +229,6 @@ temp/ # OS specific Thumbs.db ehthumbs.db -Desktop.ini \ No newline at end of file +Desktop.ini + +openManus.md \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index daae630..0cae297 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -99,7 +99,8 @@ export default function App(): JSX.Element { const startData = await startWorkflow({ input: content, - sessionId: state.sessionId || undefined + sessionId: state.sessionId || undefined, + agentTeam: true }); const sessionId = startData.session_id || startData.sessionId || ''; const topic = startData.topic || ''; diff --git a/frontend/src/api/agentApi.ts b/frontend/src/api/agentApi.ts index 7679e27..5b602e5 100644 --- a/frontend/src/api/agentApi.ts +++ b/frontend/src/api/agentApi.ts @@ -18,12 +18,17 @@ export class ApiError extends Error { } export async function startWorkflow(payload: WorkflowRequestPayload): Promise { - const response = await fetch('/api/agent/workflow-stream', { + const agentTeam = payload.agentTeam === true; + const requestPayload = { + input: payload.input, + sessionId: payload.sessionId + }; + const response = await fetch('/api/agent/workflow-stream' + (agentTeam ? '?agentTeam=true' : ''), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(requestPayload) }); const data = (await response.json()) as StreamStartResponse; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 37e06f7..3ccbd05 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -83,6 +83,7 @@ export interface SessionInfoPayload { export interface WorkflowRequestPayload { input: string; sessionId?: string; + agentTeam?: boolean; } export interface ThoughtStep { diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamApplicationService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamApplicationService.java new file mode 100644 index 0000000..307f199 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamApplicationService.java @@ -0,0 +1,17 @@ +package com.openmanus.agentteam.application; + +/** + * Thin facade for agent-team execution use cases. + */ +public class AgentTeamApplicationService { + + private final MasterAgentOrchestrator masterAgentOrchestrator; + + public AgentTeamApplicationService(MasterAgentOrchestrator masterAgentOrchestrator) { + this.masterAgentOrchestrator = masterAgentOrchestrator; + } + + public String execute(String userInput, String conversationId) { + return masterAgentOrchestrator.execute(userInput, conversationId); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java new file mode 100644 index 0000000..9e0c8e4 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java @@ -0,0 +1,169 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; +import com.openmanus.domain.service.ExecutionEventPort; +import com.openmanus.domain.service.SessionExecutionGuard; +import com.openmanus.domain.service.SessionIdPolicy; +import org.slf4j.MDC; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +/** + * HTTP-facing application service for the agentteam execution path. + */ +public class AgentTeamConversationApplicationService { + + private static final String EXECUTION_COORDINATOR = "agentteam_coordinator"; + private static final String EXECUTION_START = "AGENTTEAM_EXECUTION_START"; + private static final String EXECUTION_COMPLETE = "AGENTTEAM_EXECUTION_COMPLETE"; + private static final String EXECUTION_ERROR = "AGENTTEAM_EXECUTION_ERROR"; + private static final String DEFAULT_USER_ID = "001"; + private static final String SESSION_BUSY_MESSAGE = "当前会话正在执行中,请稍后重试"; + + private final AgentTeamApplicationService agentTeamApplicationService; + private final ExecutionEventPort executionEventPort; + private final SessionExecutionGuard sessionExecutionGuard; + private final Executor asyncExecutor; + + public AgentTeamConversationApplicationService( + AgentTeamApplicationService agentTeamApplicationService, + ExecutionEventPort executionEventPort, + SessionExecutionGuard sessionExecutionGuard, + Executor asyncExecutor + ) { + this.agentTeamApplicationService = agentTeamApplicationService; + this.executionEventPort = executionEventPort; + this.sessionExecutionGuard = sessionExecutionGuard; + this.asyncExecutor = asyncExecutor; + } + + public CompletableFuture> chat(String message, String conversationId, boolean sync) { + final String sessionId = normalizeSessionId(conversationId); + final String userId = currentUserId(); + + if (message == null || message.trim().isEmpty()) { + Map errorResult = new HashMap<>(); + errorResult.put("error", "message不能为空"); + errorResult.put("errorCode", ExecutionErrorCodes.INPUT_INVALID); + errorResult.put("conversationId", sessionId); + return CompletableFuture.completedFuture(errorResult); + } + + if (!sessionExecutionGuard.tryAcquire(sessionId)) { + return CompletableFuture.completedFuture(busyResult(sessionId)); + } + + if (sync) { + try { + return CompletableFuture.completedFuture(executeSyncWithMonitoring(message, sessionId, userId)); + } finally { + sessionExecutionGuard.release(sessionId); + } + } + + return CompletableFuture.supplyAsync(() -> executeSyncWithMonitoring(message, sessionId, userId), asyncExecutor) + .exceptionally(error -> completeFailure(sessionId, unwrapException(error))) + .whenComplete((ignored, throwable) -> sessionExecutionGuard.release(sessionId)); + } + + private Map executeSyncWithMonitoring(String message, String sessionId, String userId) { + try (MDC.MDCCloseable ignoredSession = MDC.putCloseable("sessionId", sessionId); + MDC.MDCCloseable ignoredUser = MDC.putCloseable("userId", userId)) { + startMonitoring(sessionId, message); + String response = agentTeamApplicationService.execute(message, sessionId); + return completeSuccess(sessionId, response); + } catch (Exception error) { + return completeFailure(sessionId, error); + } + } + + private void startMonitoring(String sessionId, String message) { + executionEventPort.startExecutionTracking(sessionId, message); + executionEventPort.startExecution(sessionId, EXECUTION_COORDINATOR, EXECUTION_START, message); + } + + private Map completeSuccess(String sessionId, String response) { + executionEventPort.endExecutionTracking(sessionId, response, true); + executionEventPort.endExecution( + sessionId, + EXECUTION_COORDINATOR, + EXECUTION_COMPLETE, + response, + "SUCCESS" + ); + return successResult(sessionId, response); + } + + private Map completeFailure(String sessionId, Throwable error) { + String message = safeErrorMessage(error); + executionEventPort.endExecutionTracking(sessionId, "执行出错: " + message, false); + executionEventPort.recordError(sessionId, EXECUTION_COORDINATOR, EXECUTION_ERROR, message); + executionEventPort.endExecution( + sessionId, + EXECUTION_COORDINATOR, + EXECUTION_COMPLETE, + "执行出错: " + message, + "ERROR" + ); + return errorResult(sessionId, message); + } + + private static Throwable unwrapException(Throwable throwable) { + if (throwable == null) { + return new IllegalStateException("unknown error"); + } + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private static String safeErrorMessage(Throwable throwable) { + if (throwable == null) { + return "unknown error"; + } + String message = throwable.getMessage(); + if (message == null || message.isBlank()) { + return "unknown error"; + } + return message.trim(); + } + + private static Map successResult(String sessionId, String response) { + Map result = new HashMap<>(); + result.put("answer", response); + result.put("conversationId", sessionId); + result.put("timestamp", LocalDateTime.now().toString()); + result.put("mode", "agentteam"); + return result; + } + + private static Map errorResult(String sessionId, String message) { + Map errorResult = new HashMap<>(); + errorResult.put("error", message); + errorResult.put("conversationId", sessionId); + return errorResult; + } + + private static Map busyResult(String sessionId) { + Map errorResult = new HashMap<>(); + errorResult.put("error", SESSION_BUSY_MESSAGE); + errorResult.put("errorCode", ExecutionErrorCodes.SESSION_BUSY); + errorResult.put("conversationId", sessionId); + return errorResult; + } + + private static String normalizeSessionId(String rawConversationId) { + return SessionIdPolicy.normalizeOrGenerate(rawConversationId); + } + + private static String currentUserId() { + String userId = MDC.get("userId"); + return userId == null || userId.isBlank() ? DEFAULT_USER_ID : userId; + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java new file mode 100644 index 0000000..425d766 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java @@ -0,0 +1,243 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; +import com.openmanus.domain.model.ExecutionResponse; +import com.openmanus.domain.model.ExecutionResultView; +import com.openmanus.domain.service.ExecutionEventPort; +import com.openmanus.domain.service.ExecutionStreamPublisher; +import com.openmanus.domain.service.SessionExecutionGuard; +import com.openmanus.domain.service.SessionIdPolicy; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +/** + * Streaming application service for the agentteam execution path. + */ +@Slf4j +public class AgentTeamExecutionStreamingApplicationService { + + private static final String SESSION_ID_KEY = "sessionId"; + private static final String EXECUTION_COORDINATOR = "agentteam_coordinator"; + private static final String EXECUTION_START = "AGENTTEAM_EXECUTION_START"; + private static final String EXECUTION_COMPLETE = "AGENTTEAM_EXECUTION_COMPLETE"; + private static final String EXECUTION_ERROR = "AGENTTEAM_EXECUTION_ERROR"; + private static final String SESSION_BUSY_MESSAGE = "当前会话正在执行中,请稍后重试"; + + private final AgentTeamApplicationService agentTeamApplicationService; + private final ExecutionEventPort executionEventPort; + private final ExecutionStreamPublisher streamPublisher; + private final Executor asyncExecutor; + private final SessionExecutionGuard sessionExecutionGuard; + + public AgentTeamExecutionStreamingApplicationService( + AgentTeamApplicationService agentTeamApplicationService, + ExecutionEventPort executionEventPort, + ExecutionStreamPublisher streamPublisher, + Executor asyncExecutor, + SessionExecutionGuard sessionExecutionGuard + ) { + this.agentTeamApplicationService = agentTeamApplicationService; + this.executionEventPort = executionEventPort; + this.streamPublisher = streamPublisher; + this.asyncExecutor = asyncExecutor; + this.sessionExecutionGuard = sessionExecutionGuard; + } + + public ExecutionResponse executeAndStreamEvents(String userInput, String requestedSessionId) { + if (userInput == null || userInput.trim().isEmpty()) { + return ExecutionResponse.builder() + .success(false) + .error("输入不能为空") + .errorCode(ExecutionErrorCodes.INPUT_INVALID) + .build(); + } + + String sessionId = resolveSessionId(requestedSessionId); + if (!sessionExecutionGuard.tryAcquire(sessionId)) { + return ExecutionResponse.builder() + .success(false) + .sessionId(sessionId) + .error(SESSION_BUSY_MESSAGE) + .errorCode(ExecutionErrorCodes.SESSION_BUSY) + .build(); + } + String executionId = UUID.randomUUID().toString(); + String executionTopic = executionTopic(sessionId, executionId); + + final String currentSessionId = sessionId; + ExecutionEventPort.Listener listener = event -> { + if (event == null || !currentSessionId.equals(event.getSessionId())) { + return; + } + streamPublisher.publishEvent(executionTopic, event); + }; + + try { + executionEventPort.addListener(currentSessionId, listener); + } catch (RuntimeException exception) { + sessionExecutionGuard.release(sessionId); + log.error("Listener registration failed: sessionId={}", sessionId, exception); + return ExecutionResponse.builder() + .success(false) + .sessionId(sessionId) + .executionId(executionId) + .error("内部错误,请稍后重试") + .errorCode(ExecutionErrorCodes.INTERNAL_ERROR) + .build(); + } + + try { + final String finalSessionId = sessionId; + final String finalExecutionTopic = executionTopic; + asyncExecutor.execute(() -> executeExecutionInternal(userInput, finalSessionId, finalExecutionTopic, listener)); + } catch (RejectedExecutionException exception) { + removeListenerSafely(currentSessionId, listener); + sessionExecutionGuard.release(sessionId); + log.error("Async task rejected: sessionId={}", sessionId, exception); + return ExecutionResponse.builder() + .success(false) + .sessionId(sessionId) + .executionId(executionId) + .error("任务提交失败,请稍后重试") + .errorCode(ExecutionErrorCodes.ASYNC_SUBMIT_REJECTED) + .build(); + } catch (RuntimeException exception) { + removeListenerSafely(currentSessionId, listener); + sessionExecutionGuard.release(sessionId); + log.error("Async task submit failed: sessionId={}", sessionId, exception); + return ExecutionResponse.builder() + .success(false) + .sessionId(sessionId) + .executionId(executionId) + .error("任务提交异常,请稍后重试") + .errorCode(ExecutionErrorCodes.ASYNC_SUBMIT_EXCEPTION) + .build(); + } + + return ExecutionResponse.builder() + .success(true) + .sessionId(sessionId) + .executionId(executionId) + .build(); + } + + void executeExecutionInternal( + String userInput, + String sessionId, + String executionTopic, + ExecutionEventPort.Listener listener + ) { + LocalDateTime startTime = LocalDateTime.now(); + try (MDC.MDCCloseable ignored = MDC.putCloseable(SESSION_ID_KEY, sessionId)) { + log.info("AgentTeam execution started: sessionId={}", sessionId); + executionEventPort.startExecutionTracking(sessionId, userInput); + executionEventPort.startExecution(sessionId, EXECUTION_COORDINATOR, EXECUTION_START, userInput); + String result = agentTeamApplicationService.execute(userInput, sessionId); + executionEventPort.endExecutionTracking(sessionId, result, true); + executionEventPort.endExecution(sessionId, EXECUTION_COORDINATOR, EXECUTION_COMPLETE, result, "SUCCESS"); + + LocalDateTime endTime = LocalDateTime.now(); + long executionTimeMs = ChronoUnit.MILLIS.between(startTime, endTime); + log.info("AgentTeam execution completed: sessionId={}, durationMs={}", sessionId, executionTimeMs); + sendExecutionResult(executionTopic, sessionId, userInput, result, "SUCCESS", endTime, executionTimeMs); + } catch (RuntimeException exception) { + Throwable actualError = unwrapException(exception); + String errorMessage = safeErrorMessage(actualError); + log.error("AgentTeam execution failed: sessionId={}", sessionId, exception); + executionEventPort.endExecutionTracking(sessionId, "执行出错: " + errorMessage, false); + executionEventPort.recordError(sessionId, EXECUTION_COORDINATOR, EXECUTION_ERROR, errorMessage); + executionEventPort.endExecution( + sessionId, + EXECUTION_COORDINATOR, + EXECUTION_COMPLETE, + "执行出错: " + errorMessage, + "ERROR" + ); + long executionTimeMs = ChronoUnit.MILLIS.between(startTime, LocalDateTime.now()); + sendExecutionResult( + executionTopic, + sessionId, + userInput, + "执行出错: " + errorMessage, + "ERROR", + LocalDateTime.now(), + executionTimeMs + ); + } finally { + removeListenerSafely(sessionId, listener); + sessionExecutionGuard.release(sessionId); + } + } + + private void sendExecutionResult( + String executionTopic, + String sessionId, + String userInput, + String result, + String status, + LocalDateTime completedTime, + long executionTimeMs + ) { + ExecutionResultView resultView = ExecutionResultView.builder() + .sessionId(sessionId) + .userInput(userInput) + .result(result) + .status(status) + .completedTime(completedTime) + .executionTime(executionTimeMs) + .build(); + try { + streamPublisher.publishResult(executionTopic, resultView); + } catch (Exception exception) { + log.debug("Unable to publish result for session {}: {}", sessionId, exception.getMessage()); + } + } + + private String resolveSessionId(String requestedSessionId) { + String normalized = SessionIdPolicy.normalizeOrNull(requestedSessionId); + if (normalized != null) { + return normalized; + } + return UUID.randomUUID().toString(); + } + + private static String executionTopic(String sessionId, String executionId) { + return "/topic/executions/" + sessionId + "/" + executionId; + } + + private void removeListenerSafely(String sessionId, ExecutionEventPort.Listener listener) { + try { + executionEventPort.removeListener(sessionId, listener); + } catch (RuntimeException exception) { + log.warn("Listener cleanup failed: sessionId={}", sessionId, exception); + } + } + + private static Throwable unwrapException(Throwable throwable) { + if (throwable == null) { + return new IllegalStateException("unknown error"); + } + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private static String safeErrorMessage(Throwable throwable) { + if (throwable == null) { + return "unknown error"; + } + String message = throwable.getMessage(); + if (message == null || message.isBlank()) { + return "unknown error"; + } + return message.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java new file mode 100644 index 0000000..797356a --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java @@ -0,0 +1,11 @@ +package com.openmanus.agentteam.application; + +/** + * Provides prompt templates used by the agentteam module. + */ +public interface AgentTeamPromptProvider { + + String taskDecompositionPromptTemplate(); + + String subAgentExecutionPromptTemplate(); +} diff --git a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java new file mode 100644 index 0000000..41e9d18 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java @@ -0,0 +1,160 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.agentteam.domain.model.DecompositionPlan; +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.SubTaskPlan; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupResult; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; +import com.openmanus.agentteam.domain.service.ResultAggregationService; +import com.openmanus.agentteam.domain.service.TaskGroupManager; +import com.openmanus.agentteam.domain.port.TaskPoolPort; +import com.openmanus.domain.service.AgentExecutionPort; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Main orchestration entry for the V1 agent-team flow. + */ +@Slf4j +public class MasterAgentOrchestrator { + + private final AgentExecutionPort agentExecutionPort; + private final TaskDecompositionService decompositionService; + private final TaskGroupManager taskGroupManager; + private final TaskPoolPort taskPoolPort; + private final ResultAggregationService resultAggregationService; + private final com.openmanus.agentteam.infra.SubAgentWorkerManager workerManager; + private final long masterPollIntervalMillis; + private final int maxSubTasksPerGroup; + + public MasterAgentOrchestrator( + AgentExecutionPort agentExecutionPort, + TaskDecompositionService decompositionService, + TaskGroupManager taskGroupManager, + TaskPoolPort taskPoolPort, + ResultAggregationService resultAggregationService, + com.openmanus.agentteam.infra.SubAgentWorkerManager workerManager, + long masterPollIntervalMillis, + int maxSubTasksPerGroup + ) { + this.agentExecutionPort = agentExecutionPort; + this.decompositionService = decompositionService; + this.taskGroupManager = taskGroupManager; + this.taskPoolPort = taskPoolPort; + this.resultAggregationService = resultAggregationService; + this.workerManager = workerManager; + this.masterPollIntervalMillis = masterPollIntervalMillis; + this.maxSubTasksPerGroup = maxSubTasksPerGroup; + } + + public String execute(String userInput, String conversationId) { + DecompositionPlan plan = decompositionService.decompose(userInput, maxSubTasksPerGroup); + log.info( + "MasterAgent decomposition finished: parallelizable={}, subTaskCount={}, reason={}", + plan.parallelizable(), + plan.subTasks() == null ? 0 : plan.subTasks().size(), + plan.reason() + ); + if (!plan.parallelizable()) { + log.info("MasterAgent falling back to single-agent execution: conversationId={}", conversationId); + return agentExecutionPort.executeSync(userInput, conversationId); + } + + workerManager.ensureStarted(); + TaskGroup taskGroup = taskGroupManager.createGroup( + conversationId == null || conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId, + "master-agent", + userInput + ); + List subTasks = materializeSubTasks(taskGroup.getGroupId(), plan.subTasks()); + log.info( + "MasterAgent created task group: groupId={}, conversationId={}, subTaskCount={}", + taskGroup.getGroupId(), + conversationId, + subTasks.size() + ); + taskGroupManager.registerSubTasks(taskGroup.getGroupId(), subTasks); + for (SubTask subTask : subTasks) { + log.info( + "MasterAgent submitting subtask to pool: groupId={}, taskId={}, title={}", + taskGroup.getGroupId(), + subTask.getTaskId(), + subTask.getTitle() + ); + taskPoolPort.submit(subTask); + } + + TaskGroupSnapshot snapshot = waitForCompletion(taskGroup.getGroupId()); + TaskGroupResult result = resultAggregationService.aggregate( + taskGroup, + taskPoolPort.findByGroupId(taskGroup.getGroupId()) + ); + log.info( + "MasterAgent aggregation finished: groupId={}, status={}, successCount={}, failedCount={}", + taskGroup.getGroupId(), + snapshot.status(), + snapshot.succeededTasks(), + snapshot.failedTasks() + ); + return renderFinalAnswer(taskGroup, snapshot, result); + } + + private List materializeSubTasks(String groupId, List subTaskPlans) { + List subTasks = new ArrayList<>(); + long now = System.currentTimeMillis(); + for (SubTaskPlan plan : subTaskPlans) { + subTasks.add(new SubTask( + UUID.randomUUID().toString(), + groupId, + plan.title(), + plan.description(), + now + )); + } + return subTasks; + } + + private TaskGroupSnapshot waitForCompletion(String groupId) { + while (true) { + TaskGroupSnapshot snapshot = taskGroupManager.getSnapshot(groupId); + if (snapshot.allFinished()) { + return snapshot; + } + try { + Thread.sleep(masterPollIntervalMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("master polling interrupted", exception); + } + } + } + + private String renderFinalAnswer(TaskGroup taskGroup, TaskGroupSnapshot snapshot, TaskGroupResult result) { + StringBuilder builder = new StringBuilder(); + builder.append("AgentTeam execution finished.\n"); + builder.append("groupId: ").append(taskGroup.getGroupId()).append('\n'); + builder.append("status: ").append(snapshot.status()).append('\n'); + builder.append("success: ").append(snapshot.succeededTasks()).append('\n'); + builder.append("failed: ").append(snapshot.failedTasks()).append('\n'); + + if (!result.successResults().isEmpty()) { + builder.append("\nSuccessful subtasks:\n"); + for (var success : result.successResults()) { + builder.append("- ").append(success.title()).append(": ") + .append(success.summary()).append('\n'); + } + } + if (!result.failures().isEmpty()) { + builder.append("\nFailed subtasks:\n"); + for (var failure : result.failures()) { + builder.append("- ").append(failure.title()).append(": ") + .append(failure.errorMessage()).append('\n'); + } + } + return builder.toString().trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/PromptTemplateRenderer.java b/src/main/java/com/openmanus/agentteam/application/PromptTemplateRenderer.java new file mode 100644 index 0000000..23e75e5 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/PromptTemplateRenderer.java @@ -0,0 +1,25 @@ +package com.openmanus.agentteam.application; + +import java.util.Map; + +final class PromptTemplateRenderer { + + private PromptTemplateRenderer() { + } + + static String render(String template, Map variables) { + String rendered = template == null ? "" : template; + if (variables == null || variables.isEmpty()) { + return rendered; + } + for (Map.Entry entry : variables.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isBlank()) { + continue; + } + String value = entry.getValue() == null ? "" : entry.getValue(); + rendered = rendered.replace("{{" + key + "}}", value); + } + return rendered; + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java new file mode 100644 index 0000000..7083bdc --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java @@ -0,0 +1,72 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.domain.service.AgentExecutionPort; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * Executes one subtask by reusing the existing single-agent execution path. + */ +@Slf4j +public class SubAgentExecutionService { + + private final AgentExecutionPort agentExecutionPort; + private final AgentTeamPromptProvider promptProvider; + + public SubAgentExecutionService( + AgentExecutionPort agentExecutionPort, + AgentTeamPromptProvider promptProvider + ) { + this.agentExecutionPort = agentExecutionPort; + this.promptProvider = promptProvider; + } + + public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { + String prompt = buildSubTaskPrompt(subTask, agentId); + log.info( + "SubAgentExecution dispatching to single-agent runtime: agentId={}, groupId={}, taskId={}, title={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle() + ); + String result = agentExecutionPort.executeSync(prompt, subTask.getTaskId()); + String summary = summarize(result); + log.info( + "SubAgentExecution finished runtime call: agentId={}, groupId={}, taskId={}, title={}, summary={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle(), + summary + ); + return new SubTaskExecutionOutput(summary, result); + } + + private String buildSubTaskPrompt(SubTask subTask, String agentId) { + return PromptTemplateRenderer.render( + promptProvider.subAgentExecutionPromptTemplate(), + Map.of( + "agentId", safe(agentId), + "taskId", safe(subTask.getTaskId()), + "groupId", safe(subTask.getGroupId()), + "taskTitle", safe(subTask.getTitle()), + "taskDescription", safe(subTask.getDescription()) + ) + ); + } + + private String summarize(String result) { + if (result == null || result.isBlank()) { + return "Subtask finished but returned no usable content"; + } + String compact = result.trim(); + return compact.length() > 80 ? compact.substring(0, 80) : compact; + } + + private String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/SubTaskExecutionOutput.java b/src/main/java/com/openmanus/agentteam/application/SubTaskExecutionOutput.java new file mode 100644 index 0000000..2f4511e --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/SubTaskExecutionOutput.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.application; + +/** + * Output returned after one subtask is executed by a subagent. + */ +public record SubTaskExecutionOutput(String summary, String detail) { + + public SubTaskExecutionOutput { + summary = summary == null ? "" : summary.trim(); + detail = detail == null ? "" : detail.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java b/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java new file mode 100644 index 0000000..e06e5da --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java @@ -0,0 +1,333 @@ +package com.openmanus.agentteam.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.openmanus.agentteam.domain.model.DecompositionPlan; +import com.openmanus.agentteam.domain.model.SubTaskPlan; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.model.AiChatMessage; +import com.openmanus.aiframework.runtime.model.AiChatResponse; +import com.openmanus.aiframework.runtime.model.AiChatRequest; +import com.openmanus.aiframework.runtime.model.AiToolCall; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * First-version decomposition service. + * + *

V1 now prefers LLM-based structured decomposition and keeps a conservative + * rule-based fallback for robustness.

+ */ +public class TaskDecompositionService { + + private static final Logger log = LoggerFactory.getLogger(TaskDecompositionService.class); + private static final String STRUCTURED_OUTPUT_TOOL_NAME = "structured_output"; + private static final double DECOMPOSITION_TEMPERATURE = 0.1D; + private static final int DECOMPOSITION_MAX_OUTPUT_TOKENS = 1200; + private static final int DECOMPOSITION_TIMEOUT_SECONDS = 30; + + private static final Pattern BULLET_PATTERN = Pattern.compile( + "^(?:[-*]|\\d+[.)]|[a-zA-Z][.)]|[一二三四五六七八九十]+[、.])\\s*(.+)$" + ); + + private static final List DEPENDENCY_HINTS = List.of( + "然后", "之后", "完成后", "基于前", "依赖", "先", "after", "then", "based on", "depends on" + ); + + private final AiChatModel aiChatModel; + private final ObjectMapper objectMapper; + private final AgentTeamPromptProvider promptProvider; + + public TaskDecompositionService( + AiChatModel aiChatModel, + ObjectMapper objectMapper, + AgentTeamPromptProvider promptProvider + ) { + this.aiChatModel = aiChatModel; + this.objectMapper = objectMapper; + this.promptProvider = promptProvider; + } + + public DecompositionPlan decompose(String userInput, int maxSubTasks) { + if (userInput == null || userInput.isBlank()) { + return new DecompositionPlan(false, "Task is empty and cannot be decomposed", List.of()); + } + + DecompositionPlan llmPlan = tryLlmDecompose(userInput, maxSubTasks); + if (llmPlan != null) { + return llmPlan; + } + + return ruleBasedDecompose(userInput, maxSubTasks); + } + + private DecompositionPlan tryLlmDecompose(String userInput, int maxSubTasks) { + try { + String prompt = PromptTemplateRenderer.render( + promptProvider.taskDecompositionPromptTemplate(), + Map.of( + "userInput", userInput, + "maxSubTasks", String.valueOf(Math.max(2, maxSubTasks)) + ) + ); + AiChatRequest request = new AiChatRequest( + "", + List.of(AiChatMessage.user(prompt)), + List.of(), + DECOMPOSITION_TEMPERATURE, + DECOMPOSITION_MAX_OUTPUT_TOKENS, + DECOMPOSITION_TIMEOUT_SECONDS, + buildResponseFormat(maxSubTasks) + ); + AiChatResponse response = aiChatModel.chat(request); + String payload = extractStructuredPayload(response); + if (payload == null || payload.isBlank()) { + log.warn("LLM decomposition returned empty payload, falling back to rule-based decomposition"); + return null; + } + + JsonNode root = objectMapper.readTree(payload); + return validateLlmPlan(toPlan(root, maxSubTasks), maxSubTasks); + } catch (Exception exception) { + log.warn("LLM decomposition failed, falling back to rule-based decomposition: {}", exception.getMessage()); + return null; + } + } + + private DecompositionPlan validateLlmPlan(DecompositionPlan plan, int maxSubTasks) { + List candidates = normalizeSubTasks(plan.subTasks(), maxSubTasks); + String reason = normalizeReason(plan.reason(), plan.parallelizable() + ? "LLM identified independent parallel subtasks" + : "LLM judged the request unsafe to parallelize"); + + if (!plan.parallelizable()) { + return new DecompositionPlan(false, reason, candidates); + } + if (candidates.size() < 2) { + return new DecompositionPlan(false, "LLM returned fewer than two valid subtasks", candidates); + } + if (containsDependencyHints(candidates)) { + return new DecompositionPlan(false, "LLM subtasks contain obvious dependency hints", candidates); + } + return new DecompositionPlan(true, reason, candidates); + } + + private DecompositionPlan toPlan(JsonNode root, int maxSubTasks) { + if (root == null || !root.isObject()) { + return new DecompositionPlan(false, "LLM output is not a JSON object", List.of()); + } + boolean parallelizable = root.path("parallelizable").asBoolean(false); + String reason = normalizeReason(root.path("reason").asText(null), ""); + List subTasks = parseSubTasks(root.path("subTasks"), maxSubTasks); + return new DecompositionPlan(parallelizable, reason, subTasks); + } + + private List parseSubTasks(JsonNode node, int maxSubTasks) { + if (!(node instanceof ArrayNode arrayNode)) { + return List.of(); + } + List results = new ArrayList<>(); + int limit = Math.max(2, maxSubTasks); + for (JsonNode item : arrayNode) { + if (!item.isObject()) { + continue; + } + String description = normalizeReason(item.path("description").asText(null), ""); + if (description.isBlank()) { + continue; + } + String title = normalizeReason(item.path("title").asText(null), ""); + if (title.isBlank()) { + title = buildTitle(results.size() + 1, description); + } + results.add(new SubTaskPlan(title, description)); + if (results.size() >= limit) { + break; + } + } + return results; + } + + private String extractStructuredPayload(AiChatResponse response) { + if (response == null || response.message() == null) { + return null; + } + for (AiToolCall toolCall : response.message().toolCalls()) { + if (toolCall == null || toolCall.name() == null || toolCall.name().isBlank()) { + continue; + } + if (STRUCTURED_OUTPUT_TOOL_NAME.equals(toolCall.name()) || response.message().content().isBlank()) { + return toolCall.arguments(); + } + } + return unwrapCodeFence(response.message().content()); + } + + private String unwrapCodeFence(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (!trimmed.startsWith("```")) { + return trimmed; + } + int firstLineBreak = trimmed.indexOf('\n'); + if (firstLineBreak < 0) { + return trimmed; + } + int lastFence = trimmed.lastIndexOf("```"); + if (lastFence <= firstLineBreak) { + return trimmed.substring(firstLineBreak + 1).trim(); + } + return trimmed.substring(firstLineBreak + 1, lastFence).trim(); + } + + private JsonNode buildResponseFormat(int maxSubTasks) { + ObjectNode responseFormat = objectMapper.createObjectNode(); + responseFormat.put("type", "json_schema"); + + ObjectNode jsonSchema = responseFormat.putObject("jsonSchema"); + jsonSchema.put("name", "task_decomposition_plan"); + + ObjectNode schema = jsonSchema.putObject("schema"); + schema.put("type", "object"); + schema.put("additionalProperties", false); + + ObjectNode properties = schema.putObject("properties"); + properties.putObject("parallelizable").put("type", "boolean"); + properties.putObject("reason").put("type", "string"); + + ObjectNode subTasks = properties.putObject("subTasks"); + subTasks.put("type", "array"); + subTasks.put("maxItems", Math.max(2, maxSubTasks)); + + ObjectNode itemSchema = subTasks.putObject("items"); + itemSchema.put("type", "object"); + itemSchema.put("additionalProperties", false); + + ObjectNode itemProperties = itemSchema.putObject("properties"); + itemProperties.putObject("title").put("type", "string"); + itemProperties.putObject("description").put("type", "string"); + + ArrayNode itemRequired = itemSchema.putArray("required"); + itemRequired.add("title"); + itemRequired.add("description"); + + ArrayNode rootRequired = schema.putArray("required"); + rootRequired.add("parallelizable"); + rootRequired.add("reason"); + rootRequired.add("subTasks"); + return responseFormat; + } + + private DecompositionPlan ruleBasedDecompose(String userInput, int maxSubTasks) { + List candidates = extractCandidates(userInput, maxSubTasks); + if (candidates.size() < 2) { + return new DecompositionPlan(false, "Rule fallback found fewer than two parallel subtasks", candidates); + } + if (containsDependencyHints(candidates)) { + return new DecompositionPlan(false, "Rule fallback detected obvious dependency between subtasks", candidates); + } + return new DecompositionPlan(true, "Rule fallback recognized explicit independent subtasks", candidates); + } + + private List extractCandidates(String userInput, int maxSubTasks) { + String[] lines = userInput.split("\\R"); + Set normalizedDescriptions = new LinkedHashSet<>(); + List results = new ArrayList<>(); + int limit = Math.max(2, maxSubTasks); + + for (String line : lines) { + String candidate = extractBulletContent(line); + if (candidate == null || candidate.isBlank()) { + continue; + } + String normalized = candidate.trim(); + if (!normalizedDescriptions.add(normalized)) { + continue; + } + results.add(new SubTaskPlan(buildTitle(results.size() + 1, normalized), normalized)); + if (results.size() >= limit) { + break; + } + } + return results; + } + + private List normalizeSubTasks(List subTasks, int maxSubTasks) { + if (subTasks == null || subTasks.isEmpty()) { + return List.of(); + } + Set normalizedDescriptions = new LinkedHashSet<>(); + List results = new ArrayList<>(); + int limit = Math.max(2, maxSubTasks); + + for (SubTaskPlan subTask : subTasks) { + if (subTask == null) { + continue; + } + String description = normalizeReason(subTask.description(), ""); + if (description.isBlank()) { + continue; + } + if (!normalizedDescriptions.add(description)) { + continue; + } + String title = normalizeReason(subTask.title(), ""); + if (title.isBlank()) { + title = buildTitle(results.size() + 1, description); + } + results.add(new SubTaskPlan(title, description)); + if (results.size() >= limit) { + break; + } + } + return results; + } + + private String extractBulletContent(String rawLine) { + if (rawLine == null) { + return null; + } + String line = rawLine.trim(); + Matcher matcher = BULLET_PATTERN.matcher(line); + if (!matcher.matches()) { + return null; + } + return matcher.group(1); + } + + private boolean containsDependencyHints(List subTasks) { + for (SubTaskPlan subTask : subTasks) { + String lower = subTask.description().toLowerCase(); + for (String hint : DEPENDENCY_HINTS) { + if (lower.contains(hint.toLowerCase())) { + return true; + } + } + } + return false; + } + + private String buildTitle(int index, String description) { + String compact = description.length() > 24 ? description.substring(0, 24) : description; + return "SubTask-" + index + ": " + compact; + } + + private String normalizeReason(String reason, String fallback) { + if (reason == null || reason.isBlank()) { + return fallback; + } + return reason.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/AgentMessage.java b/src/main/java/com/openmanus/agentteam/domain/model/AgentMessage.java new file mode 100644 index 0000000..6ad61ef --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/AgentMessage.java @@ -0,0 +1,36 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Mailbox message exchanged between agents. + */ +public record AgentMessage( + String messageId, + String fromAgentId, + String toAgentId, + String groupId, + MessageType type, + String content, + boolean read, + long createdAt, + Long readAt +) { + + public AgentMessage { + content = content == null ? "" : content; + type = type == null ? MessageType.INFO : type; + } + + public AgentMessage markRead(long timestamp) { + return new AgentMessage( + messageId, + fromAgentId, + toAgentId, + groupId, + type, + content, + true, + createdAt, + timestamp + ); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/DecompositionPlan.java b/src/main/java/com/openmanus/agentteam/domain/model/DecompositionPlan.java new file mode 100644 index 0000000..eee9edc --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/DecompositionPlan.java @@ -0,0 +1,18 @@ +package com.openmanus.agentteam.domain.model; + +import java.util.List; + +/** + * Result of asking the system whether a request can be decomposed safely. + */ +public record DecompositionPlan( + boolean parallelizable, + String reason, + List subTasks +) { + + public DecompositionPlan { + reason = reason == null ? "" : reason.trim(); + subTasks = subTasks == null ? List.of() : List.copyOf(subTasks); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/MessageType.java b/src/main/java/com/openmanus/agentteam/domain/model/MessageType.java new file mode 100644 index 0000000..d7894e2 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/MessageType.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Minimal message categories for agent-team mailbox communication. + */ +public enum MessageType { + INFO, + QUESTION, + ANSWER, + BLOCKER, + RESULT_HINT +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java new file mode 100644 index 0000000..4d6362e --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java @@ -0,0 +1,58 @@ +package com.openmanus.agentteam.domain.model; + +import lombok.Getter; + +/** + * Runtime subtask tracked by the agent-team scheduler. + */ +@Getter +public class SubTask { + + private final String taskId; + private final String groupId; + private final String title; + private final String description; + private final long createdAt; + private TaskStatus status; + private String assignedAgentId; + private String resultSummary; + private String resultDetail; + private String errorMessage; + private long claimedAt; + private long startedAt; + private long finishedAt; + + public SubTask(String taskId, String groupId, String title, String description, long createdAt) { + this.taskId = taskId; + this.groupId = groupId; + this.title = title == null ? "" : title.trim(); + this.description = description == null ? "" : description.trim(); + this.createdAt = createdAt; + this.status = TaskStatus.PENDING; + } + + public void claim(String agentId, long timestamp) { + this.assignedAgentId = agentId; + this.claimedAt = timestamp; + this.status = TaskStatus.CLAIMED; + } + + public void markRunning(long timestamp) { + this.startedAt = timestamp; + this.status = TaskStatus.RUNNING; + } + + public void markSucceeded(String summary, String detail, long timestamp) { + this.resultSummary = summary; + this.resultDetail = detail; + this.errorMessage = null; + this.finishedAt = timestamp; + this.status = TaskStatus.SUCCEEDED; + } + + public void markFailed(String error, long timestamp) { + this.errorMessage = error; + this.finishedAt = timestamp; + this.status = TaskStatus.FAILED; + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTaskFailure.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskFailure.java new file mode 100644 index 0000000..e64ba08 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskFailure.java @@ -0,0 +1,11 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Failed subtask result prepared for final aggregation. + */ +public record SubTaskFailure( + String taskId, + String title, + String errorMessage +) { +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java new file mode 100644 index 0000000..83a6e47 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Candidate subtask emitted by decomposition. + */ +public record SubTaskPlan(String title, String description) { + + public SubTaskPlan { + title = title == null ? "" : title.trim(); + description = description == null ? "" : description.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTaskResult.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskResult.java new file mode 100644 index 0000000..d5dfbc3 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskResult.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Successful subtask result prepared for final aggregation. + */ +public record SubTaskResult( + String taskId, + String title, + String summary, + String detail +) { +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/TaskGroup.java b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroup.java new file mode 100644 index 0000000..be34e34 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroup.java @@ -0,0 +1,56 @@ +package com.openmanus.agentteam.domain.model; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Group of parallel subtasks emitted from one user request. + */ +@Getter +public class TaskGroup { + + private final String groupId; + private final String parentTaskId; + private final String masterAgentId; + private final String originalUserRequest; + private final long createdAt; + private final List subTaskIds = new ArrayList<>(); + private TaskGroupStatus status; + private long updatedAt; + + public TaskGroup( + String groupId, + String parentTaskId, + String masterAgentId, + String originalUserRequest, + long createdAt + ) { + this.groupId = groupId; + this.parentTaskId = parentTaskId; + this.masterAgentId = masterAgentId; + this.originalUserRequest = originalUserRequest == null ? "" : originalUserRequest.trim(); + this.createdAt = createdAt; + this.updatedAt = createdAt; + this.status = TaskGroupStatus.CREATED; + } + + public List getSubTaskIds() { + return Collections.unmodifiableList(subTaskIds); + } + + public void addSubTask(String taskId) { + if (taskId == null || taskId.isBlank()) { + return; + } + this.subTaskIds.add(taskId); + this.updatedAt = System.currentTimeMillis(); + } + + public void updateStatus(TaskGroupStatus newStatus, long timestamp) { + this.status = newStatus == null ? this.status : newStatus; + this.updatedAt = timestamp; + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupResult.java b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupResult.java new file mode 100644 index 0000000..0f8b669 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupResult.java @@ -0,0 +1,19 @@ +package com.openmanus.agentteam.domain.model; + +import java.util.List; + +/** + * Aggregated task-group output returned by application orchestration. + */ +public record TaskGroupResult( + String groupId, + List successResults, + List failures, + boolean allSucceeded +) { + + public TaskGroupResult { + successResults = successResults == null ? List.of() : List.copyOf(successResults); + failures = failures == null ? List.of() : List.copyOf(failures); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupSnapshot.java b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupSnapshot.java new file mode 100644 index 0000000..cfad748 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupSnapshot.java @@ -0,0 +1,20 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Aggregated view of one task group at a point in time. + */ +public record TaskGroupSnapshot( + String groupId, + int totalTasks, + int pendingTasks, + int claimedTasks, + int runningTasks, + int succeededTasks, + int failedTasks, + TaskGroupStatus status +) { + + public boolean allFinished() { + return totalTasks > 0 && succeededTasks + failedTasks == totalTasks; + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupStatus.java b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupStatus.java new file mode 100644 index 0000000..9a81a66 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/TaskGroupStatus.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Lifecycle for one decomposed task group. + */ +public enum TaskGroupStatus { + CREATED, + RUNNING, + COMPLETED, + FAILED, + PARTIAL_FAILED +} diff --git a/src/main/java/com/openmanus/agentteam/domain/model/TaskStatus.java b/src/main/java/com/openmanus/agentteam/domain/model/TaskStatus.java new file mode 100644 index 0000000..83a1f98 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/model/TaskStatus.java @@ -0,0 +1,16 @@ +package com.openmanus.agentteam.domain.model; + +/** + * Lifecycle for a single subtask inside an agent-team execution. + */ +public enum TaskStatus { + PENDING, + CLAIMED, + RUNNING, + SUCCEEDED, + FAILED; + + public boolean isTerminal() { + return this == SUCCEEDED || this == FAILED; + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/port/AgentMessageBusPort.java b/src/main/java/com/openmanus/agentteam/domain/port/AgentMessageBusPort.java new file mode 100644 index 0000000..7e5a4fe --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/port/AgentMessageBusPort.java @@ -0,0 +1,14 @@ +package com.openmanus.agentteam.domain.port; + +import com.openmanus.agentteam.domain.model.AgentMessage; + +import java.util.List; + +public interface AgentMessageBusPort { + + void send(AgentMessage message); + + List fetchUnread(String agentId); + + void markAsRead(String agentId, List messageIds); +} diff --git a/src/main/java/com/openmanus/agentteam/domain/port/TaskGroupRepositoryPort.java b/src/main/java/com/openmanus/agentteam/domain/port/TaskGroupRepositoryPort.java new file mode 100644 index 0000000..f471257 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/port/TaskGroupRepositoryPort.java @@ -0,0 +1,20 @@ +package com.openmanus.agentteam.domain.port; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; + +import java.util.List; +import java.util.Optional; + +public interface TaskGroupRepositoryPort { + + void saveGroup(TaskGroup group); + + Optional findGroup(String groupId); + + void saveSubTask(SubTask subTask); + + Optional findSubTask(String taskId); + + List findSubTasksByGroupId(String groupId); +} diff --git a/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java b/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java new file mode 100644 index 0000000..297e1bf --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java @@ -0,0 +1,23 @@ +package com.openmanus.agentteam.domain.port; + +import com.openmanus.agentteam.domain.model.SubTask; + +import java.util.List; +import java.util.Optional; + +public interface TaskPoolPort { + + void submit(SubTask subTask); + + Optional claimNext(String agentId); + + void markRunning(String taskId, String agentId); + + void markSucceeded(String taskId, String summary, String detail); + + void markFailed(String taskId, String errorMessage); + + Optional findById(String taskId); + + List findByGroupId(String groupId); +} diff --git a/src/main/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationService.java b/src/main/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationService.java new file mode 100644 index 0000000..5f398df --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationService.java @@ -0,0 +1,52 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.SubTaskFailure; +import com.openmanus.agentteam.domain.model.SubTaskResult; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupResult; +import com.openmanus.agentteam.domain.model.TaskStatus; + +import java.util.ArrayList; +import java.util.List; + +/** + * Default aggregation logic for one task group. + */ +public class DefaultResultAggregationService implements ResultAggregationService { + + @Override + public TaskGroupResult aggregate(TaskGroup taskGroup, List subTasks) { + List successResults = new ArrayList<>(); + List failures = new ArrayList<>(); + + if (subTasks != null) { + for (SubTask subTask : subTasks) { + if (subTask == null || subTask.getStatus() == null) { + continue; + } + if (subTask.getStatus() == TaskStatus.SUCCEEDED) { + successResults.add(new SubTaskResult( + subTask.getTaskId(), + subTask.getTitle(), + subTask.getResultSummary(), + subTask.getResultDetail() + )); + } else if (subTask.getStatus() == TaskStatus.FAILED) { + failures.add(new SubTaskFailure( + subTask.getTaskId(), + subTask.getTitle(), + subTask.getErrorMessage() + )); + } + } + } + + return new TaskGroupResult( + taskGroup.getGroupId(), + successResults, + failures, + failures.isEmpty() + ); + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManager.java b/src/main/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManager.java new file mode 100644 index 0000000..472c7b1 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManager.java @@ -0,0 +1,70 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; +import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; + +import java.util.List; +import java.util.UUID; + +/** + * Default domain implementation for task-group lifecycle management. + */ +public class DefaultTaskGroupManager implements TaskGroupManager { + + private final TaskGroupRepositoryPort repository; + private final TaskGroupStatusCalculator statusCalculator; + + public DefaultTaskGroupManager( + TaskGroupRepositoryPort repository, + TaskGroupStatusCalculator statusCalculator + ) { + this.repository = repository; + this.statusCalculator = statusCalculator; + } + + @Override + public TaskGroup createGroup(String parentTaskId, String masterAgentId, String originalUserRequest) { + long now = System.currentTimeMillis(); + TaskGroup group = new TaskGroup( + UUID.randomUUID().toString(), + parentTaskId, + masterAgentId, + originalUserRequest, + now + ); + repository.saveGroup(group); + return group; + } + + @Override + public void registerSubTasks(String groupId, List subTasks) { + TaskGroup group = repository.findGroup(groupId) + .orElseThrow(() -> new IllegalArgumentException("Task group not found: " + groupId)); + if (subTasks == null || subTasks.isEmpty()) { + repository.saveGroup(group); + return; + } + for (SubTask subTask : subTasks) { + if (subTask == null) { + continue; + } + group.addSubTask(subTask.getTaskId()); + repository.saveSubTask(subTask); + } + group.updateStatus(statusCalculator.calculate(groupId, subTasks).status(), System.currentTimeMillis()); + repository.saveGroup(group); + } + + @Override + public TaskGroupSnapshot getSnapshot(String groupId) { + List subTasks = repository.findSubTasksByGroupId(groupId); + TaskGroupSnapshot snapshot = statusCalculator.calculate(groupId, subTasks); + repository.findGroup(groupId).ifPresent(group -> { + group.updateStatus(snapshot.status(), System.currentTimeMillis()); + repository.saveGroup(group); + }); + return snapshot; + } +} diff --git a/src/main/java/com/openmanus/agentteam/domain/service/ResultAggregationService.java b/src/main/java/com/openmanus/agentteam/domain/service/ResultAggregationService.java new file mode 100644 index 0000000..ae8a19d --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/service/ResultAggregationService.java @@ -0,0 +1,12 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupResult; + +import java.util.List; + +public interface ResultAggregationService { + + TaskGroupResult aggregate(TaskGroup taskGroup, List subTasks); +} diff --git a/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupManager.java b/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupManager.java new file mode 100644 index 0000000..a4b835c --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupManager.java @@ -0,0 +1,16 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; + +import java.util.List; + +public interface TaskGroupManager { + + TaskGroup createGroup(String parentTaskId, String masterAgentId, String originalUserRequest); + + void registerSubTasks(String groupId, List subTasks); + + TaskGroupSnapshot getSnapshot(String groupId); +} diff --git a/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculator.java b/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculator.java new file mode 100644 index 0000000..50d4e19 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculator.java @@ -0,0 +1,69 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; +import com.openmanus.agentteam.domain.model.TaskGroupStatus; +import com.openmanus.agentteam.domain.model.TaskStatus; + +import java.util.List; + +/** + * Computes task-group progress from current subtask states. + */ +public class TaskGroupStatusCalculator { + + public TaskGroupSnapshot calculate(String groupId, List subTasks) { + List tasks = subTasks == null ? List.of() : subTasks; + int total = tasks.size(); + int pending = 0; + int claimed = 0; + int running = 0; + int succeeded = 0; + int failed = 0; + + for (SubTask task : tasks) { + if (task == null || task.getStatus() == null) { + continue; + } + TaskStatus status = task.getStatus(); + switch (status) { + case PENDING -> pending++; + case CLAIMED -> claimed++; + case RUNNING -> running++; + case SUCCEEDED -> succeeded++; + case FAILED -> failed++; + default -> { + } + } + } + + TaskGroupStatus groupStatus = resolveStatus(total, pending, claimed, running, succeeded, failed); + return new TaskGroupSnapshot(groupId, total, pending, claimed, running, succeeded, failed, groupStatus); + } + + private TaskGroupStatus resolveStatus( + int total, + int pending, + int claimed, + int running, + int succeeded, + int failed + ) { + if (total == 0 || pending == total) { + return TaskGroupStatus.CREATED; + } + if (succeeded == total) { + return TaskGroupStatus.COMPLETED; + } + if (failed == total) { + return TaskGroupStatus.FAILED; + } + if (succeeded + failed == total && failed > 0) { + return TaskGroupStatus.PARTIAL_FAILED; + } + if (claimed > 0 || running > 0 || succeeded > 0 || failed > 0) { + return TaskGroupStatus.RUNNING; + } + return TaskGroupStatus.CREATED; + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java b/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java new file mode 100644 index 0000000..c37a13a --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java @@ -0,0 +1,46 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.application.AgentTeamPromptProvider; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Loads agentteam prompt templates from classpath resources. + */ +public class ClasspathAgentTeamPromptProvider implements AgentTeamPromptProvider { + + private static final String TASK_DECOMPOSITION_TEMPLATE = "prompts/agentteam/task-decomposition.prompt.md"; + private static final String SUB_AGENT_EXECUTION_TEMPLATE = "prompts/agentteam/subagent-execution.prompt.md"; + + private final Map cache = new ConcurrentHashMap<>(); + + @Override + public String taskDecompositionPromptTemplate() { + return load(TASK_DECOMPOSITION_TEMPLATE); + } + + @Override + public String subAgentExecutionPromptTemplate() { + return load(SUB_AGENT_EXECUTION_TEMPLATE); + } + + private String load(String path) { + return cache.computeIfAbsent(path, this::readClasspathResource); + } + + private String readClasspathResource(String path) { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + try (InputStream stream = classLoader.getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("Prompt template not found: " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to load prompt template: " + path, exception); + } + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBus.java b/src/main/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBus.java new file mode 100644 index 0000000..7d8d706 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBus.java @@ -0,0 +1,64 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.domain.model.AgentMessage; +import com.openmanus.agentteam.domain.port.AgentMessageBusPort; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory mailbox implementation for agent-to-agent communication. + */ +public class InMemoryAgentMessageBus implements AgentMessageBusPort { + + private final ConcurrentHashMap> mailboxes = new ConcurrentHashMap<>(); + + @Override + public void send(AgentMessage message) { + if (message == null || message.toAgentId() == null || message.toAgentId().isBlank()) { + return; + } + mailboxes.computeIfAbsent(message.toAgentId(), ignored -> new ArrayList<>()); + List mailbox = mailboxes.get(message.toAgentId()); + synchronized (mailbox) { + mailbox.add(message); + } + } + + @Override + public List fetchUnread(String agentId) { + List mailbox = mailboxes.get(agentId); + if (mailbox == null) { + return List.of(); + } + synchronized (mailbox) { + List unread = new ArrayList<>(); + for (AgentMessage message : mailbox) { + if (!message.read()) { + unread.add(message); + } + } + return unread; + } + } + + @Override + public void markAsRead(String agentId, List messageIds) { + List mailbox = mailboxes.get(agentId); + if (mailbox == null || messageIds == null || messageIds.isEmpty()) { + return; + } + Set readIds = new HashSet<>(messageIds); + synchronized (mailbox) { + for (int i = 0; i < mailbox.size(); i++) { + AgentMessage message = mailbox.get(i); + if (message != null && message.messageId() != null && readIds.contains(message.messageId())) { + mailbox.set(i, message.markRead(System.currentTimeMillis())); + } + } + } + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskGroupRepository.java b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskGroupRepository.java new file mode 100644 index 0000000..16689e2 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskGroupRepository.java @@ -0,0 +1,62 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory repository for task-group runtime state. + */ +public class InMemoryTaskGroupRepository implements TaskGroupRepositoryPort { + + private final ConcurrentHashMap groups = new ConcurrentHashMap<>(); + private final ConcurrentHashMap subTasks = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> groupToTaskIds = new ConcurrentHashMap<>(); + + @Override + public void saveGroup(TaskGroup group) { + if (group == null) { + return; + } + groups.put(group.getGroupId(), group); + } + + @Override + public Optional findGroup(String groupId) { + return Optional.ofNullable(groups.get(groupId)); + } + + @Override + public void saveSubTask(SubTask subTask) { + if (subTask == null) { + return; + } + subTasks.put(subTask.getTaskId(), subTask); + groupToTaskIds.computeIfAbsent(subTask.getGroupId(), ignored -> ConcurrentHashMap.newKeySet()) + .add(subTask.getTaskId()); + } + + @Override + public Optional findSubTask(String taskId) { + return Optional.ofNullable(subTasks.get(taskId)); + } + + @Override + public List findSubTasksByGroupId(String groupId) { + Set taskIds = groupToTaskIds.getOrDefault(groupId, Set.of()); + List results = new ArrayList<>(taskIds.size()); + for (String taskId : taskIds) { + SubTask subTask = subTasks.get(taskId); + if (subTask != null) { + results.add(subTask); + } + } + return results; + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java new file mode 100644 index 0000000..b1a484b --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java @@ -0,0 +1,144 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskStatus; +import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; +import com.openmanus.agentteam.domain.port.TaskPoolPort; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.locks.ReentrantLock; + +/** + * In-memory task pool with a locked claim section to prevent duplicate claim. + */ +@Slf4j +public class InMemoryTaskPool implements TaskPoolPort { + + private final ConcurrentLinkedQueue pendingQueue = new ConcurrentLinkedQueue<>(); + private final TaskGroupRepositoryPort repository; + private final ReentrantLock claimLock = new ReentrantLock(); + + public InMemoryTaskPool(TaskGroupRepositoryPort repository) { + this.repository = repository; + } + + @Override + public void submit(SubTask subTask) { + if (subTask == null) { + return; + } + repository.saveSubTask(subTask); + pendingQueue.offer(subTask.getTaskId()); + log.info( + "TaskPool submit: groupId={}, taskId={}, title={}, pendingQueueSize={}", + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle(), + pendingQueue.size() + ); + } + + @Override + public Optional claimNext(String agentId) { + claimLock.lock(); + try { + while (!pendingQueue.isEmpty()) { + String taskId = pendingQueue.poll(); + if (taskId == null) { + break; + } + Optional maybeTask = repository.findSubTask(taskId); + if (maybeTask.isEmpty()) { + continue; + } + SubTask subTask = maybeTask.get(); + if (subTask.getStatus() != TaskStatus.PENDING) { + continue; + } + subTask.claim(agentId, System.currentTimeMillis()); + repository.saveSubTask(subTask); + log.info( + "TaskPool claim success: agentId={}, groupId={}, taskId={}, title={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle() + ); + return Optional.of(subTask); + } + return Optional.empty(); + } finally { + claimLock.unlock(); + } + } + + @Override + public void markRunning(String taskId, String agentId) { + repository.findSubTask(taskId).ifPresent(task -> { + if (task.getAssignedAgentId() == null || task.getAssignedAgentId().equals(agentId)) { + task.markRunning(System.currentTimeMillis()); + repository.saveSubTask(task); + log.info( + "TaskPool mark running: agentId={}, groupId={}, taskId={}, title={}", + agentId, + task.getGroupId(), + task.getTaskId(), + task.getTitle() + ); + } + }); + } + + @Override + public void markSucceeded(String taskId, String summary, String detail) { + repository.findSubTask(taskId).ifPresent(task -> { + task.markSucceeded(summary, detail, System.currentTimeMillis()); + repository.saveSubTask(task); + log.info( + "TaskPool mark succeeded: agentId={}, groupId={}, taskId={}, title={}, summary={}", + task.getAssignedAgentId(), + task.getGroupId(), + task.getTaskId(), + task.getTitle(), + summarize(summary) + ); + }); + } + + @Override + public void markFailed(String taskId, String errorMessage) { + repository.findSubTask(taskId).ifPresent(task -> { + task.markFailed(errorMessage, System.currentTimeMillis()); + repository.saveSubTask(task); + log.warn( + "TaskPool mark failed: agentId={}, groupId={}, taskId={}, title={}, error={}", + task.getAssignedAgentId(), + task.getGroupId(), + task.getTaskId(), + task.getTitle(), + summarize(errorMessage) + ); + }); + } + + @Override + public Optional findById(String taskId) { + return repository.findSubTask(taskId); + } + + @Override + public List findByGroupId(String groupId) { + return repository.findSubTasksByGroupId(groupId); + } + + private String summarize(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String trimmed = value.trim(); + return trimmed.length() > 120 ? trimmed.substring(0, 120) + "..." : trimmed; + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java new file mode 100644 index 0000000..442ba6e --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java @@ -0,0 +1,132 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.application.SubAgentExecutionService; +import com.openmanus.agentteam.application.SubTaskExecutionOutput; +import com.openmanus.agentteam.domain.model.AgentMessage; +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.port.AgentMessageBusPort; +import com.openmanus.agentteam.domain.port.TaskPoolPort; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Polling worker for V1 agent-team execution. + */ +@Slf4j +public class SubAgentWorker implements Runnable { + + private final String agentId; + private final TaskPoolPort taskPoolPort; + private final AgentMessageBusPort messageBusPort; + private final SubAgentExecutionService executionService; + private final long idlePollIntervalMillis; + private final AtomicBoolean running = new AtomicBoolean(true); + + public SubAgentWorker( + String agentId, + TaskPoolPort taskPoolPort, + AgentMessageBusPort messageBusPort, + SubAgentExecutionService executionService, + long idlePollIntervalMillis + ) { + this.agentId = agentId; + this.taskPoolPort = taskPoolPort; + this.messageBusPort = messageBusPort; + this.executionService = executionService; + this.idlePollIntervalMillis = idlePollIntervalMillis; + } + + public String getAgentId() { + return agentId; + } + + public void stop() { + running.set(false); + } + + @Override + public void run() { + log.info("SubAgentWorker started: agentId={}, idlePollIntervalMillis={}", agentId, idlePollIntervalMillis); + while (running.get() && !Thread.currentThread().isInterrupted()) { + drainMailbox(); + SubTask subTask = taskPoolPort.claimNext(agentId).orElse(null); + if (subTask == null) { + sleepQuietly(idlePollIntervalMillis); + continue; + } + executeClaimedTask(subTask); + } + log.info("SubAgentWorker stopped: agentId={}", agentId); + } + + private void drainMailbox() { + List unreadMessages = messageBusPort.fetchUnread(agentId); + if (unreadMessages.isEmpty()) { + return; + } + List messageIds = unreadMessages.stream() + .map(AgentMessage::messageId) + .filter(id -> id != null && !id.isBlank()) + .toList(); + messageBusPort.markAsRead(agentId, messageIds); + } + + private void executeClaimedTask(SubTask subTask) { + try { + log.info( + "SubAgentWorker executing claimed task: agentId={}, groupId={}, taskId={}, title={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle() + ); + taskPoolPort.markRunning(subTask.getTaskId(), agentId); + SubTaskExecutionOutput output = executionService.execute(subTask, agentId); + taskPoolPort.markSucceeded(subTask.getTaskId(), output.summary(), output.detail()); + log.info( + "SubAgentWorker completed task: agentId={}, groupId={}, taskId={}, title={}, summary={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle(), + summarize(output.summary()) + ); + } catch (Exception exception) { + taskPoolPort.markFailed(subTask.getTaskId(), safeMessage(exception)); + log.warn( + "SubAgentWorker failed task: agentId={}, groupId={}, taskId={}, title={}, error={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle(), + safeMessage(exception) + ); + } + } + + private void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + running.set(false); + } + } + + private String safeMessage(Exception exception) { + if (exception == null || exception.getMessage() == null || exception.getMessage().isBlank()) { + return "subagent execution failed"; + } + return exception.getMessage().trim(); + } + + private String summarize(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String trimmed = value.trim(); + return trimmed.length() > 120 ? trimmed.substring(0, 120) + "..." : trimmed; + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/SubAgentWorkerManager.java b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorkerManager.java new file mode 100644 index 0000000..1e37874 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorkerManager.java @@ -0,0 +1,72 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.application.SubAgentExecutionService; +import com.openmanus.agentteam.domain.port.AgentMessageBusPort; +import com.openmanus.agentteam.domain.port.TaskPoolPort; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Starts and owns a fixed set of polling subagent workers. + */ +public class SubAgentWorkerManager implements AutoCloseable { + + private final int workerCount; + private final long idlePollIntervalMillis; + private final TaskPoolPort taskPoolPort; + private final AgentMessageBusPort messageBusPort; + private final SubAgentExecutionService executionService; + private final AtomicBoolean started = new AtomicBoolean(false); + private final List workers = new ArrayList<>(); + private ExecutorService executorService; + + public SubAgentWorkerManager( + int workerCount, + long idlePollIntervalMillis, + TaskPoolPort taskPoolPort, + AgentMessageBusPort messageBusPort, + SubAgentExecutionService executionService + ) { + this.workerCount = workerCount; + this.idlePollIntervalMillis = idlePollIntervalMillis; + this.taskPoolPort = taskPoolPort; + this.messageBusPort = messageBusPort; + this.executionService = executionService; + } + + public void ensureStarted() { + if (!started.compareAndSet(false, true)) { + return; + } + executorService = Executors.newFixedThreadPool(workerCount); + for (int i = 1; i <= workerCount; i++) { + SubAgentWorker worker = new SubAgentWorker( + "subagent-" + i, + taskPoolPort, + messageBusPort, + executionService, + idlePollIntervalMillis + ); + workers.add(worker); + executorService.submit(worker); + } + } + + public List getWorkerIds() { + return workers.stream().map(SubAgentWorker::getAgentId).toList(); + } + + @Override + public void close() { + for (SubAgentWorker worker : workers) { + worker.stop(); + } + if (executorService != null) { + executorService.shutdownNow(); + } + } +} diff --git a/src/main/java/com/openmanus/agentteam/plan.md b/src/main/java/com/openmanus/agentteam/plan.md new file mode 100644 index 0000000..600c8c7 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/plan.md @@ -0,0 +1,236 @@ +# OpenManus-Java AgentTeam 开发计划 + +## 目标 + +第一版将 `agentteam` 实现为一个独立的纵向子模块,采用以下包结构: + +- `com.openmanus.agentteam.application` +- `com.openmanus.agentteam.domain` +- `com.openmanus.agentteam.infra` + +这一版先打通一个最小可运行的多 Agent 协作闭环: + +1. 主 agent 接收一个大任务。 +2. 主 agent 判断该任务是否适合拆成多个可并行子任务。 +3. 子任务进入内存任务池。 +4. sub-agent worker 通过短轮询领取任务。 +5. agent 之间通过内存 mailbox / message bus 通信。 +6. 主 agent 轮询任务组状态并汇总结果。 + +## 范围边界 + +### 第一版要做 + +- [x] 只支持 `fan-out / fan-in` +- [x] 只支持无依赖并行子任务 +- [x] subagent 不做角色区分,能力和主 agent 保持一致 +- [x] 支持内存版任务池 +- [x] 支持内存版 agent 通信 mailbox +- [x] 支持主 agent 轮询任务组完成状态 +- [x] 支持最小结果汇总 + +### 第一版明确不做 + +- [x] 不支持 DAG 依赖调度 +- [x] 不支持角色系统 +- [x] 不支持持久化存储和崩溃恢复 +- [x] 不支持 git/worktree 隔离 +- [x] 不支持同文件并行修改后的 merge 策略 +- [x] 不支持自动重试策略 + +## 分层规划 + +### `com.openmanus.agentteam.domain` + +这一层放稳定的领域模型、状态枚举、领域 port 和核心规则。 + +- [x] 创建任务与消息领域模型 + - `TaskGroup` + - `SubTask` + - `AgentMessage` +- [x] 创建状态枚举 + - `TaskStatus` + - `TaskGroupStatus` + - `MessageType` +- [x] 定义领域 port + - `TaskPoolPort` + - `AgentMessageBusPort` + - `TaskGroupRepositoryPort` +- [x] 定义领域服务接口 + - `TaskGroupManager` + - `ResultAggregationService` +- [x] 定义任务拆分结果模型 + - `DecompositionPlan` + - `SubTaskPlan` + +### `com.openmanus.agentteam.application` + +这一层放用例编排逻辑,负责协调 domain 对象和现有 agent 执行能力。 + +- [x] 实现 `MasterAgentOrchestrator` +- [x] 实现 `TaskDecompositionService` +- [x] 实现 `SubAgentExecutionService` +- [x] 实现 `AgentTeamApplicationService` 或等价 facade +- [x] 在不破坏单 agent 流程的前提下接入现有执行链路 +- [x] 增加回退逻辑:拆分不安全时退回单 agent 执行 + +### `com.openmanus.agentteam.infra` + +这一层放具体运行时实现。 + +- [x] 实现 `InMemoryTaskPool` +- [x] 实现 `InMemoryTaskGroupRepository` +- [x] 实现 `InMemoryAgentMessageBus` +- [x] 实现 `SubAgentWorker` +- [x] 实现 `SubAgentWorkerManager` +- [x] 增加 worker 数量与轮询间隔配置 +- [x] 完成 Spring Bean 装配 + +## 实施顺序 + +### Phase 1:骨架与领域层 + +- [x] 创建 `src/main/java/com/openmanus/agentteam/` 包骨架 +- [x] 增加领域模型与状态枚举 +- [x] 增加领域 port +- [x] 增加任务组状态计算逻辑 + +完成标准: + +- 领域类型能单独编译和测试 +- 暂时不接运行时 wiring + +### Phase 2:Infra MVP + +- [x] 增加内存版任务池 +- [x] 增加内存版任务组仓库 +- [x] 增加内存版 mailbox / message bus +- [x] 验证任务提交、领取、成功、失败流转 + +完成标准: + +- 单进程内可以正确提交和领取任务 +- 并发下不会重复 claim 同一个任务 + +### Phase 3:Worker 运行时 + +- [x] 增加 sub-agent worker 循环 +- [x] 增加短轮询行为 + - 空闲时 sleep + - 有任务时立即 claim +- [x] 在 worker 循环中加入 mailbox 轮询 +- [x] 基于 `ThreadPoolExecutor` 增加 worker manager + +完成标准: + +- 多个 worker 可以并行运行 +- worker 一轮循环里既能收消息,也能领任务 + +### Phase 4:应用编排层 + +- [x] 增加主 agent 编排流程 +- [x] 增加任务拆分结果校验 +- [x] 增加任务组轮询逻辑 +- [x] 增加子任务结果汇总 +- [x] 增加拆分不安全时回退单 agent 的逻辑 + +完成标准: + +- 内存版多 agent 流程端到端跑通 +- 主 agent 能知道一组子任务是否都完成了 + +### Phase 5:接入现有 Agent 层 + +- [x] 将 subtask 执行接到现有 agent 执行能力 +- [x] 保证 subagent 与主 agent 的工具能力一致 +- [x] 默认不破坏当前单 agent 路径 +- [x] 增加开关控制是否启用 `agentteam` + +完成标准: + +- 现有单 agent 流程和 smoke 路径仍可通过 +- 多 agent 路径可以通过开关单独启用 + +### Phase 6:测试与验证 + +- [x] 增加领域状态流转单测 +- [x] 增加任务 claim 并发测试 +- [x] 增加 mailbox send/read/mark-read 测试 +- [x] 增加完整 task-group 流程 smoke test +- [x] 增加部分失败场景测试 + +完成标准: + +- happy path 和 failure path 都有覆盖 +- claim 关键并发路径没有明显竞态 + +## 详细编码清单 + +实现时按下面顺序推进: + +- [x] Step 1: 创建 `agentteam` 包骨架 +- [x] Step 2: 增加领域模型与状态枚举 +- [x] Step 3: 增加领域 port 与服务接口 +- [x] Step 4: 增加内存版 infra 实现 +- [x] Step 5: 增加 worker manager 与轮询循环 +- [x] Step 6: 增加 application orchestrator +- [x] Step 7: 接入现有 agent 执行链路 +- [x] Step 8: 增加配置与 Bean 装配 +- [x] Step 9: 增加测试 +- [x] Step 10: 更新进度文档 + +## 实现约束 + +- [x] 优先复用现有结构,不要过度抽象 +- [x] `agentteam` 逻辑不要散落到 controller +- [x] `aiframework` 不承载 team 协作语义 +- [x] 领域规则和线程池 / Spring wiring 分离 +- [x] 第一版优先使用 JDK 并发原语 +- [x] mailbox 是通信模型,不是持久化方案 + +## 当前进度说明 + +当前已经完成: + +- `agentteam` 包骨架 +- 领域模型、状态枚举、port、基础结果模型 +- `TaskGroupStatusCalculator` +- `DefaultTaskGroupManager` +- `DefaultResultAggregationService` +- `TaskDecompositionService` +- `SubAgentExecutionService` +- `InMemoryTaskPool` +- `InMemoryTaskGroupRepository` +- `InMemoryAgentMessageBus` +- `SubAgentWorker` +- `SubAgentWorkerManager` +- `MasterAgentOrchestrator` +- `AgentTeamApplicationService` +- `AgentTeamConversationApplicationService` +- `AgentTeamProperties` +- `AgentTeamConfig` +- `DomainServiceConfig` 中的接线 +- `AgentController` 中的可选 `agentTeam=true` 入口 + +当前验证情况: + +- 主代码 `compile` 已通过 +- 以下测试已通过: + - `TaskGroupStatusCalculatorTest` + - `DefaultTaskGroupManagerTest` + - `DefaultResultAggregationServiceTest` + - `TaskDecompositionServiceTest` + - `InMemoryTaskPoolTest` + - `InMemoryAgentMessageBusTest` + - `MasterAgentOrchestratorTest` + - `WebSocketExecutionStreamPublisherTest` + - `AgentControllerSessionSandboxStartTest` +- 已覆盖 happy path、claim 并发、mailbox 收发已读、端到端编排成功、端到端部分失败 + +## 勾选规则 + +后续如果继续增强: + +- 完成一项就把 `- [ ]` 改成 `- [x]` +- 如果某项延期或放弃,要在本文档里明确标注 +- 这份文件作为 `agentteam` MVP 的单一跟踪清单 diff --git a/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java b/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java new file mode 100644 index 0000000..55f04b3 --- /dev/null +++ b/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java @@ -0,0 +1,132 @@ +package com.openmanus.infra.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openmanus.agentteam.application.AgentTeamApplicationService; +import com.openmanus.agentteam.application.AgentTeamPromptProvider; +import com.openmanus.agentteam.application.MasterAgentOrchestrator; +import com.openmanus.agentteam.application.SubAgentExecutionService; +import com.openmanus.agentteam.application.TaskDecompositionService; +import com.openmanus.agentteam.domain.port.AgentMessageBusPort; +import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; +import com.openmanus.agentteam.domain.port.TaskPoolPort; +import com.openmanus.agentteam.domain.service.DefaultResultAggregationService; +import com.openmanus.agentteam.domain.service.DefaultTaskGroupManager; +import com.openmanus.agentteam.domain.service.ResultAggregationService; +import com.openmanus.agentteam.domain.service.TaskGroupManager; +import com.openmanus.agentteam.domain.service.TaskGroupStatusCalculator; +import com.openmanus.agentteam.infra.ClasspathAgentTeamPromptProvider; +import com.openmanus.agentteam.infra.InMemoryAgentMessageBus; +import com.openmanus.agentteam.infra.InMemoryTaskGroupRepository; +import com.openmanus.agentteam.infra.InMemoryTaskPool; +import com.openmanus.agentteam.infra.SubAgentWorkerManager; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.domain.service.AgentExecutionPort; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Bean wiring for the agentteam module. + */ +@Configuration +@EnableConfigurationProperties(AgentTeamProperties.class) +public class AgentTeamConfig { + + @Bean + TaskGroupStatusCalculator taskGroupStatusCalculator() { + return new TaskGroupStatusCalculator(); + } + + @Bean + TaskGroupRepositoryPort taskGroupRepositoryPort() { + return new InMemoryTaskGroupRepository(); + } + + @Bean + TaskPoolPort taskPoolPort(TaskGroupRepositoryPort repositoryPort) { + return new InMemoryTaskPool(repositoryPort); + } + + @Bean + AgentMessageBusPort agentMessageBusPort() { + return new InMemoryAgentMessageBus(); + } + + @Bean + TaskGroupManager taskGroupManager( + TaskGroupRepositoryPort repositoryPort, + TaskGroupStatusCalculator statusCalculator + ) { + return new DefaultTaskGroupManager(repositoryPort, statusCalculator); + } + + @Bean + ResultAggregationService resultAggregationService() { + return new DefaultResultAggregationService(); + } + + @Bean + AgentTeamPromptProvider agentTeamPromptProvider() { + return new ClasspathAgentTeamPromptProvider(); + } + + @Bean + TaskDecompositionService taskDecompositionService( + AiChatModel aiChatModel, + ObjectMapper objectMapper, + AgentTeamPromptProvider promptProvider + ) { + return new TaskDecompositionService(aiChatModel, objectMapper, promptProvider); + } + + @Bean + SubAgentExecutionService subAgentExecutionService( + AgentExecutionPort agentExecutionPort, + AgentTeamPromptProvider promptProvider + ) { + return new SubAgentExecutionService(agentExecutionPort, promptProvider); + } + + @Bean(destroyMethod = "close") + SubAgentWorkerManager subAgentWorkerManager( + AgentTeamProperties properties, + TaskPoolPort taskPoolPort, + AgentMessageBusPort messageBusPort, + SubAgentExecutionService executionService + ) { + return new SubAgentWorkerManager( + properties.getWorkerCount(), + properties.getIdlePollIntervalMillis(), + taskPoolPort, + messageBusPort, + executionService + ); + } + + @Bean + MasterAgentOrchestrator masterAgentOrchestrator( + AgentExecutionPort agentExecutionPort, + TaskDecompositionService taskDecompositionService, + TaskGroupManager taskGroupManager, + TaskPoolPort taskPoolPort, + ResultAggregationService resultAggregationService, + SubAgentWorkerManager subAgentWorkerManager, + AgentTeamProperties properties + ) { + return new MasterAgentOrchestrator( + agentExecutionPort, + taskDecompositionService, + taskGroupManager, + taskPoolPort, + resultAggregationService, + subAgentWorkerManager, + properties.getMasterPollIntervalMillis(), + properties.getMaxSubTasksPerGroup() + ); + } + + @Bean + AgentTeamApplicationService agentTeamApplicationService(MasterAgentOrchestrator masterAgentOrchestrator) { + return new AgentTeamApplicationService(masterAgentOrchestrator); + } +} diff --git a/src/main/java/com/openmanus/infra/config/AgentTeamProperties.java b/src/main/java/com/openmanus/infra/config/AgentTeamProperties.java new file mode 100644 index 0000000..980d28b --- /dev/null +++ b/src/main/java/com/openmanus/infra/config/AgentTeamProperties.java @@ -0,0 +1,18 @@ +package com.openmanus.infra.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Runtime configuration for the agentteam module. + */ +@Data +@ConfigurationProperties(prefix = "openmanus.agentteam") +public class AgentTeamProperties { + + private boolean enabled = false; + private int workerCount = 3; + private long idlePollIntervalMillis = 500L; + private long masterPollIntervalMillis = 300L; + private int maxSubTasksPerGroup = 5; +} diff --git a/src/main/java/com/openmanus/infra/config/DomainServiceConfig.java b/src/main/java/com/openmanus/infra/config/DomainServiceConfig.java index 8478c95..8891b38 100644 --- a/src/main/java/com/openmanus/infra/config/DomainServiceConfig.java +++ b/src/main/java/com/openmanus/infra/config/DomainServiceConfig.java @@ -1,5 +1,8 @@ package com.openmanus.infra.config; +import com.openmanus.agentteam.application.AgentTeamApplicationService; +import com.openmanus.agentteam.application.AgentTeamConversationApplicationService; +import com.openmanus.agentteam.application.AgentTeamExecutionStreamingApplicationService; import com.openmanus.domain.service.ConversationApplicationService; import com.openmanus.domain.service.AgentExecutionPort; import com.openmanus.domain.service.ExecutionStreamingApplicationService; @@ -43,6 +46,36 @@ ExecutionStreamingApplicationService executionStreamingApplicationService(AgentE ); } + @Bean + AgentTeamConversationApplicationService agentTeamConversationApplicationService( + AgentTeamApplicationService agentTeamApplicationService, + ExecutionEventPort executionEventPort, + SessionExecutionGuard sessionExecutionGuard, + @Qualifier(AsyncConfig.ASYNC_EXECUTOR_NAME) Executor asyncExecutor) { + return new AgentTeamConversationApplicationService( + agentTeamApplicationService, + executionEventPort, + sessionExecutionGuard, + asyncExecutor + ); + } + + @Bean + AgentTeamExecutionStreamingApplicationService agentTeamExecutionStreamingApplicationService( + AgentTeamApplicationService agentTeamApplicationService, + ExecutionEventPort executionEventPort, + ExecutionStreamPublisher streamPublisher, + @Qualifier(AsyncConfig.ASYNC_EXECUTOR_NAME) Executor asyncExecutor, + SessionExecutionGuard sessionExecutionGuard) { + return new AgentTeamExecutionStreamingApplicationService( + agentTeamApplicationService, + executionEventPort, + streamPublisher, + asyncExecutor, + sessionExecutionGuard + ); + } + @Bean SessionExecutionGuard sessionExecutionGuard() { return new InMemorySessionExecutionGuard(); diff --git a/src/main/java/com/openmanus/infra/web/AgentController.java b/src/main/java/com/openmanus/infra/web/AgentController.java index bd8bd24..3a57bfe 100644 --- a/src/main/java/com/openmanus/infra/web/AgentController.java +++ b/src/main/java/com/openmanus/infra/web/AgentController.java @@ -1,11 +1,14 @@ package com.openmanus.infra.web; +import com.openmanus.agentteam.application.AgentTeamConversationApplicationService; +import com.openmanus.agentteam.application.AgentTeamExecutionStreamingApplicationService; import com.openmanus.domain.model.ExecutionErrorCodes; import com.openmanus.domain.model.ExecutionRequest; import com.openmanus.domain.model.ExecutionResponse; import com.openmanus.domain.service.ConversationApplicationService; import com.openmanus.domain.service.ExecutionStreamingApplicationService; import com.openmanus.domain.service.SessionIdPolicy; +import com.openmanus.infra.config.AgentTeamProperties; import com.openmanus.sandbox.domain.model.SessionSandboxInfo; import com.openmanus.sandbox.application.SandboxSessionApplicationService; import io.swagger.v3.oas.annotations.Operation; @@ -31,15 +34,24 @@ public class AgentController { private static final String ERROR_ASYNC_SUBMIT_FAILED = "任务提交失败,请稍后重试"; private static final String ERROR_ASYNC_SUBMIT_EXCEPTION = "任务提交异常,请稍后重试"; private final ConversationApplicationService conversationApplicationService; + private final AgentTeamConversationApplicationService agentTeamConversationApplicationService; + private final AgentTeamExecutionStreamingApplicationService agentTeamExecutionStreamingApplicationService; private final ExecutionStreamingApplicationService executionStreamingApplicationService; + private final AgentTeamProperties agentTeamProperties; private final SandboxSessionApplicationService sandboxSessionApplicationService; - + public AgentController( ConversationApplicationService conversationApplicationService, + AgentTeamConversationApplicationService agentTeamConversationApplicationService, + AgentTeamExecutionStreamingApplicationService agentTeamExecutionStreamingApplicationService, ExecutionStreamingApplicationService executionStreamingApplicationService, + AgentTeamProperties agentTeamProperties, SandboxSessionApplicationService sandboxSessionApplicationService) { this.conversationApplicationService = conversationApplicationService; + this.agentTeamConversationApplicationService = agentTeamConversationApplicationService; + this.agentTeamExecutionStreamingApplicationService = agentTeamExecutionStreamingApplicationService; this.executionStreamingApplicationService = executionStreamingApplicationService; + this.agentTeamProperties = agentTeamProperties; this.sandboxSessionApplicationService = sandboxSessionApplicationService; } /** @@ -58,6 +70,7 @@ public AgentController( public CompletableFuture>> chat( @RequestBody Map payload, @RequestParam(defaultValue = "false") boolean stateful, + @RequestParam(defaultValue = "false") boolean agentTeam, @RequestParam(defaultValue = "false") boolean sync) { String message = payload.get("message"); @@ -74,10 +87,12 @@ public CompletableFuture>> chat( } try { - return conversationApplicationService.chat(message, conversationId, sync) - .handle((result, throwable) -> throwable == null - ? toChatResponse(result) - : buildChatInternalErrorResponse(conversationId)); + CompletableFuture> execution = shouldUseAgentTeam(agentTeam) + ? agentTeamConversationApplicationService.chat(message, conversationId, sync) + : conversationApplicationService.chat(message, conversationId, sync); + return execution.handle((result, throwable) -> throwable == null + ? toChatResponse(result) + : buildChatInternalErrorResponse(conversationId)); } catch (RuntimeException e) { return CompletableFuture.completedFuture(buildChatInternalErrorResponse(conversationId)); } @@ -95,12 +110,18 @@ public CompletableFuture>> chat( description = "Runs the same agent execution pipeline and returns a session ID for WebSocket streaming." ) public ResponseEntity executionStream( - @RequestBody ExecutionRequest executionRequest) { + @RequestBody ExecutionRequest executionRequest, + @RequestParam(defaultValue = "false") boolean agentTeam) { String userInput = executionRequest.getInput(); - ExecutionResponse serviceResult = executionStreamingApplicationService.executeAndStreamEvents( - userInput, - executionRequest.getSessionId() - ); + ExecutionResponse serviceResult = shouldUseAgentTeam(agentTeam) + ? agentTeamExecutionStreamingApplicationService.executeAndStreamEvents( + userInput, + executionRequest.getSessionId() + ) + : executionStreamingApplicationService.executeAndStreamEvents( + userInput, + executionRequest.getSessionId() + ); if (!serviceResult.isSuccess()) { HttpStatus status = resolveErrorStatus(serviceResult.getErrorCode(), serviceResult.getError()); @@ -224,6 +245,10 @@ private static String normalizeConversationId(String rawConversationId) { return SessionIdPolicy.normalizeOrNull(rawConversationId); } + private boolean shouldUseAgentTeam(boolean agentTeamRequested) { + return agentTeamRequested && agentTeamProperties.isEnabled(); + } + /** * 查询会话信息(包括沙箱状态) * diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml index fe780b0..7f3bda8 100644 --- a/src/main/resources/application-example.yml +++ b/src/main/resources/application-example.yml @@ -23,6 +23,13 @@ openmanus: timeout: 120 providers: {} + agentteam: + enabled: false + worker-count: 3 + idle-poll-interval-millis: 500 + master-poll-interval-millis: 300 + max-sub-tasks-per-group: 5 + sandbox: type: "docker" image: "python:3.11-slim" diff --git a/src/main/resources/prompts/agentteam/subagent-execution.prompt.md b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md new file mode 100644 index 0000000..897e5bf --- /dev/null +++ b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md @@ -0,0 +1,20 @@ +You are a subagent executing one parallel subtask inside an agent team. + +You do not have a special role in V1. Your capabilities and tools are the same as the main agent. + +Context: +- agentId: {{agentId}} +- taskId: {{taskId}} +- groupId: {{groupId}} + +Subtask title: +{{taskTitle}} + +Subtask description: +{{taskDescription}} + +Requirements: +1. Only work on this subtask. +2. Do not assume results from other subtasks. +3. If information is missing, explicitly state the blocker. +4. Start with a short conclusion, then provide the necessary details. diff --git a/src/main/resources/prompts/agentteam/task-decomposition.prompt.md b/src/main/resources/prompts/agentteam/task-decomposition.prompt.md new file mode 100644 index 0000000..17cc056 --- /dev/null +++ b/src/main/resources/prompts/agentteam/task-decomposition.prompt.md @@ -0,0 +1,15 @@ +You are the task decomposition planner for a fan-out / fan-in agent team. + +Your job is to decide whether the user request can be safely decomposed into independent parallel subtasks. + +Rules: +1. Only allow independent subtasks that can run in parallel. +2. If the work has obvious ordering, shared dependency, or "do A then B" structure, set parallelizable to false. +3. Do not produce a DAG or staged workflow. +4. Keep the number of subtasks between 2 and {{maxSubTasks}} when parallelizable is true. +5. Each subtask must be concrete, self-contained, and understandable on its own. +6. Keep titles short and descriptions specific. +7. Return only the final structured output required by the schema. + +User request: +{{userInput}} diff --git a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java new file mode 100644 index 0000000..cc4720f --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java @@ -0,0 +1,232 @@ +package com.openmanus.agentteam.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openmanus.agentteam.domain.service.DefaultResultAggregationService; +import com.openmanus.agentteam.domain.service.DefaultTaskGroupManager; +import com.openmanus.agentteam.domain.service.TaskGroupStatusCalculator; +import com.openmanus.agentteam.infra.InMemoryAgentMessageBus; +import com.openmanus.agentteam.infra.InMemoryTaskGroupRepository; +import com.openmanus.agentteam.infra.InMemoryTaskPool; +import com.openmanus.agentteam.infra.SubAgentWorkerManager; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.model.AiChatMessage; +import com.openmanus.aiframework.runtime.model.AiChatResponse; +import com.openmanus.domain.service.AgentExecutionPort; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("MasterAgentOrchestrator Tests") +class MasterAgentOrchestratorTest { + + private final AgentTeamPromptProvider promptProvider = new AgentTeamPromptProvider() { + @Override + public String taskDecompositionPromptTemplate() { + return "Decompose this request into parallel subtasks:\n{{userInput}}\nlimit={{maxSubTasks}}"; + } + + @Override + public String subAgentExecutionPromptTemplate() { + return """ + agentId={{agentId}} + taskId={{taskId}} + groupId={{groupId}} + title={{taskTitle}} + description={{taskDescription}} + """; + } + }; + + @Test + @DisplayName("should fall back to single agent when request is not safely parallelizable") + void shouldFallBackToSingleAgentWhenRequestIsNotSafelyParallelizable() { + AgentExecutionPort agentExecutionPort = mock(AgentExecutionPort.class); + when(agentExecutionPort.executeSync("Implement this feature end to end", "conv-1")) + .thenReturn("single-agent-result"); + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenThrow(new IllegalStateException("fallback to rule")); + + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + SubAgentWorkerManager workerManager = new SubAgentWorkerManager( + 2, + 10L, + taskPool, + new InMemoryAgentMessageBus(), + new SubAgentExecutionService(agentExecutionPort, promptProvider) + ); + + try { + MasterAgentOrchestrator orchestrator = new MasterAgentOrchestrator( + agentExecutionPort, + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider), + new DefaultTaskGroupManager(repository, new TaskGroupStatusCalculator()), + taskPool, + new DefaultResultAggregationService(), + workerManager, + 10L, + 5 + ); + + String result = orchestrator.execute("Implement this feature end to end", "conv-1"); + + assertThat(result).isEqualTo("single-agent-result"); + assertThat(workerManager.getWorkerIds()).isEmpty(); + verify(agentExecutionPort).executeSync("Implement this feature end to end", "conv-1"); + } finally { + workerManager.close(); + } + } + + @Test + @DisplayName("should execute parallel subtasks through shared agent execution port") + void shouldExecuteParallelSubTasksThroughSharedAgentExecutionPort() { + AgentExecutionPort agentExecutionPort = mock(AgentExecutionPort.class); + when(agentExecutionPort.execute(anyString(), anyString())).thenReturn(CompletableFuture.completedFuture("unused")); + when(agentExecutionPort.executeSync(anyString(), anyString())).thenAnswer(invocation -> { + String prompt = invocation.getArgument(0, String.class); + String conversationId = invocation.getArgument(1, String.class); + if (prompt.contains("Collect API requirements")) { + return "Collected requirements for " + conversationId; + } + if (prompt.contains("Summarize deployment risks")) { + return "Summarized risks for " + conversationId; + } + return "Generic result for " + conversationId; + }); + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Independent work items", + "subTasks": [ + {"title": "API requirements", "description": "Collect API requirements"}, + {"title": "Deployment risks", "description": "Summarize deployment risks"} + ] + } + """), + null, + null, + null, + null, + null + )); + + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + SubAgentWorkerManager workerManager = new SubAgentWorkerManager( + 2, + 10L, + taskPool, + new InMemoryAgentMessageBus(), + new SubAgentExecutionService(agentExecutionPort, promptProvider) + ); + + try { + MasterAgentOrchestrator orchestrator = new MasterAgentOrchestrator( + agentExecutionPort, + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider), + new DefaultTaskGroupManager(repository, new TaskGroupStatusCalculator()), + taskPool, + new DefaultResultAggregationService(), + workerManager, + 10L, + 5 + ); + + String result = orchestrator.execute("Please decompose this request", "conv-2"); + + assertThat(workerManager.getWorkerIds()).hasSize(2); + assertThat(result).contains("status: COMPLETED"); + assertThat(result).contains("success: 2"); + assertThat(result).contains("failed: 0"); + assertThat(result).contains("API requirements"); + assertThat(result).contains("Deployment risks"); + verify(agentExecutionPort, never()).executeSync(eq("Please decompose this request"), eq("conv-2")); + verify(agentExecutionPort, atLeast(2)).executeSync(anyString(), anyString()); + } finally { + workerManager.close(); + } + } + + @Test + @DisplayName("should report partial failure when one subtask execution fails") + void shouldReportPartialFailureWhenOneSubTaskExecutionFails() { + AgentExecutionPort agentExecutionPort = mock(AgentExecutionPort.class); + when(agentExecutionPort.execute(anyString(), anyString())).thenReturn(CompletableFuture.completedFuture("unused")); + when(agentExecutionPort.executeSync(anyString(), anyString())).thenAnswer(invocation -> { + String prompt = invocation.getArgument(0, String.class); + String conversationId = invocation.getArgument(1, String.class); + if (prompt.contains("Collect API requirements")) { + return "Collected requirements for " + conversationId; + } + if (prompt.contains("Summarize deployment risks")) { + throw new IllegalStateException("subtask failed intentionally"); + } + return "Generic result for " + conversationId; + }); + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Independent work items", + "subTasks": [ + {"title": "API requirements", "description": "Collect API requirements"}, + {"title": "Deployment risks", "description": "Summarize deployment risks"} + ] + } + """), + null, + null, + null, + null, + null + )); + + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + SubAgentWorkerManager workerManager = new SubAgentWorkerManager( + 2, + 10L, + taskPool, + new InMemoryAgentMessageBus(), + new SubAgentExecutionService(agentExecutionPort, promptProvider) + ); + + try { + MasterAgentOrchestrator orchestrator = new MasterAgentOrchestrator( + agentExecutionPort, + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider), + new DefaultTaskGroupManager(repository, new TaskGroupStatusCalculator()), + taskPool, + new DefaultResultAggregationService(), + workerManager, + 10L, + 5 + ); + + String result = orchestrator.execute("Please decompose this request", "conv-3"); + + assertThat(result).contains("status: PARTIAL_FAILED"); + assertThat(result).contains("success: 1"); + assertThat(result).contains("failed: 1"); + assertThat(result).contains("Failed subtasks:"); + assertThat(result).contains("subtask failed intentionally"); + } finally { + workerManager.close(); + } + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java new file mode 100644 index 0000000..69fac27 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java @@ -0,0 +1,163 @@ +package com.openmanus.agentteam.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openmanus.agentteam.domain.model.DecompositionPlan; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.model.AiChatMessage; +import com.openmanus.aiframework.runtime.model.AiChatResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("TaskDecompositionService Tests") +class TaskDecompositionServiceTest { + + private final AgentTeamPromptProvider promptProvider = new AgentTeamPromptProvider() { + @Override + public String taskDecompositionPromptTemplate() { + return "Decompose this request into parallel subtasks:\n{{userInput}}\nlimit={{maxSubTasks}}"; + } + + @Override + public String subAgentExecutionPromptTemplate() { + return "unused"; + } + }; + + @Test + @DisplayName("should accept structured independent subtasks from llm") + void shouldAcceptStructuredIndependentSubTasksFromLlm() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Independent work items", + "subTasks": [ + {"title": "API requirements", "description": "Collect API requirements"}, + {"title": "Deployment risks", "description": "Summarize deployment risks"}, + {"title": "Checklist", "description": "Write a short validation checklist"} + ] + } + """), + null, + null, + null, + null, + null + )); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose("big task", 5); + + assertThat(plan.parallelizable()).isTrue(); + assertThat(plan.reason()).isEqualTo("Independent work items"); + assertThat(plan.subTasks()).hasSize(3); + assertThat(plan.subTasks()) + .extracting(item -> item.description()) + .containsExactly( + "Collect API requirements", + "Summarize deployment risks", + "Write a short validation checklist" + ); + } + + @Test + @DisplayName("should reject tasks with obvious dependency hints from llm result") + void shouldRejectTasksWithObviousDependencyHintsFromLlmResult() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Proposed by model", + "subTasks": [ + {"title": "Analysis", "description": "Collect API requirements"}, + {"title": "Implementation", "description": "Then implement the service based on the approved requirements"} + ] + } + """), + null, + null, + null, + null, + null + )); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose("big task", 5); + + assertThat(plan.parallelizable()).isFalse(); + assertThat(plan.reason()).isNotBlank(); + } + + @Test + @DisplayName("should fall back to rule decomposition when llm fails") + void shouldFallBackToRuleDecompositionWhenLlmFails() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenThrow(new IllegalStateException("boom")); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose(""" + - Task 1 + - Task 2 + - Task 3 + """, 5); + + assertThat(plan.parallelizable()).isTrue(); + assertThat(plan.subTasks()).hasSize(3); + } + + @Test + @DisplayName("should reject inputs with fewer than two subtasks after fallback") + void shouldRejectInputsWithFewerThanTwoSubTasksAfterFallback() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenThrow(new IllegalStateException("boom")); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose("- Only one task", 5); + + assertThat(plan.parallelizable()).isFalse(); + assertThat(plan.subTasks()).hasSize(1); + } + + @Test + @DisplayName("should respect max subtasks limit") + void shouldRespectMaxSubTasksLimit() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Independent work items", + "subTasks": [ + {"title": "Task 1", "description": "Task 1"}, + {"title": "Task 2", "description": "Task 2"}, + {"title": "Task 3", "description": "Task 3"}, + {"title": "Task 4", "description": "Task 4"} + ] + } + """), + null, + null, + null, + null, + null + )); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose("big task", 2); + + assertThat(plan.parallelizable()).isTrue(); + assertThat(plan.subTasks()).hasSize(2); + } +} diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java new file mode 100644 index 0000000..c06ff8b --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java @@ -0,0 +1,60 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DefaultResultAggregationService Tests") +class DefaultResultAggregationServiceTest { + + private final DefaultResultAggregationService aggregationService = new DefaultResultAggregationService(); + + @Test + @DisplayName("should aggregate success and failure results") + void shouldAggregateSuccessAndFailureResults() { + TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); + SubTask success = new SubTask("task-1", "group-1", "task A", "desc", 1L); + SubTask failed = new SubTask("task-2", "group-1", "task B", "desc", 1L); + SubTask pending = new SubTask("task-3", "group-1", "task C", "desc", 1L); + + success.claim("agent-1", 2L); + success.markRunning(3L); + success.markSucceeded("summary A", "detail A", 4L); + failed.claim("agent-2", 2L); + failed.markRunning(3L); + failed.markFailed("boom", 4L); + + TaskGroupResult result = aggregationService.aggregate(group, java.util.Arrays.asList(success, failed, pending, null)); + + assertThat(result.groupId()).isEqualTo("group-1"); + assertThat(result.allSucceeded()).isFalse(); + assertThat(result.successResults()) + .extracting(item -> item.title() + ":" + item.summary()) + .containsExactly("task A:summary A"); + assertThat(result.failures()) + .extracting(item -> item.title() + ":" + item.errorMessage()) + .containsExactly("task B:boom"); + } + + @Test + @DisplayName("should mark all succeeded when there are no failures") + void shouldMarkAllSucceededWhenThereAreNoFailures() { + TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); + SubTask success = new SubTask("task-1", "group-1", "task A", "desc", 1L); + success.claim("agent-1", 2L); + success.markRunning(3L); + success.markSucceeded("summary A", "detail A", 4L); + + TaskGroupResult result = aggregationService.aggregate(group, List.of(success)); + + assertThat(result.allSucceeded()).isTrue(); + assertThat(result.failures()).isEmpty(); + assertThat(result.successResults()).hasSize(1); + } +} diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java new file mode 100644 index 0000000..5b2907d --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java @@ -0,0 +1,87 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroup; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; +import com.openmanus.agentteam.domain.model.TaskGroupStatus; +import com.openmanus.agentteam.domain.model.TaskStatus; +import com.openmanus.agentteam.infra.InMemoryTaskGroupRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DefaultTaskGroupManager Tests") +class DefaultTaskGroupManagerTest { + + private final InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + private final DefaultTaskGroupManager manager = + new DefaultTaskGroupManager(repository, new TaskGroupStatusCalculator()); + + @Test + @DisplayName("should create group and persist metadata") + void shouldCreateGroupAndPersistMetadata() { + TaskGroup group = manager.createGroup("parent-1", "master-1", "split this task"); + + assertThat(group.getGroupId()).isNotBlank(); + assertThat(group.getParentTaskId()).isEqualTo("parent-1"); + assertThat(group.getMasterAgentId()).isEqualTo("master-1"); + assertThat(group.getOriginalUserRequest()).isEqualTo("split this task"); + assertThat(group.getStatus()).isEqualTo(TaskGroupStatus.CREATED); + assertThat(repository.findGroup(group.getGroupId())).containsSame(group); + } + + @Test + @DisplayName("should register subtasks and refresh snapshot") + void shouldRegisterSubTasksAndRefreshSnapshot() { + TaskGroup group = manager.createGroup("parent-1", "master-1", "split this task"); + SubTask first = new SubTask("task-1", group.getGroupId(), "task A", "desc A", 1L); + SubTask second = new SubTask("task-2", group.getGroupId(), "task B", "desc B", 1L); + + manager.registerSubTasks(group.getGroupId(), List.of(first, second)); + + assertThat(repository.findSubTasksByGroupId(group.getGroupId())) + .extracting(SubTask::getTaskId) + .containsExactlyInAnyOrder("task-1", "task-2"); + assertThat(repository.findGroup(group.getGroupId())) + .get() + .extracting(TaskGroup::getSubTaskIds) + .asList() + .hasSize(2); + + first.claim("agent-1", 2L); + first.markRunning(3L); + first.markSucceeded("ok", "detail", 4L); + second.claim("agent-2", 2L); + second.markRunning(3L); + second.markFailed("boom", 4L); + repository.saveSubTask(first); + repository.saveSubTask(second); + + TaskGroupSnapshot snapshot = manager.getSnapshot(group.getGroupId()); + + assertThat(snapshot.status()).isEqualTo(TaskGroupStatus.PARTIAL_FAILED); + assertThat(snapshot.succeededTasks()).isEqualTo(1); + assertThat(snapshot.failedTasks()).isEqualTo(1); + assertThat(repository.findGroup(group.getGroupId())) + .get() + .extracting(TaskGroup::getStatus) + .isEqualTo(TaskGroupStatus.PARTIAL_FAILED); + } + + @Test + @DisplayName("should keep group created when registering empty subtasks") + void shouldKeepGroupCreatedWhenRegisteringEmptySubTasks() { + TaskGroup group = manager.createGroup("parent-1", "master-1", "split this task"); + + manager.registerSubTasks(group.getGroupId(), List.of()); + + assertThat(repository.findGroup(group.getGroupId())) + .get() + .extracting(TaskGroup::getStatus) + .isEqualTo(TaskGroupStatus.CREATED); + assertThat(repository.findSubTasksByGroupId(group.getGroupId())).isEmpty(); + } +} diff --git a/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java new file mode 100644 index 0000000..2867b6a --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java @@ -0,0 +1,81 @@ +package com.openmanus.agentteam.domain.service; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskGroupSnapshot; +import com.openmanus.agentteam.domain.model.TaskGroupStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TaskGroupStatusCalculator Tests") +class TaskGroupStatusCalculatorTest { + + private final TaskGroupStatusCalculator calculator = new TaskGroupStatusCalculator(); + + @Test + @DisplayName("should mark empty group as created") + void shouldMarkEmptyGroupAsCreated() { + TaskGroupSnapshot snapshot = calculator.calculate("group-1", List.of()); + + assertThat(snapshot.status()).isEqualTo(TaskGroupStatus.CREATED); + assertThat(snapshot.totalTasks()).isZero(); + } + + @Test + @DisplayName("should mark all-success group as completed") + void shouldMarkAllSuccessGroupAsCompleted() { + SubTask first = new SubTask("task-1", "group-1", "A", "desc", 1L); + SubTask second = new SubTask("task-2", "group-1", "B", "desc", 1L); + first.claim("agent-1", 2L); + first.markRunning(3L); + first.markSucceeded("ok", "detail", 4L); + second.claim("agent-2", 2L); + second.markRunning(3L); + second.markSucceeded("ok", "detail", 4L); + + TaskGroupSnapshot snapshot = calculator.calculate("group-1", List.of(first, second)); + + assertThat(snapshot.status()).isEqualTo(TaskGroupStatus.COMPLETED); + assertThat(snapshot.succeededTasks()).isEqualTo(2); + assertThat(snapshot.allFinished()).isTrue(); + } + + @Test + @DisplayName("should mark mixed finished group as partial failed") + void shouldMarkMixedFinishedGroupAsPartialFailed() { + SubTask success = new SubTask("task-1", "group-1", "A", "desc", 1L); + SubTask failed = new SubTask("task-2", "group-1", "B", "desc", 1L); + success.claim("agent-1", 2L); + success.markRunning(3L); + success.markSucceeded("ok", "detail", 4L); + failed.claim("agent-2", 2L); + failed.markRunning(3L); + failed.markFailed("boom", 4L); + + TaskGroupSnapshot snapshot = calculator.calculate("group-1", List.of(success, failed)); + + assertThat(snapshot.status()).isEqualTo(TaskGroupStatus.PARTIAL_FAILED); + assertThat(snapshot.failedTasks()).isEqualTo(1); + assertThat(snapshot.allFinished()).isTrue(); + } + + @Test + @DisplayName("should mark claimed or running tasks as running") + void shouldMarkClaimedOrRunningTasksAsRunning() { + SubTask claimed = new SubTask("task-1", "group-1", "A", "desc", 1L); + SubTask running = new SubTask("task-2", "group-1", "B", "desc", 1L); + claimed.claim("agent-1", 2L); + running.claim("agent-2", 2L); + running.markRunning(3L); + + TaskGroupSnapshot snapshot = calculator.calculate("group-1", List.of(claimed, running)); + + assertThat(snapshot.status()).isEqualTo(TaskGroupStatus.RUNNING); + assertThat(snapshot.claimedTasks()).isEqualTo(1); + assertThat(snapshot.runningTasks()).isEqualTo(1); + assertThat(snapshot.allFinished()).isFalse(); + } +} diff --git a/src/test/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBusTest.java b/src/test/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBusTest.java new file mode 100644 index 0000000..6f430b2 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/infra/InMemoryAgentMessageBusTest.java @@ -0,0 +1,59 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.domain.model.AgentMessage; +import com.openmanus.agentteam.domain.model.MessageType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("InMemoryAgentMessageBus Tests") +class InMemoryAgentMessageBusTest { + + private final InMemoryAgentMessageBus messageBus = new InMemoryAgentMessageBus(); + + @Test + @DisplayName("should fetch unread mailbox messages") + void shouldFetchUnreadMailboxMessages() { + AgentMessage message = new AgentMessage( + "msg-1", + "agent-a", + "agent-b", + "group-1", + MessageType.INFO, + "hello", + false, + 1L, + null + ); + + messageBus.send(message); + + assertThat(messageBus.fetchUnread("agent-b")) + .extracting(AgentMessage::content) + .containsExactly("hello"); + } + + @Test + @DisplayName("should mark fetched messages as read") + void shouldMarkFetchedMessagesAsRead() { + AgentMessage message = new AgentMessage( + "msg-1", + "agent-a", + "agent-b", + "group-1", + MessageType.INFO, + "hello", + false, + 1L, + null + ); + messageBus.send(message); + + messageBus.markAsRead("agent-b", List.of("msg-1")); + + assertThat(messageBus.fetchUnread("agent-b")).isEmpty(); + } +} diff --git a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java new file mode 100644 index 0000000..599344b --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java @@ -0,0 +1,70 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agentteam.domain.model.SubTask; +import com.openmanus.agentteam.domain.model.TaskStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("InMemoryTaskPool Tests") +class InMemoryTaskPoolTest { + + @Test + @DisplayName("should claim pending task once") + void shouldClaimPendingTaskOnce() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + SubTask subTask = new SubTask("task-1", "group-1", "A", "desc", 1L); + + taskPool.submit(subTask); + + Optional claimed = taskPool.claimNext("agent-1"); + + assertThat(claimed).isPresent(); + assertThat(claimed.get().getAssignedAgentId()).isEqualTo("agent-1"); + assertThat(claimed.get().getStatus()).isEqualTo(TaskStatus.CLAIMED); + assertThat(taskPool.claimNext("agent-2")).isEmpty(); + } + + @Test + @DisplayName("should prevent duplicate claim under concurrency") + void shouldPreventDuplicateClaimUnderConcurrency() throws InterruptedException { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "A", "desc", 1L)); + + AtomicInteger successClaims = new AtomicInteger(); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executorService = Executors.newFixedThreadPool(2); + + Runnable claimer = () -> { + ready.countDown(); + try { + start.await(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + if (taskPool.claimNext(Thread.currentThread().getName()).isPresent()) { + successClaims.incrementAndGet(); + } + }; + + executorService.submit(claimer); + executorService.submit(claimer); + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + executorService.shutdown(); + assertThat(executorService.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(successClaims.get()).isEqualTo(1); + } +} diff --git a/src/test/java/com/openmanus/infra/monitoring/WebSocketExecutionStreamPublisherTest.java b/src/test/java/com/openmanus/infra/monitoring/WebSocketExecutionStreamPublisherTest.java index 5b2e954..d78b77e 100644 --- a/src/test/java/com/openmanus/infra/monitoring/WebSocketExecutionStreamPublisherTest.java +++ b/src/test/java/com/openmanus/infra/monitoring/WebSocketExecutionStreamPublisherTest.java @@ -70,7 +70,7 @@ void shouldPublishFinalResultToMatchingExecutionTopic() { verify(messagingTemplate).convertAndSend( eq(TOPIC + "/result"), - argThat(payload -> payload instanceof ExecutionResultView view + (Object) argThat(payload -> payload instanceof ExecutionResultView view && "hello".equals(view.getResult())) ); } diff --git a/src/test/java/com/openmanus/infra/web/AgentControllerSessionSandboxStartTest.java b/src/test/java/com/openmanus/infra/web/AgentControllerSessionSandboxStartTest.java index ffb487a..4ef9994 100644 --- a/src/test/java/com/openmanus/infra/web/AgentControllerSessionSandboxStartTest.java +++ b/src/test/java/com/openmanus/infra/web/AgentControllerSessionSandboxStartTest.java @@ -1,9 +1,12 @@ package com.openmanus.infra.web; +import com.openmanus.agentteam.application.AgentTeamConversationApplicationService; +import com.openmanus.agentteam.application.AgentTeamExecutionStreamingApplicationService; import com.openmanus.domain.model.ExecutionErrorCodes; import com.openmanus.domain.model.ExecutionResponse; import com.openmanus.domain.service.ConversationApplicationService; import com.openmanus.domain.service.ExecutionStreamingApplicationService; +import com.openmanus.infra.config.AgentTeamProperties; import com.openmanus.sandbox.application.SandboxSessionApplicationService; import com.openmanus.sandbox.domain.model.SessionSandboxInfo; import org.junit.jupiter.api.DisplayName; @@ -23,14 +26,22 @@ class AgentControllerSessionSandboxStartTest { private final ConversationApplicationService conversationApplicationService = mock(ConversationApplicationService.class); + private final AgentTeamConversationApplicationService agentTeamConversationApplicationService = + mock(AgentTeamConversationApplicationService.class); + private final AgentTeamExecutionStreamingApplicationService agentTeamExecutionStreamingApplicationService = + mock(AgentTeamExecutionStreamingApplicationService.class); private final ExecutionStreamingApplicationService executionStreamingApplicationService = mock(ExecutionStreamingApplicationService.class); + private final AgentTeamProperties agentTeamProperties = new AgentTeamProperties(); private final SandboxSessionApplicationService sandboxSessionApplicationService = mock(SandboxSessionApplicationService.class); private final AgentController controller = new AgentController( conversationApplicationService, + agentTeamConversationApplicationService, + agentTeamExecutionStreamingApplicationService, executionStreamingApplicationService, + agentTeamProperties, sandboxSessionApplicationService ); @@ -97,7 +108,7 @@ void executionStream_mapsSessionBusyToConflict() { request.setInput("hello"); request.setSessionId("session-123"); - ResponseEntity response = controller.executionStream(request); + ResponseEntity response = controller.executionStream(request, false); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); assertThat(response.getBody()).isNotNull(); From 12118bbd351f3da7d0135a649a89335a8eeb960f Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Tue, 26 May 2026 22:04:39 +0800 Subject: [PATCH 03/14] feat(agentteam): implement tool and prompt isolation for team master and sub agents --- .gitignore | 2 + .../agent/base/AbstractAgentExecutor.java | 4 + .../agent/coordination/AgentCoordinator.java | 4 +- .../application/AgentTeamPromptProvider.java | 4 + .../agentteam/application/AgentTeamRole.java | 9 + .../AgentTeamRoleExecutionPort.java | 9 + .../AgentTeamRoleExecutionService.java | 33 ++ .../application/AgentTeamToolPolicy.java | 13 + .../application/MasterAgentOrchestrator.java | 14 +- .../application/SubAgentExecutionService.java | 16 +- .../application/SubAgentToolPolicy.java | 38 ++ .../application/TeamMasterToolPolicy.java | 31 ++ .../docs/AGENT_TEAM_DEVELOPMENT_PLAN.md | 36 ++ .../docs/AGENT_TEAM_ISOLATION_PLAN.md | 510 ++++++++++++++++++ .../infra/AgentTeamCoordinatorFactory.java | 102 ++++ .../ClasspathAgentTeamPromptProvider.java | 12 + .../2026-05-19-agentteam-mvp-progress.md | 35 ++ .../infra/config/AgentArchitectureConfig.java | 74 +-- .../infra/config/AgentTeamConfig.java | 52 +- .../infra/config/LocalAgentToolRegistry.java | 72 +++ .../agentteam/subagent-execution.prompt.md | 9 +- .../agentteam/subagent-system.prompt.md | 10 + .../agentteam/team-master-system.prompt.md | 10 + .../AgentTeamPromptProviderTest.java | 22 + .../MasterAgentOrchestratorTest.java | 22 +- .../SubAgentExecutionIsolationTest.java | 68 +++ .../application/SubAgentToolPolicyTest.java | 40 ++ .../TaskDecompositionServiceTest.java | 10 + .../application/TeamMasterToolPolicyTest.java | 40 ++ .../config/SingleAgentRegressionTest.java | 126 +++++ 30 files changed, 1372 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamRole.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamToolPolicy.java create mode 100644 src/main/java/com/openmanus/agentteam/application/SubAgentToolPolicy.java create mode 100644 src/main/java/com/openmanus/agentteam/application/TeamMasterToolPolicy.java create mode 100644 src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md create mode 100644 src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md create mode 100644 src/main/java/com/openmanus/agentteam/infra/AgentTeamCoordinatorFactory.java create mode 100644 src/main/java/com/openmanus/agentteam/progress/2026-05-19-agentteam-mvp-progress.md create mode 100644 src/main/java/com/openmanus/infra/config/LocalAgentToolRegistry.java create mode 100644 src/main/resources/prompts/agentteam/subagent-system.prompt.md create mode 100644 src/main/resources/prompts/agentteam/team-master-system.prompt.md create mode 100644 src/test/java/com/openmanus/agentteam/application/AgentTeamPromptProviderTest.java create mode 100644 src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java create mode 100644 src/test/java/com/openmanus/agentteam/application/SubAgentToolPolicyTest.java create mode 100644 src/test/java/com/openmanus/agentteam/application/TeamMasterToolPolicyTest.java create mode 100644 src/test/java/com/openmanus/infra/config/SingleAgentRegressionTest.java diff --git a/.gitignore b/.gitignore index ea96dc0..47daddc 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ Thumbs.db # Docker docker-compose.override.yml .dockerignore +Dockerfile # Secrets and configuration *.env @@ -138,6 +139,7 @@ application-dev.yml application-prod.yml application.yml application.yaml +*.yml # Temporary files *.tmp diff --git a/src/main/java/com/openmanus/agent/base/AbstractAgentExecutor.java b/src/main/java/com/openmanus/agent/base/AbstractAgentExecutor.java index 7f92147..472cc14 100644 --- a/src/main/java/com/openmanus/agent/base/AbstractAgentExecutor.java +++ b/src/main/java/com/openmanus/agent/base/AbstractAgentExecutor.java @@ -227,6 +227,10 @@ public B systemMessage(String message) { this.systemMessage = Objects.requireNonNull(message, "message cannot be null"); return result(); } + + protected String configuredSystemMessage() { + return systemMessage; + } } private final AiChatModel aiChatModel; diff --git a/src/main/java/com/openmanus/agent/coordination/AgentCoordinator.java b/src/main/java/com/openmanus/agent/coordination/AgentCoordinator.java index 71e9f3e..2bc2ec2 100644 --- a/src/main/java/com/openmanus/agent/coordination/AgentCoordinator.java +++ b/src/main/java/com/openmanus/agent/coordination/AgentCoordinator.java @@ -58,7 +58,9 @@ public AgentCoordinator build() { this.name("agent_coordinator") .description("统一协调搜索、代码、文件与反思工具,完成端到端任务。") .singleParameter("用户请求") - .systemMessage(SYSTEM_PROMPT); + .systemMessage(this.configuredSystemMessage() == null || this.configuredSystemMessage().isBlank() + ? SYSTEM_PROMPT + : this.configuredSystemMessage()); if (browserTool != null) { this.toolFromObject(browserTool); diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java index 797356a..bf7abea 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamPromptProvider.java @@ -7,5 +7,9 @@ public interface AgentTeamPromptProvider { String taskDecompositionPromptTemplate(); + String teamMasterSystemPromptTemplate(); + + String subAgentSystemPromptTemplate(); + String subAgentExecutionPromptTemplate(); } diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRole.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRole.java new file mode 100644 index 0000000..f47b457 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRole.java @@ -0,0 +1,9 @@ +package com.openmanus.agentteam.application; + +/** + * Roles inside the agentteam execution flow. + */ +public enum AgentTeamRole { + TEAM_MASTER, + SUB_AGENT +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java new file mode 100644 index 0000000..6076cd2 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java @@ -0,0 +1,9 @@ +package com.openmanus.agentteam.application; + +/** + * Role-scoped execution entry for the agentteam module. + */ +public interface AgentTeamRoleExecutionPort { + + String executeSync(AgentTeamRole role, String input, String conversationId); +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java new file mode 100644 index 0000000..dbfc75d --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java @@ -0,0 +1,33 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.agent.coordination.AgentCoordinator; +import com.openmanus.agentteam.infra.AgentTeamCoordinatorFactory; +import org.slf4j.MDC; + +import java.util.UUID; + +/** + * Executes one request with the coordinator built for a specific agentteam role. + */ +public class AgentTeamRoleExecutionService implements AgentTeamRoleExecutionPort { + + private final AgentTeamCoordinatorFactory coordinatorFactory; + + public AgentTeamRoleExecutionService(AgentTeamCoordinatorFactory coordinatorFactory) { + this.coordinatorFactory = coordinatorFactory; + } + + @Override + public String executeSync(AgentTeamRole role, String input, String conversationId) { + if (input == null || input.isBlank()) { + throw new IllegalArgumentException("input cannot be null or blank"); + } + Object memoryId = conversationId != null && !conversationId.isBlank() + ? conversationId + : UUID.randomUUID(); + try (MDC.MDCCloseable ignored = MDC.putCloseable("sessionId", String.valueOf(memoryId))) { + AgentCoordinator coordinator = coordinatorFactory.create(role); + return coordinator.execute(input, memoryId); + } + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamToolPolicy.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamToolPolicy.java new file mode 100644 index 0000000..5102674 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamToolPolicy.java @@ -0,0 +1,13 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.aiframework.tool.AiRegisteredTool; + +import java.util.List; + +/** + * Selects the local tool set available to one agentteam role. + */ +public interface AgentTeamToolPolicy { + + List selectTools(List defaultTools); +} diff --git a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java index 41e9d18..8be2dd6 100644 --- a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java +++ b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java @@ -17,7 +17,7 @@ import java.util.UUID; /** - * Main orchestration entry for the V1 agent-team flow. + * Team master orchestration entry for the V1 agent-team flow. */ @Slf4j public class MasterAgentOrchestrator { @@ -54,25 +54,25 @@ public MasterAgentOrchestrator( public String execute(String userInput, String conversationId) { DecompositionPlan plan = decompositionService.decompose(userInput, maxSubTasksPerGroup); log.info( - "MasterAgent decomposition finished: parallelizable={}, subTaskCount={}, reason={}", + "TeamMaster decomposition finished: parallelizable={}, subTaskCount={}, reason={}", plan.parallelizable(), plan.subTasks() == null ? 0 : plan.subTasks().size(), plan.reason() ); if (!plan.parallelizable()) { - log.info("MasterAgent falling back to single-agent execution: conversationId={}", conversationId); + log.info("TeamMaster falling back to single-agent execution: conversationId={}", conversationId); return agentExecutionPort.executeSync(userInput, conversationId); } workerManager.ensureStarted(); TaskGroup taskGroup = taskGroupManager.createGroup( conversationId == null || conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId, - "master-agent", + "team-master", userInput ); List subTasks = materializeSubTasks(taskGroup.getGroupId(), plan.subTasks()); log.info( - "MasterAgent created task group: groupId={}, conversationId={}, subTaskCount={}", + "TeamMaster created task group: groupId={}, conversationId={}, subTaskCount={}", taskGroup.getGroupId(), conversationId, subTasks.size() @@ -80,7 +80,7 @@ public String execute(String userInput, String conversationId) { taskGroupManager.registerSubTasks(taskGroup.getGroupId(), subTasks); for (SubTask subTask : subTasks) { log.info( - "MasterAgent submitting subtask to pool: groupId={}, taskId={}, title={}", + "TeamMaster submitting subtask to pool: groupId={}, taskId={}, title={}", taskGroup.getGroupId(), subTask.getTaskId(), subTask.getTitle() @@ -94,7 +94,7 @@ public String execute(String userInput, String conversationId) { taskPoolPort.findByGroupId(taskGroup.getGroupId()) ); log.info( - "MasterAgent aggregation finished: groupId={}, status={}, successCount={}, failedCount={}", + "TeamMaster aggregation finished: groupId={}, status={}, successCount={}, failedCount={}", taskGroup.getGroupId(), snapshot.status(), snapshot.succeededTasks(), diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java index 7083bdc..8498b76 100644 --- a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java @@ -1,38 +1,40 @@ package com.openmanus.agentteam.application; import com.openmanus.agentteam.domain.model.SubTask; -import com.openmanus.domain.service.AgentExecutionPort; import lombok.extern.slf4j.Slf4j; import java.util.Map; /** - * Executes one subtask by reusing the existing single-agent execution path. + * Executes one subtask through the role-scoped sub-agent runtime. + * + *

This service remains a thin application-layer bridge and deliberately does not own + * task scheduling or result aggregation decisions.

*/ @Slf4j public class SubAgentExecutionService { - private final AgentExecutionPort agentExecutionPort; + private final AgentTeamRoleExecutionPort roleExecutionPort; private final AgentTeamPromptProvider promptProvider; public SubAgentExecutionService( - AgentExecutionPort agentExecutionPort, + AgentTeamRoleExecutionPort roleExecutionPort, AgentTeamPromptProvider promptProvider ) { - this.agentExecutionPort = agentExecutionPort; + this.roleExecutionPort = roleExecutionPort; this.promptProvider = promptProvider; } public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { String prompt = buildSubTaskPrompt(subTask, agentId); log.info( - "SubAgentExecution dispatching to single-agent runtime: agentId={}, groupId={}, taskId={}, title={}", + "SubAgentExecution dispatching to role-scoped runtime: agentId={}, groupId={}, taskId={}, title={}", agentId, subTask.getGroupId(), subTask.getTaskId(), subTask.getTitle() ); - String result = agentExecutionPort.executeSync(prompt, subTask.getTaskId()); + String result = roleExecutionPort.executeSync(AgentTeamRole.SUB_AGENT, prompt, subTask.getTaskId()); String summary = summarize(result); log.info( "SubAgentExecution finished runtime call: agentId={}, groupId={}, taskId={}, title={}, summary={}", diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentToolPolicy.java b/src/main/java/com/openmanus/agentteam/application/SubAgentToolPolicy.java new file mode 100644 index 0000000..4f2f8b6 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentToolPolicy.java @@ -0,0 +1,38 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.aiframework.tool.AiRegisteredTool; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * First-version tool policy for sub-agents. + */ +public class SubAgentToolPolicy implements AgentTeamToolPolicy { + + static final Set ALLOWED_TOOL_NAMES = Set.of( + "browser_open_url", + "browser_ensure_sandbox", + "executePython", + "executePythonFile", + "search_web", + "browser_fetch_web", + "runShellCommand", + "recordTask", + "reflectOnTask", + "getTaskHistory" + ); + + @Override + public List selectTools(List defaultTools) { + if (defaultTools == null || defaultTools.isEmpty()) { + return List.of(); + } + Set deduplicatedNames = new LinkedHashSet<>(); + return defaultTools.stream() + .filter(tool -> tool != null && ALLOWED_TOOL_NAMES.contains(tool.name())) + .filter(tool -> deduplicatedNames.add(tool.name())) + .toList(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/TeamMasterToolPolicy.java b/src/main/java/com/openmanus/agentteam/application/TeamMasterToolPolicy.java new file mode 100644 index 0000000..cbb7ef1 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/TeamMasterToolPolicy.java @@ -0,0 +1,31 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.aiframework.tool.AiRegisteredTool; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * First-version tool policy for the team master role. + */ +public class TeamMasterToolPolicy implements AgentTeamToolPolicy { + + static final Set ALLOWED_TOOL_NAMES = Set.of( + "search_web", + "browser_fetch_web", + "runShellCommand" + ); + + @Override + public List selectTools(List defaultTools) { + if (defaultTools == null || defaultTools.isEmpty()) { + return List.of(); + } + Set deduplicatedNames = new LinkedHashSet<>(); + return defaultTools.stream() + .filter(tool -> tool != null && ALLOWED_TOOL_NAMES.contains(tool.name())) + .filter(tool -> deduplicatedNames.add(tool.name())) + .toList(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md b/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..bfc06f7 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md @@ -0,0 +1,36 @@ +# Agent Team 开发执行计划 + +本文档用于跟踪 `agentteam` 提示词隔离、工具隔离与执行入口隔离的开发进展。 + +## 开发清单 + +- [x] 第 1 步:角色语义收口 + - 新增 `AgentTeamRole` + - 明确 `MasterAgentOrchestrator` 的 Team Master 语义 + - 明确 `SubAgentExecutionService` 当前职责和过渡边界 + +- [x] 第 2 步:提示词入口收口 + - 扩展 `AgentTeamPromptProvider` + - 增加 Team Master / SubAgent 专用系统提示词模板 + - 保持任务拆解提示词继续独立存在 + +- [x] 第 3 步:角色化执行入口 + - 新增 `AgentTeamRoleExecutionPort` + - 新增 `AgentTeamRoleExecutionService` + - 让 `SubAgentExecutionService` 不再直接依赖默认 `AgentExecutionPort` + +- [x] 第 4 步:工具隔离策略 + - 新增 `AgentTeamToolPolicy` + - 实现 `SubAgentToolPolicy` + - 实现 `TeamMasterToolPolicy` + - 提供本地工具注册表,避免在 `agentteam` 内散落工具扫描逻辑 + +- [x] 第 5 步:角色化 Coordinator 构造 + - 新增 `AgentTeamCoordinatorFactory` + - 基于角色选择系统提示词和工具集合 + - 先不开放默认 MCP tools + +- [x] 第 6 步:配置装配与回归测试 + - 调整 `AgentTeamConfig` + - 补充 prompt、工具策略、SubAgent 隔离相关测试 + - 验证默认单 Agent 主链路不受影响 diff --git a/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md b/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md new file mode 100644 index 0000000..d9cd16e --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md @@ -0,0 +1,510 @@ +# Agent Team 提示词与工具隔离开发计划 + +## 1. 背景 + +当前仓库已经具备 `agentteam` 的最小协作闭环: + +1. 主流程可以判断任务是否适合拆分。 +2. 可以把任务拆成多个 `SubTask`。 +3. 多个 `SubAgentWorker` 可以并行领取并执行子任务。 +4. 主流程可以汇总成功/失败结果并输出最终文本。 + +但当前实现仍然存在一个明显问题: + +- `agentteam` 中的 `master agent` 还没有和普通单 Agent 流程中的主 Agent 做清晰区分。 +- `subagent` 当前仍然通过 `SubAgentExecutionService -> AgentExecutionPort` 直接复用默认单 Agent 执行链路。 +- 因此,`普通主 Agent`、`Agent Team Master Agent`、`SubAgent` 三个角色在提示词和工具能力上尚未真正隔离。 + +这与 Claude Code 的多 Agent 设计原则不一致。Claude Code 的关键不是“能并行跑多个 agent”,而是: + +1. 不同角色有不同职责。 +2. 不同角色有不同提示词。 +3. 不同角色有不同工具边界。 +4. `subagent` 不能默认继承父 Agent 的全部权力。 + +本计划用于收敛下一阶段实现目标:先完成 `提示词隔离 + 工具隔离`,再继续推进上下文隔离和消息驱动通信增强。 + +## 2. 目标 + +本阶段目标不是扩展更多功能,而是把当前 `agentteam` 从“复用默认 Agent 的并行执行”演进为“具有明确角色边界的协作执行”。 + +本阶段完成后,应满足以下目标: + +1. 明确区分三种角色: + - 普通流程 Agent + - Agent Team Master Agent + - Agent Team SubAgent +2. 三种角色拥有不同的提示词职责说明。 +3. Agent Team Master Agent 与 SubAgent 拥有不同的工具集合。 +4. `subagent` 不再默认拥有主流程中的调度权和全局控制权。 +5. 改造不破坏当前普通单 Agent 默认主链路。 + +## 3. 非目标 + +本阶段明确不做以下内容: + +1. 不实现完整角色系统或通用 RBAC 权限系统。 +2. 不实现 Skills Registry / Skill Loader。 +3. 不实现 cron / trigger / environment monitor / auto recovery。 +4. 不实现分布式调度或跨进程 Agent Team。 +5. 不在本阶段引入复杂的上下文共享策略。 +6. 不把 `agentteam` 权限逻辑下沉成 `aiframework` 的通用抽象。 + +## 4. 角色定义 + +### 4.1 普通流程 Agent + +普通流程 Agent 指当前默认执行链路中的主 Agent。 + +职责: + +1. 直接理解用户输入。 +2. 直接调用工具完成任务。 +3. 直接给出最终回答。 + +特点: + +1. 是单兵执行者。 +2. 工具集以“直接干活”为中心。 +3. 不承担 Team 协调职责。 + +### 4.2 Agent Team Master Agent + +Agent Team Master Agent 指多 Agent 模式中的协调者。 + +职责: + +1. 判断任务是否适合拆分。 +2. 生成子任务计划。 +3. 创建和派发子任务。 +4. 跟踪子任务状态。 +5. 汇总子任务结果。 +6. 形成最终答复。 + +特点: + +1. 是协调者,不是默认全能执行者。 +2. 工具集以“拆分、委派、查询、汇总”为中心。 +3. 即使保留少量直接执行工具,也不应与普通流程 Agent 完全相同。 + +### 4.3 Agent Team SubAgent + +SubAgent 指被 Team Master 派发出去的执行单元。 + +职责: + +1. 接收单个明确子任务。 +2. 使用受限工具完成子任务。 +3. 返回结构化结果或失败信息。 + +特点: + +1. 是受控 worker。 +2. 只负责执行,不负责拆分、委派、汇总、最终答复。 +3. 不应拥有调度权、全局控制权或默认全量工具。 + +## 5. 提示词隔离方案 + +提示词隔离的核心不是“多写几句说明”,而是让模型明确进入不同角色。 + +### 5.1 普通流程 Agent 提示词 + +定位: + +- 单兵执行者 + +提示词目标: + +1. 直接分析任务。 +2. 直接使用工具。 +3. 自行推进计划、执行、观察、调整。 +4. 直接向用户回答。 + +当前状态: + +- 继续沿用当前默认单 Agent 主链路提示词。 + +### 5.2 Agent Team Master Agent 提示词 + +定位: + +- 协调者 / 调度者 + +提示词目标: + +1. 优先判断任务是否适合拆分。 +2. 优先生成并管理子任务,而不是亲自完成所有细节。 +3. 明确自己负责: + - 任务拆解 + - 子任务委派 + - 子任务状态跟踪 + - 结果汇总 +4. 只有在不值得委派时,才少量直接执行。 + +建议提示词约束: + +1. 明确写出“你的首要职责是协调多个 worker,而不是默认亲自完成所有子任务”。 +2. 明确写出“只有适合并行且可独立执行的工作才拆分”。 +3. 明确写出“最终答复由你统一汇总输出”。 + +### 5.3 SubAgent 提示词 + +定位: + +- 受委派执行单元 + +提示词目标: + +1. 明确自己正在执行一个被委派的子任务。 +2. 明确只处理当前 `SubTask`,不要重新拆任务。 +3. 明确不负责最终用户答复。 +4. 明确输出重点是: + - 子任务结果 + - 关键证据 + - 失败原因 + +建议提示词约束: + +1. 明确写出“你不是最终协调者”。 +2. 明确写出“不要再次委派子任务”。 +3. 明确写出“仅在当前授权工具范围内完成工作”。 +4. 明确写出“结果需要便于上游汇总”。 + +### 5.4 提示词文件拆分建议 + +当前已有: + +- `prompts/agentteam/task-decomposition.prompt.md` +- `prompts/agentteam/subagent-execution.prompt.md` + +建议新增或调整为: + +1. `prompts/agentteam/team-master-system.prompt.md` + - Team Master 角色总提示词 +2. `prompts/agentteam/task-decomposition.prompt.md` + - 拆分专用提示词,可保留或并入 Team Master 提示词体系 +3. `prompts/agentteam/subagent-execution.prompt.md` + - SubAgent 执行提示词 + +建议原则: + +1. 不修改普通流程主 Agent 提示词语义。 +2. `agentteam` 的 Master / SubAgent 提示词完全独立维护。 +3. 提示词差异是角色差异,不是文案差异。 + +## 6. 工具隔离方案 + +工具隔离的目标是按角色分配工具,而不是把所有角色都接到同一个默认工具池。 + +### 6.1 当前工具来源 + +当前本地工具主要位于: + +- `src/main/java/com/openmanus/agent/tool` + +包括: + +1. `BrowserTool` +2. `PythonExecutionTool` +3. `SearchTool` +4. `ShellTool` +5. `TaskReflectionTool` +6. `WebFetchTool` + +并通过: + +- `infra/config/AgentArchitectureConfig` + +注册进入默认 `AgentCoordinator`。 + +此外还有: + +1. 运行时发现的 MCP tools +2. `agentteam` 模块自身的调度能力 + +需要注意: + +- `agent/tool` 中的是“执行工具”。 +- `agentteam` 中的是“调度能力”。 +- 二者不能混为一谈。 + +### 6.2 角色与工具能力划分 + +#### 普通流程 Agent 工具能力 + +普通流程 Agent 继续使用当前默认工具集: + +1. Browser +2. Python +3. Search +4. WebFetch +5. Shell +6. TaskReflection +7. 可选 MCP tools + +#### Agent Team Master Agent 工具能力 + +Team Master Agent 的主能力应从“执行工具”转向“协调工具”。 + +建议第一版 Team Master 核心能力包含: + +1. 任务拆分 +2. 创建任务组 +3. 生成子任务 +4. 派发子任务 +5. 查询子任务状态 +6. 汇总结果 + +Team Master 是否保留直接执行工具: + +1. 第一版可保留少量只读或辅助工具,例如: + - `SearchTool` + - `WebFetchTool` + - 受限 `ShellTool` +2. 不建议保留完整执行工具集并默认直接干活。 + +#### SubAgent 工具能力 + +SubAgent 只保留完成子任务所需的工作工具。 + +建议第一版允许: + +1. `BrowserTool` +2. `PythonExecutionTool` +3. `SearchTool` +4. `WebFetchTool` +5. `ShellTool` +6. 受限 `TaskReflectionTool` + +### 6.3 SubAgent 禁用能力 + +SubAgent 必须禁止以下能力: + +1. 再次拆分任务 +2. 再次派发子任务 +3. 创建新的 TaskGroup +4. 修改其他 SubTask 的状态 +5. 修改 TaskGroup 汇总状态 +6. 控制 WorkerManager 生命周期 +7. 接管最终用户答复 +8. 默认开放所有 MCP tools +9. 无边界读取/写入全局任务历史 + +### 6.4 为什么第一步先做工具隔离 + +因为当前最明显的问题不是“不能并发”,而是: + +1. `SubAgentExecutionService` 仍直接复用默认执行链路。 +2. `subagent` 仍隐式拥有与主流程近似的能力集合。 + +先做工具隔离有以下好处: + +1. 改动范围比上下文隔离更小。 +2. 不会立即侵入整个执行上下文模型。 +3. 可以先建立清晰角色边界。 +4. 是后续上下文隔离和消息通信增强的前置基础。 + +## 7. 代码分层设计 + +本阶段改造要遵守现有边界: + +1. `domain` 负责模型与规则边界。 +2. `application` 负责编排、角色决策和执行入口。 +3. `infra` 负责 Spring 装配、提示词资源加载和运行时适配。 + +### 7.1 application 层 + +application 层新增或调整的职责: + +1. 定义 Agent Team 的角色类型 +2. 定义角色到提示词的选择逻辑 +3. 定义角色到工具策略的选择逻辑 +4. 提供 Team Master 与 SubAgent 的独立执行入口 + +建议新增类: + +1. `AgentTeamRole` + - 角色枚举 + - 值建议: + - `TEAM_MASTER` + - `SUB_AGENT` + +2. `AgentTeamPromptStrategy` + - 根据角色返回对应提示词模板或提示词描述 + +3. `SubAgentToolPolicy` + - 输入默认可用工具 + - 输出 SubAgent 可用工具 + +4. `TeamMasterToolPolicy` + - 输入默认可用工具 + - 输出 Team Master 可用工具 + +5. `TeamMasterExecutionService` + - Team Master 专用执行入口 + - 负责使用 Team Master 的提示词和工具策略 + +6. `SubAgentExecutionService` + - 从“仅包装默认 AgentExecutionPort”调整为“走 SubAgent 专用执行入口” + +建议原则: + +1. 工具隔离策略收口在 `application`,不要散落到 controller。 +2. 提示词选择逻辑收口在 `application`,不要散落到 Worker 或 Config。 + +### 7.2 domain 层 + +domain 层不直接依赖 Spring 和运行时实现,主要承载边界规则。 + +建议新增内容: + +1. `AgentRoleCapabilities` 或等价模型 + - 描述某角色允许和禁止的能力类别 + - 不是为了做复杂权限系统,而是把规则显式化 + +2. `ToolAccessRule` + - 可选 + - 若第一版只做名称级过滤,可先不引入 + +建议原则: + +1. domain 层描述“边界是什么”。 +2. application 层负责“边界怎么执行”。 + +### 7.3 infra 层 + +infra 层负责运行时装配,不负责业务决策。 + +建议新增或调整: + +1. `ClasspathAgentTeamPromptProvider` + - 扩展为支持 Team Master 与 SubAgent 不同提示词模板 + +2. `AgentTeamConfig` + - 负责装配: + - `AgentTeamPromptStrategy` + - `SubAgentToolPolicy` + - `TeamMasterToolPolicy` + - Team Master / SubAgent 执行服务 + +3. 如需独立 Coordinator 构造器,可增加: + - `AgentTeamCoordinatorFactory` + - 负责基于裁剪后的工具集构造 Team Master / SubAgent 执行器 + +建议原则: + +1. `infra` 负责“怎么装”。 +2. `application` 负责“装什么策略”。 + +## 8. 推荐实现路径 + +### 阶段 A:角色收口 + +目标: + +1. 明确普通 Agent、Team Master、SubAgent 的角色边界 +2. 文档和代码命名先统一 + +工作项: + +1. 引入 `AgentTeamRole` +2. 把 `MasterAgentOrchestrator` 的语义明确为 Team Master 调度者 + +### 阶段 B:提示词隔离 + +目标: + +1. Team Master 与 SubAgent 使用不同提示词 + +工作项: + +1. 扩展 `AgentTeamPromptProvider` +2. 增加 Team Master 专用提示词模板 +3. 调整 `SubAgentExecutionService` 提示词约束 + +完成标准: + +1. Team Master 提示词强调协调职责 +2. SubAgent 提示词强调执行职责 + +### 阶段 C:工具隔离 + +目标: + +1. Team Master 与 SubAgent 使用不同工具策略 + +工作项: + +1. 实现 `SubAgentToolPolicy` +2. 实现 `TeamMasterToolPolicy` +3. 改造执行入口,不再让 SubAgent 无条件走默认全量工具集 + +完成标准: + +1. SubAgent 无法再次委派子任务 +2. SubAgent 无法触碰全局调度控制能力 +3. 单 Agent 默认主链路不受影响 + +### 阶段 D:测试补齐 + +目标: + +1. 为角色隔离提供自动化验证 + +建议测试: + +1. `SubAgentToolPolicyTest` +2. `TeamMasterToolPolicyTest` +3. `SubAgentExecutionIsolationTest` +4. `AgentTeamRolePromptSelectionTest` +5. `SingleAgentRegressionTest` + +## 9. 验收标准 + +本阶段验收建议如下: + +### 功能验收 + +1. 普通单 Agent 默认链路行为不变 +2. Team Master 与 SubAgent 提示词不同 +3. Team Master 与 SubAgent 工具集合不同 +4. SubAgent 不可递归派发新子任务 +5. Team Master 保留汇总和最终答复职责 + +### 架构验收 + +1. `agentteam` 的角色隔离逻辑收口在 `agentteam` 模块 +2. 不把复杂隔离逻辑散落到 controller +3. 不污染普通单 Agent 默认主链路 +4. 不为未来扩展提前引入复杂权限框架 + +### 测试验收 + +1. `compile` 通过 +2. 默认 `test` 通过 +3. 新增角色隔离相关测试通过 + +## 10. 后续演进 + +当本阶段稳定后,再考虑继续推进: + +1. 上下文隔离 + - Team Master / SubAgent 的独立执行上下文 +2. 消息驱动通信增强 + - 父子 Agent 显式消息协议 +3. 更细粒度的工具权限 + - 按工具名、按工具类别、按副作用等级控制 +4. MCP tools 的角色化开放策略 +5. 角色系统和 skills system 的后续版本设计 + +## 11. 当前结论 + +当前 `agentteam` 已具备最小协作闭环,但要向 Claude Code 学习,下一步不应继续堆叠更多功能,而应优先补齐角色边界。 + +本阶段最重要的收口是: + +1. 把普通流程 Agent、Team Master、SubAgent 区分清楚。 +2. 让 Team Master 与 SubAgent 的提示词不同。 +3. 让 Team Master 与 SubAgent 的工具集合不同。 + +只有先把这三层角色隔离开,后面的上下文隔离、通信机制和更强的多 Agent 协作策略才有稳定落点。 diff --git a/src/main/java/com/openmanus/agentteam/infra/AgentTeamCoordinatorFactory.java b/src/main/java/com/openmanus/agentteam/infra/AgentTeamCoordinatorFactory.java new file mode 100644 index 0000000..c806219 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/infra/AgentTeamCoordinatorFactory.java @@ -0,0 +1,102 @@ +package com.openmanus.agentteam.infra; + +import com.openmanus.agent.coordination.AgentCoordinator; +import com.openmanus.agentteam.application.AgentTeamRole; +import com.openmanus.agentteam.application.AgentTeamPromptProvider; +import com.openmanus.agentteam.application.AgentTeamToolPolicy; +import com.openmanus.agentteam.application.SubAgentToolPolicy; +import com.openmanus.agentteam.application.TeamMasterToolPolicy; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.AiMemoryProvider; +import com.openmanus.aiframework.runtime.AiSessionSandboxGateway; +import com.openmanus.aiframework.tool.AiRegisteredTool; +import com.openmanus.domain.service.ExecutionEventPort; +import com.openmanus.infra.config.LocalAgentToolRegistry; +import com.openmanus.infra.config.OpenManusProperties; + +import java.util.List; +import java.util.Objects; + +/** + * Builds role-scoped coordinators for the agentteam module. + */ +public class AgentTeamCoordinatorFactory { + + private final AiChatModel aiChatModel; + private final AiMemoryProvider aiMemoryProvider; + private final AiSessionSandboxGateway sessionSandboxGateway; + private final OpenManusProperties properties; + private final ExecutionEventPort executionEventPort; + private final LocalAgentToolRegistry localAgentToolRegistry; + private final AgentTeamPromptProvider promptProvider; + private final TeamMasterToolPolicy teamMasterToolPolicy; + private final SubAgentToolPolicy subAgentToolPolicy; + + public AgentTeamCoordinatorFactory( + AiChatModel aiChatModel, + AiMemoryProvider aiMemoryProvider, + AiSessionSandboxGateway sessionSandboxGateway, + OpenManusProperties properties, + ExecutionEventPort executionEventPort, + LocalAgentToolRegistry localAgentToolRegistry, + AgentTeamPromptProvider promptProvider, + TeamMasterToolPolicy teamMasterToolPolicy, + SubAgentToolPolicy subAgentToolPolicy + ) { + this.aiChatModel = Objects.requireNonNull(aiChatModel, "aiChatModel"); + this.aiMemoryProvider = Objects.requireNonNull(aiMemoryProvider, "aiMemoryProvider"); + this.sessionSandboxGateway = Objects.requireNonNull(sessionSandboxGateway, "sessionSandboxGateway"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.executionEventPort = executionEventPort; + this.localAgentToolRegistry = Objects.requireNonNull(localAgentToolRegistry, "localAgentToolRegistry"); + this.promptProvider = Objects.requireNonNull(promptProvider, "promptProvider"); + this.teamMasterToolPolicy = Objects.requireNonNull(teamMasterToolPolicy, "teamMasterToolPolicy"); + this.subAgentToolPolicy = Objects.requireNonNull(subAgentToolPolicy, "subAgentToolPolicy"); + } + + public AgentCoordinator create(AgentTeamRole role) { + AgentCoordinator.Builder builder = AgentCoordinator.builder() + .aiChatModel(aiChatModel) + .aiMemoryProvider(aiMemoryProvider) + .sessionSandboxGateway(sessionSandboxGateway) + .maxIterations(properties.getChatMemory().getReactMaxIterations()) + .maxExecutionSeconds(properties.getChatMemory().getReactMaxExecutionSeconds()) + .repeatedToolCallThreshold(properties.getChatMemory().getReactRepeatedToolCallThreshold()) + .taskStatePlanMaxChars(properties.getChatMemory().getTaskStatePlanMaxChars()) + .taskStateInProgressMaxChars(properties.getChatMemory().getTaskStateInProgressMaxChars()) + .taskStateLastFailureMaxChars(properties.getChatMemory().getTaskStateLastFailureMaxChars()) + .taskStateTodoMaxItems(properties.getChatMemory().getTaskStateTodoMaxItems()) + .taskStateTodoItemMaxChars(properties.getChatMemory().getTaskStateTodoItemMaxChars()) + .enableToolResultBudget(properties.getChatMemory().isToolResultBudgetEnabled()) + .toolResultBudgetMinChars(properties.getChatMemory().getToolResultBudgetMinChars()) + .toolResultBudgetPreviewHeadChars(properties.getChatMemory().getToolResultBudgetPreviewHeadChars()) + .toolResultBudgetPreviewTailChars(properties.getChatMemory().getToolResultBudgetPreviewTailChars()) + .toolResultBudgetDecayChars(properties.getChatMemory().getToolResultBudgetDecayChars()) + .executionEventPort(executionEventPort) + .name("agentteam_" + role.name().toLowerCase()) + .description("Role-scoped executor for agentteam role " + role.name().toLowerCase()) + .singleParameter("Role-scoped request") + .systemMessage(systemPromptFor(role)); + + for (AiRegisteredTool tool : toolsFor(role)) { + builder.tool(tool); + } + return builder.build(); + } + + private String systemPromptFor(AgentTeamRole role) { + return switch (role) { + case TEAM_MASTER -> promptProvider.teamMasterSystemPromptTemplate(); + case SUB_AGENT -> promptProvider.subAgentSystemPromptTemplate(); + }; + } + + private List toolsFor(AgentTeamRole role) { + List defaultTools = localAgentToolRegistry.allLocalTools(); + AgentTeamToolPolicy policy = switch (role) { + case TEAM_MASTER -> teamMasterToolPolicy; + case SUB_AGENT -> subAgentToolPolicy; + }; + return policy.selectTools(defaultTools); + } +} diff --git a/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java b/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java index c37a13a..554d245 100644 --- a/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java +++ b/src/main/java/com/openmanus/agentteam/infra/ClasspathAgentTeamPromptProvider.java @@ -14,6 +14,8 @@ public class ClasspathAgentTeamPromptProvider implements AgentTeamPromptProvider { private static final String TASK_DECOMPOSITION_TEMPLATE = "prompts/agentteam/task-decomposition.prompt.md"; + private static final String TEAM_MASTER_SYSTEM_TEMPLATE = "prompts/agentteam/team-master-system.prompt.md"; + private static final String SUB_AGENT_SYSTEM_TEMPLATE = "prompts/agentteam/subagent-system.prompt.md"; private static final String SUB_AGENT_EXECUTION_TEMPLATE = "prompts/agentteam/subagent-execution.prompt.md"; private final Map cache = new ConcurrentHashMap<>(); @@ -23,6 +25,16 @@ public String taskDecompositionPromptTemplate() { return load(TASK_DECOMPOSITION_TEMPLATE); } + @Override + public String teamMasterSystemPromptTemplate() { + return load(TEAM_MASTER_SYSTEM_TEMPLATE); + } + + @Override + public String subAgentSystemPromptTemplate() { + return load(SUB_AGENT_SYSTEM_TEMPLATE); + } + @Override public String subAgentExecutionPromptTemplate() { return load(SUB_AGENT_EXECUTION_TEMPLATE); diff --git a/src/main/java/com/openmanus/agentteam/progress/2026-05-19-agentteam-mvp-progress.md b/src/main/java/com/openmanus/agentteam/progress/2026-05-19-agentteam-mvp-progress.md new file mode 100644 index 0000000..3447d9b --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/progress/2026-05-19-agentteam-mvp-progress.md @@ -0,0 +1,35 @@ +# AgentTeam 第一版进度记录 + +日期:2026-05-19 + +## 本次完成 + +- 完成 `agentteam` 独立子模块骨架: + - `com.openmanus.agentteam.application` + - `com.openmanus.agentteam.domain` + - `com.openmanus.agentteam.infra` +- 完成第一版主链路: + - 主 agent 拆解任务 + - 创建 `TaskGroup` / `SubTask` + - 子任务提交到内存任务池 + - 多个 subagent 短轮询领取任务并执行 + - 主 agent 轮询等待并汇总结果 +- 明确第一版范围: + - 只支持 `fan-out / fan-in` + - 只支持无依赖并行子任务 + - 暂不支持 DAG + - 暂不支持角色系统 +- 完成 LLM 拆解能力: + - 拆解优先走 LLM 结构化输出 + - 失败时回退到规则拆解 + +## 当前状态 + +- AgentTeam 第一版 MVP 已基本打通 +- 当前可用于前后端联调与断点调试 + +## 后续建议 + +- 补前端可视化开关,不再硬编码 `agentTeam=true` +- 增强 mailbox 通信能力 +- 逐步评估 DAG、worktree 隔离、代码合并等后续能力 diff --git a/src/main/java/com/openmanus/infra/config/AgentArchitectureConfig.java b/src/main/java/com/openmanus/infra/config/AgentArchitectureConfig.java index 55b2cb0..447a3c0 100644 --- a/src/main/java/com/openmanus/infra/config/AgentArchitectureConfig.java +++ b/src/main/java/com/openmanus/infra/config/AgentArchitectureConfig.java @@ -15,7 +15,6 @@ import com.openmanus.aiframework.runtime.AiSearchConfig; import com.openmanus.aiframework.runtime.AiSessionSandboxGateway; import com.openmanus.aiframework.tool.AiRegisteredTool; -import com.openmanus.aiframework.tool.AiToolRegistry; import com.openmanus.aiframework.tool.mcp.McpToolRegistryBootstrap; import com.openmanus.domain.service.ExecutionEventPort; import com.openmanus.sandbox.support.SandboxPathResolver; @@ -83,6 +82,25 @@ public TaskReflectionTool taskReflectionTool() { return new TaskReflectionTool(); } + @Bean + public LocalAgentToolRegistry localAgentToolRegistry(BrowserTool browserTool, + PythonExecutionTool pythonExecutionTool, + SearchTool searchTool, + WebFetchTool webFetchTool, + ShellTool shellTool, + TaskReflectionTool taskReflectionTool, + OpenManusProperties properties) { + return new LocalAgentToolRegistry( + browserTool, + pythonExecutionTool, + searchTool, + webFetchTool, + shellTool, + taskReflectionTool, + properties.getChatMemory().isShellToolEnabled() + ); + } + @Bean public AgentExecutionService agentExecutionService(AgentCoordinator agentCoordinator, @Qualifier(AsyncConfig.ASYNC_EXECUTOR_NAME) Executor asyncExecutor) { @@ -99,9 +117,11 @@ AgentCoordinator agentCoordinator( SearchTool searchTool, WebFetchTool webFetchTool, ShellTool shellTool, - TaskReflectionTool taskReflectionTool) { + TaskReflectionTool taskReflectionTool, + LocalAgentToolRegistry localAgentToolRegistry) { return agentCoordinator(chatModel, chatMemoryProvider, properties, sessionSandboxGateway, null, null, - browserTool, pythonExecutionTool, searchTool, webFetchTool, shellTool, taskReflectionTool); + browserTool, pythonExecutionTool, searchTool, webFetchTool, shellTool, taskReflectionTool, + localAgentToolRegistry); } @Bean @@ -117,6 +137,7 @@ public AgentCoordinator agentCoordinator( ShellTool shellTool, TaskReflectionTool taskReflectionTool, Optional mcpToolRegistryBootstrap, + LocalAgentToolRegistry localAgentToolRegistry, ExecutionEventPort executionEventPort) { return agentCoordinator( chatModel, @@ -130,7 +151,8 @@ public AgentCoordinator agentCoordinator( searchTool, webFetchTool, shellTool, - taskReflectionTool + taskReflectionTool, + localAgentToolRegistry ); } @@ -145,7 +167,8 @@ public AgentCoordinator agentCoordinator( SearchTool searchTool, WebFetchTool webFetchTool, ShellTool shellTool, - TaskReflectionTool taskReflectionTool) { + TaskReflectionTool taskReflectionTool, + LocalAgentToolRegistry localAgentToolRegistry) { return agentCoordinator( chatModel, chatMemoryProvider, @@ -158,7 +181,8 @@ public AgentCoordinator agentCoordinator( searchTool, webFetchTool, shellTool, - taskReflectionTool + taskReflectionTool, + localAgentToolRegistry ); } @@ -174,7 +198,8 @@ AgentCoordinator agentCoordinator( SearchTool searchTool, WebFetchTool webFetchTool, ShellTool shellTool, - TaskReflectionTool taskReflectionTool) { + TaskReflectionTool taskReflectionTool, + LocalAgentToolRegistry localAgentToolRegistry) { AgentCoordinator.Builder builder = AgentCoordinator.builder() .aiChatModel(chatModel) .aiMemoryProvider(chatMemoryProvider) @@ -192,13 +217,11 @@ AgentCoordinator agentCoordinator( .toolResultBudgetPreviewHeadChars(properties.getChatMemory().getToolResultBudgetPreviewHeadChars()) .toolResultBudgetPreviewTailChars(properties.getChatMemory().getToolResultBudgetPreviewTailChars()) .toolResultBudgetDecayChars(properties.getChatMemory().getToolResultBudgetDecayChars()) - .executionEventPort(executionEventPort) - .browserTool(browserTool) - .pythonExecutionTool(pythonExecutionTool) - .toolFromObject(searchTool) - .toolFromObject(webFetchTool) - .shellTool(properties.getChatMemory().isShellToolEnabled() ? shellTool : null) - .taskReflectionTool(taskReflectionTool); + .executionEventPort(executionEventPort); + + for (AiRegisteredTool tool : localAgentToolRegistry.allLocalTools()) { + builder.tool(tool); + } registerMcpTools( builder, @@ -229,14 +252,14 @@ private static void registerMcpTools( return; } Map localToolRegistry = new LinkedHashMap<>(); - appendLocalTools(localToolRegistry, browserTool); - appendLocalTools(localToolRegistry, pythonExecutionTool); - appendLocalTools(localToolRegistry, searchTool); - appendLocalTools(localToolRegistry, webFetchTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, browserTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, pythonExecutionTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, searchTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, webFetchTool); if (properties.getChatMemory().isShellToolEnabled()) { - appendLocalTools(localToolRegistry, shellTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, shellTool); } - appendLocalTools(localToolRegistry, taskReflectionTool); + LocalAgentToolRegistry.appendLocalTools(localToolRegistry, taskReflectionTool); List mcpTools = bootstrap.discoverAndRegister(localToolRegistry); for (AiRegisteredTool mcpTool : mcpTools) { @@ -244,15 +267,4 @@ private static void registerMcpTools( } } - private static void appendLocalTools(Map targetRegistry, Object toolObject) { - if (toolObject == null) { - return; - } - for (AiRegisteredTool localTool : AiToolRegistry.scan(toolObject)) { - AiRegisteredTool existing = targetRegistry.putIfAbsent(localTool.name(), localTool); - if (existing != null) { - throw new IllegalStateException("Duplicate tool name detected: " + localTool.name()); - } - } - } } diff --git a/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java b/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java index 55f04b3..e9471ff 100644 --- a/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java +++ b/src/main/java/com/openmanus/infra/config/AgentTeamConfig.java @@ -3,9 +3,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.openmanus.agentteam.application.AgentTeamApplicationService; import com.openmanus.agentteam.application.AgentTeamPromptProvider; +import com.openmanus.agentteam.application.AgentTeamRoleExecutionPort; +import com.openmanus.agentteam.application.AgentTeamRoleExecutionService; import com.openmanus.agentteam.application.MasterAgentOrchestrator; +import com.openmanus.agentteam.application.SubAgentToolPolicy; import com.openmanus.agentteam.application.SubAgentExecutionService; import com.openmanus.agentteam.application.TaskDecompositionService; +import com.openmanus.agentteam.application.TeamMasterToolPolicy; import com.openmanus.agentteam.domain.port.AgentMessageBusPort; import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; import com.openmanus.agentteam.domain.port.TaskPoolPort; @@ -14,13 +18,17 @@ import com.openmanus.agentteam.domain.service.ResultAggregationService; import com.openmanus.agentteam.domain.service.TaskGroupManager; import com.openmanus.agentteam.domain.service.TaskGroupStatusCalculator; +import com.openmanus.agentteam.infra.AgentTeamCoordinatorFactory; import com.openmanus.agentteam.infra.ClasspathAgentTeamPromptProvider; import com.openmanus.agentteam.infra.InMemoryAgentMessageBus; import com.openmanus.agentteam.infra.InMemoryTaskGroupRepository; import com.openmanus.agentteam.infra.InMemoryTaskPool; import com.openmanus.agentteam.infra.SubAgentWorkerManager; import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.AiMemoryProvider; +import com.openmanus.aiframework.runtime.AiSessionSandboxGateway; import com.openmanus.domain.service.AgentExecutionPort; +import com.openmanus.domain.service.ExecutionEventPort; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -70,6 +78,16 @@ AgentTeamPromptProvider agentTeamPromptProvider() { return new ClasspathAgentTeamPromptProvider(); } + @Bean + TeamMasterToolPolicy teamMasterToolPolicy() { + return new TeamMasterToolPolicy(); + } + + @Bean + SubAgentToolPolicy subAgentToolPolicy() { + return new SubAgentToolPolicy(); + } + @Bean TaskDecompositionService taskDecompositionService( AiChatModel aiChatModel, @@ -79,12 +97,42 @@ TaskDecompositionService taskDecompositionService( return new TaskDecompositionService(aiChatModel, objectMapper, promptProvider); } + @Bean + AgentTeamCoordinatorFactory agentTeamCoordinatorFactory( + AiChatModel aiChatModel, + AiMemoryProvider aiMemoryProvider, + AiSessionSandboxGateway sessionSandboxGateway, + OpenManusProperties properties, + ExecutionEventPort executionEventPort, + LocalAgentToolRegistry localAgentToolRegistry, + AgentTeamPromptProvider promptProvider, + TeamMasterToolPolicy teamMasterToolPolicy, + SubAgentToolPolicy subAgentToolPolicy + ) { + return new AgentTeamCoordinatorFactory( + aiChatModel, + aiMemoryProvider, + sessionSandboxGateway, + properties, + executionEventPort, + localAgentToolRegistry, + promptProvider, + teamMasterToolPolicy, + subAgentToolPolicy + ); + } + + @Bean + AgentTeamRoleExecutionPort agentTeamRoleExecutionPort(AgentTeamCoordinatorFactory coordinatorFactory) { + return new AgentTeamRoleExecutionService(coordinatorFactory); + } + @Bean SubAgentExecutionService subAgentExecutionService( - AgentExecutionPort agentExecutionPort, + AgentTeamRoleExecutionPort roleExecutionPort, AgentTeamPromptProvider promptProvider ) { - return new SubAgentExecutionService(agentExecutionPort, promptProvider); + return new SubAgentExecutionService(roleExecutionPort, promptProvider); } @Bean(destroyMethod = "close") diff --git a/src/main/java/com/openmanus/infra/config/LocalAgentToolRegistry.java b/src/main/java/com/openmanus/infra/config/LocalAgentToolRegistry.java new file mode 100644 index 0000000..9d4eed0 --- /dev/null +++ b/src/main/java/com/openmanus/infra/config/LocalAgentToolRegistry.java @@ -0,0 +1,72 @@ +package com.openmanus.infra.config; + +import com.openmanus.agent.tool.BrowserTool; +import com.openmanus.agent.tool.PythonExecutionTool; +import com.openmanus.agent.tool.SearchTool; +import com.openmanus.agent.tool.ShellTool; +import com.openmanus.agent.tool.TaskReflectionTool; +import com.openmanus.agent.tool.WebFetchTool; +import com.openmanus.aiframework.tool.AiRegisteredTool; +import com.openmanus.aiframework.tool.AiToolRegistry; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Centralized local tool registry shared by default agent and agentteam. + */ +public class LocalAgentToolRegistry { + + private final BrowserTool browserTool; + private final PythonExecutionTool pythonExecutionTool; + private final SearchTool searchTool; + private final WebFetchTool webFetchTool; + private final ShellTool shellTool; + private final TaskReflectionTool taskReflectionTool; + private final boolean shellToolEnabled; + + public LocalAgentToolRegistry( + BrowserTool browserTool, + PythonExecutionTool pythonExecutionTool, + SearchTool searchTool, + WebFetchTool webFetchTool, + ShellTool shellTool, + TaskReflectionTool taskReflectionTool, + boolean shellToolEnabled + ) { + this.browserTool = browserTool; + this.pythonExecutionTool = pythonExecutionTool; + this.searchTool = searchTool; + this.webFetchTool = webFetchTool; + this.shellTool = shellTool; + this.taskReflectionTool = taskReflectionTool; + this.shellToolEnabled = shellToolEnabled; + } + + public List allLocalTools() { + Map registry = new LinkedHashMap<>(); + appendLocalTools(registry, browserTool); + appendLocalTools(registry, pythonExecutionTool); + appendLocalTools(registry, searchTool); + appendLocalTools(registry, webFetchTool); + if (shellToolEnabled) { + appendLocalTools(registry, shellTool); + } + appendLocalTools(registry, taskReflectionTool); + return new ArrayList<>(registry.values()); + } + + public static void appendLocalTools(Map targetRegistry, Object toolObject) { + if (toolObject == null) { + return; + } + for (AiRegisteredTool localTool : AiToolRegistry.scan(toolObject)) { + AiRegisteredTool existing = targetRegistry.putIfAbsent(localTool.name(), localTool); + if (existing != null) { + throw new IllegalStateException("Duplicate tool name detected: " + localTool.name()); + } + } + } +} diff --git a/src/main/resources/prompts/agentteam/subagent-execution.prompt.md b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md index 897e5bf..09fa35d 100644 --- a/src/main/resources/prompts/agentteam/subagent-execution.prompt.md +++ b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md @@ -1,6 +1,6 @@ You are a subagent executing one parallel subtask inside an agent team. -You do not have a special role in V1. Your capabilities and tools are the same as the main agent. +You are not the final coordinator. Work only inside the current subtask boundary. Context: - agentId: {{agentId}} @@ -15,6 +15,7 @@ Subtask description: Requirements: 1. Only work on this subtask. -2. Do not assume results from other subtasks. -3. If information is missing, explicitly state the blocker. -4. Start with a short conclusion, then provide the necessary details. +2. Do not decompose work again or delegate more tasks. +3. Do not assume results from other subtasks. +4. If information is missing, explicitly state the blocker. +5. Start with a short conclusion, then provide the necessary details and evidence. diff --git a/src/main/resources/prompts/agentteam/subagent-system.prompt.md b/src/main/resources/prompts/agentteam/subagent-system.prompt.md new file mode 100644 index 0000000..14fe324 --- /dev/null +++ b/src/main/resources/prompts/agentteam/subagent-system.prompt.md @@ -0,0 +1,10 @@ +You are a SubAgent inside an agent team. + +You are an execution worker, not the final coordinator. + +Rules: +1. Only work on the currently assigned subtask. +2. Do not decompose work again or delegate more subtasks. +3. Do not assume access to global team state beyond the assigned task prompt. +4. Return results that are easy for the Team Master to aggregate. +5. If blocked, clearly state the blocker and the evidence you gathered. diff --git a/src/main/resources/prompts/agentteam/team-master-system.prompt.md b/src/main/resources/prompts/agentteam/team-master-system.prompt.md new file mode 100644 index 0000000..d7ed2b6 --- /dev/null +++ b/src/main/resources/prompts/agentteam/team-master-system.prompt.md @@ -0,0 +1,10 @@ +You are the Team Master inside an agent team. + +Your primary job is to coordinate work, not to personally execute every detail. + +Rules: +1. Decompose work only when subtasks are independent and can run in parallel. +2. Focus on planning, delegation, tracking, and aggregation. +3. Keep the final user-facing answer responsibility at the Team Master level. +4. Use direct tools only when a small supporting action is clearly cheaper than delegation. +5. Do not behave like a generic all-purpose worker by default. diff --git a/src/test/java/com/openmanus/agentteam/application/AgentTeamPromptProviderTest.java b/src/test/java/com/openmanus/agentteam/application/AgentTeamPromptProviderTest.java new file mode 100644 index 0000000..7a9bcfb --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/AgentTeamPromptProviderTest.java @@ -0,0 +1,22 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.agentteam.infra.ClasspathAgentTeamPromptProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AgentTeamPromptProvider Tests") +class AgentTeamPromptProviderTest { + + private final AgentTeamPromptProvider promptProvider = new ClasspathAgentTeamPromptProvider(); + + @Test + @DisplayName("should load all agentteam prompt templates") + void shouldLoadAllAgentTeamPromptTemplates() { + assertThat(promptProvider.taskDecompositionPromptTemplate()).isNotBlank(); + assertThat(promptProvider.teamMasterSystemPromptTemplate()).contains("Team Master"); + assertThat(promptProvider.subAgentSystemPromptTemplate()).contains("SubAgent"); + assertThat(promptProvider.subAgentExecutionPromptTemplate()).contains("subtask"); + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java index cc4720f..548a1b8 100644 --- a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java +++ b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java @@ -36,6 +36,16 @@ public String taskDecompositionPromptTemplate() { return "Decompose this request into parallel subtasks:\n{{userInput}}\nlimit={{maxSubTasks}}"; } + @Override + public String teamMasterSystemPromptTemplate() { + return "You are the team master"; + } + + @Override + public String subAgentSystemPromptTemplate() { + return "You are the subagent"; + } + @Override public String subAgentExecutionPromptTemplate() { return """ @@ -48,6 +58,12 @@ public String subAgentExecutionPromptTemplate() { } }; + private SubAgentExecutionService subAgentExecutionService(AgentExecutionPort agentExecutionPort) { + AgentTeamRoleExecutionPort roleExecutionPort = (role, input, conversationId) -> + agentExecutionPort.executeSync(input, conversationId); + return new SubAgentExecutionService(roleExecutionPort, promptProvider); + } + @Test @DisplayName("should fall back to single agent when request is not safely parallelizable") void shouldFallBackToSingleAgentWhenRequestIsNotSafelyParallelizable() { @@ -64,7 +80,7 @@ void shouldFallBackToSingleAgentWhenRequestIsNotSafelyParallelizable() { 10L, taskPool, new InMemoryAgentMessageBus(), - new SubAgentExecutionService(agentExecutionPort, promptProvider) + subAgentExecutionService(agentExecutionPort) ); try { @@ -131,7 +147,7 @@ void shouldExecuteParallelSubTasksThroughSharedAgentExecutionPort() { 10L, taskPool, new InMemoryAgentMessageBus(), - new SubAgentExecutionService(agentExecutionPort, promptProvider) + subAgentExecutionService(agentExecutionPort) ); try { @@ -203,7 +219,7 @@ void shouldReportPartialFailureWhenOneSubTaskExecutionFails() { 10L, taskPool, new InMemoryAgentMessageBus(), - new SubAgentExecutionService(agentExecutionPort, promptProvider) + subAgentExecutionService(agentExecutionPort) ); try { diff --git a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java new file mode 100644 index 0000000..4f8c78a --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java @@ -0,0 +1,68 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.agentteam.domain.model.SubTask; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SubAgentExecutionIsolation Tests") +class SubAgentExecutionIsolationTest { + + @Test + @DisplayName("should execute subtask through subagent role port") + void shouldExecuteSubTaskThroughSubAgentRolePort() { + RecordingRoleExecutionPort roleExecutionPort = new RecordingRoleExecutionPort("done"); + AgentTeamPromptProvider promptProvider = new AgentTeamPromptProvider() { + @Override + public String taskDecompositionPromptTemplate() { + return "unused"; + } + + @Override + public String teamMasterSystemPromptTemplate() { + return "unused"; + } + + @Override + public String subAgentSystemPromptTemplate() { + return "unused"; + } + + @Override + public String subAgentExecutionPromptTemplate() { + return "task={{taskDescription}}"; + } + }; + SubAgentExecutionService service = new SubAgentExecutionService(roleExecutionPort, promptProvider); + SubTask subTask = new SubTask("task-1", "group-1", "API", "Collect API requirements", System.currentTimeMillis()); + + SubTaskExecutionOutput output = service.execute(subTask, "agent-1"); + + assertThat(output.summary()).isEqualTo("done"); + assertThat(output.detail()).isEqualTo("done"); + assertThat(roleExecutionPort.role).isEqualTo(AgentTeamRole.SUB_AGENT); + assertThat(roleExecutionPort.input).isEqualTo("task=Collect API requirements"); + assertThat(roleExecutionPort.conversationId).isEqualTo("task-1"); + } + + private static final class RecordingRoleExecutionPort implements AgentTeamRoleExecutionPort { + + private final String result; + private AgentTeamRole role; + private String input; + private String conversationId; + + private RecordingRoleExecutionPort(String result) { + this.result = result; + } + + @Override + public String executeSync(AgentTeamRole role, String input, String conversationId) { + this.role = role; + this.input = input; + this.conversationId = conversationId; + return result; + } + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/SubAgentToolPolicyTest.java b/src/test/java/com/openmanus/agentteam/application/SubAgentToolPolicyTest.java new file mode 100644 index 0000000..f667fa9 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/SubAgentToolPolicyTest.java @@ -0,0 +1,40 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.aiframework.runtime.model.AiAgentParameterSchema; +import com.openmanus.aiframework.tool.AiRegisteredTool; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SubAgentToolPolicy Tests") +class SubAgentToolPolicyTest { + + @Test + @DisplayName("should keep only subagent allowed tools") + void shouldKeepOnlySubAgentAllowedTools() { + SubAgentToolPolicy policy = new SubAgentToolPolicy(); + + List selected = policy.selectTools(List.of( + tool("search_web"), + tool("browser_fetch_web"), + tool("runShellCommand"), + tool("custom_mcp_tool") + )); + + assertThat(selected) + .extracting(AiRegisteredTool::name) + .containsExactly("search_web", "browser_fetch_web", "runShellCommand"); + } + + private AiRegisteredTool tool(String name) { + return new AiRegisteredTool( + name, + name, + AiAgentParameterSchema.singleStringParameter("input", "input"), + (request, memoryId) -> "{}" + ); + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java index 69fac27..d9b7885 100644 --- a/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java +++ b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java @@ -22,6 +22,16 @@ public String taskDecompositionPromptTemplate() { return "Decompose this request into parallel subtasks:\n{{userInput}}\nlimit={{maxSubTasks}}"; } + @Override + public String teamMasterSystemPromptTemplate() { + return "unused"; + } + + @Override + public String subAgentSystemPromptTemplate() { + return "unused"; + } + @Override public String subAgentExecutionPromptTemplate() { return "unused"; diff --git a/src/test/java/com/openmanus/agentteam/application/TeamMasterToolPolicyTest.java b/src/test/java/com/openmanus/agentteam/application/TeamMasterToolPolicyTest.java new file mode 100644 index 0000000..2791571 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/TeamMasterToolPolicyTest.java @@ -0,0 +1,40 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.aiframework.runtime.model.AiAgentParameterSchema; +import com.openmanus.aiframework.tool.AiRegisteredTool; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TeamMasterToolPolicy Tests") +class TeamMasterToolPolicyTest { + + @Test + @DisplayName("should keep only coordinator support tools") + void shouldKeepOnlyCoordinatorSupportTools() { + TeamMasterToolPolicy policy = new TeamMasterToolPolicy(); + + List selected = policy.selectTools(List.of( + tool("search_web"), + tool("browser_fetch_web"), + tool("runShellCommand"), + tool("executePython") + )); + + assertThat(selected) + .extracting(AiRegisteredTool::name) + .containsExactly("search_web", "browser_fetch_web", "runShellCommand"); + } + + private AiRegisteredTool tool(String name) { + return new AiRegisteredTool( + name, + name, + AiAgentParameterSchema.singleStringParameter("input", "input"), + (request, memoryId) -> "{}" + ); + } +} diff --git a/src/test/java/com/openmanus/infra/config/SingleAgentRegressionTest.java b/src/test/java/com/openmanus/infra/config/SingleAgentRegressionTest.java new file mode 100644 index 0000000..d3a2c59 --- /dev/null +++ b/src/test/java/com/openmanus/infra/config/SingleAgentRegressionTest.java @@ -0,0 +1,126 @@ +package com.openmanus.infra.config; + +import com.openmanus.agent.coordination.AgentCoordinator; +import com.openmanus.aiframework.runtime.AiChatModel; +import com.openmanus.aiframework.runtime.AiMemory; +import com.openmanus.aiframework.runtime.AiMemoryProvider; +import com.openmanus.aiframework.runtime.model.AiChatMessage; +import com.openmanus.aiframework.runtime.model.AiChatRequest; +import com.openmanus.aiframework.runtime.model.AiChatResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SingleAgentRegression Tests") +class SingleAgentRegressionTest { + + @Test + @DisplayName("default single-agent coordinator should still use the built-in system prompt") + void defaultSingleAgentCoordinatorShouldStillUseBuiltInSystemPrompt() { + CapturingChatModel chatModel = new CapturingChatModel("single-agent-result"); + AgentCoordinator coordinator = AgentCoordinator.builder() + .aiChatModel(chatModel) + .aiMemoryProvider(new InMemoryProvider()) + .build(); + + String result = coordinator.execute("hello", "memory-1"); + + assertThat(result).isEqualTo("single-agent-result"); + assertThat(chatModel.lastRequest()).isNotNull(); + assertThat(chatModel.lastRequest().messages()).isNotEmpty(); + assertThat(chatModel.lastRequest().messages().getFirst().role()).isEqualTo(AiChatMessage.Role.SYSTEM); + assertThat(chatModel.lastRequest().messages().getFirst().content()).contains("OpenManus"); + } + + @Test + @DisplayName("custom role prompt should not replace the default single-agent system prompt globally") + void customRolePromptShouldNotReplaceTheDefaultSingleAgentSystemPromptGlobally() { + CapturingChatModel defaultChatModel = new CapturingChatModel("default-result"); + AgentCoordinator defaultCoordinator = AgentCoordinator.builder() + .aiChatModel(defaultChatModel) + .aiMemoryProvider(new InMemoryProvider()) + .build(); + + CapturingChatModel customChatModel = new CapturingChatModel("custom-result"); + AgentCoordinator customCoordinator = AgentCoordinator.builder() + .aiChatModel(customChatModel) + .aiMemoryProvider(new InMemoryProvider()) + .systemMessage("You are a sub agent") + .build(); + + defaultCoordinator.execute("default", "memory-default"); + customCoordinator.execute("custom", "memory-custom"); + + assertThat(defaultChatModel.lastRequest().messages().getFirst().content()).contains("OpenManus"); + assertThat(customChatModel.lastRequest().messages().getFirst().content()).isEqualTo("You are a sub agent"); + } + + private static final class CapturingChatModel implements AiChatModel { + + private final String assistantResult; + private AiChatRequest lastRequest; + + private CapturingChatModel(String assistantResult) { + this.assistantResult = assistantResult; + } + + @Override + public AiChatResponse chat(AiChatRequest request) { + this.lastRequest = request; + return new AiChatResponse( + AiChatMessage.assistant(assistantResult), + null, + null, + null, + null, + null + ); + } + + private AiChatRequest lastRequest() { + return lastRequest; + } + } + + private static final class InMemoryProvider implements AiMemoryProvider { + + @Override + public AiMemory get(Object memoryId) { + return new SimpleMemory(memoryId); + } + } + + private static final class SimpleMemory implements AiMemory { + + private final Object id; + private final List messages = new ArrayList<>(); + + private SimpleMemory(Object id) { + this.id = id; + } + + @Override + public Object id() { + return id; + } + + @Override + public List messages() { + return messages; + } + + @Override + public void add(AiChatMessage message) { + messages.add(message); + } + + @Override + public void clear() { + messages.clear(); + } + } +} From 5d9e15ee4f44270c498509099ebd868e5f5d2739 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Thu, 28 May 2026 17:35:00 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" | 563 ++++++++++++++++++ .../AGENT_TEAM_DEVELOPMENT_PLAN.md" | 0 .../AGENT_TEAM_ISOLATION_PLAN.md" | 0 3 files changed, 563 insertions(+) create mode 100644 "src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" rename src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md => "src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_DEVELOPMENT_PLAN.md" (100%) rename src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md => "src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_ISOLATION_PLAN.md" (100%) diff --git "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" new file mode 100644 index 0000000..d909219 --- /dev/null +++ "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" @@ -0,0 +1,563 @@ +# Agent Team 上下文隔离开发方案 + +本文档用于规划 `agentteam` 在完成提示词隔离、工具隔离之后的下一阶段工作:上下文隔离。 + +当前目标不是一次性复刻 Claude Code 的完整 Subagent 机制,而是在现有 `agentteam` 最小协作链路上,逐步补齐父 Agent、Team Master、SubAgent 之间的运行时边界。 + +## 1. 背景 + +当前 `agentteam` 已经具备以下基础能力: + +1. Team Master 可以判断任务是否适合拆分。 +2. Team Master 可以生成多个 `SubTask`。 +3. 多个 `SubAgentWorker` 可以从任务池中领取并执行子任务。 +4. SubAgent 已经具备独立的系统提示词。 +5. Team Master 和 SubAgent 已经具备不同的工具策略。 +6. 主流程可以聚合子任务成功和失败结果。 + +但当前上下文边界仍然不够显式: + +1. SubAgent 的聊天记忆隔离主要依赖 `taskId`,缺少统一的 memoryId 生成规则。 +2. SubAgent 执行时缺少显式的 `parentSessionId/groupId/taskId/agentId/depth` 运行身份。 +3. 子任务描述中尚未稳定承载 Team Master 下发的必要背景上下文。 +4. 任务状态写回缺少 agentId 级别的所有权校验。 +5. 消息总线已有雏形,但 mailbox 消息尚未真正注入 SubAgent 执行上下文。 + +因此下一阶段应先完成上下文隔离的最小闭环,再逐步增强消息驱动通信和恢复能力。 + +## 2. 目标 + +本阶段目标是让每个 SubAgent 成为一个具有清晰运行边界的执行单元: + +1. SubAgent 不读取父 Agent 的完整聊天历史。 +2. SubAgent 使用独立的聊天 memoryId。 +3. SubAgent 只接收 Team Master 下发的当前子任务和必要上下文切片。 +4. SubAgent 只能更新自己领取的任务状态。 +5. Team Master 保留任务组视图和结果聚合职责。 +6. 普通单 Agent 主链路不受影响。 + +完成后应满足: + +- 父 Agent 的 memory 与 SubAgent memory 分离。 +- SubAgent 的任务上下文由后端明确注入,而不是由 SubAgent 自行读取父上下文。 +- 任务状态读写有明确归属,避免多个 worker 互相污染。 +- 后续可以平滑演进到消息驱动通信、失败重试、恢复执行。 + +## 3. 非目标 + +本阶段不做以下内容: + +1. 不实现 Fork Subagent 或 prompt cache 优化。 +2. 不实现 completed SubAgent 自动唤醒。 +3. 不实现完整事件总线。 +4. 不实现跨进程或分布式 Agent Team。 +5. 不引入复杂 RBAC 权限系统。 +6. 不让 SubAgent 直接读取父 Agent 完整聊天历史。 +7. 不让 SubAgent 再次创建新的 SubAgent。 +8. 不把上下文隔离逻辑下沉为 `aiframework` 通用框架能力。 + +## 4. 上下文分类与隔离策略 + +### 4.1 聊天上下文 + +含义: + +- system message +- user message +- assistant message +- tool call message +- tool result message +- 当前 memoryId 对应的历史消息 + +隔离策略: + +- 父 Agent 使用父会话 memoryId。 +- SubAgent 使用独立 memoryId。 +- SubAgent 不直接读取父 Agent memory。 +- 如需父任务背景,由 Team Master 生成 summary 后写入 `SubTask`。 + +建议 memoryId 格式: + +```text +agentteam:{parentSessionId}:{groupId}:{taskId}:{agentId} +``` + +第一版可以先不做复杂转义,仅保证格式稳定。后续如 sessionId 中存在特殊字符,再补充 sanitize。 + +### 4.2 任务上下文 + +含义: + +- `TaskGroup` +- `SubTask` +- `TaskStatus` +- `claimedBy` +- 子任务标题、描述、必要背景 +- 子任务结果、失败原因 + +隔离策略: + +- Team Master 拥有 TaskGroup 全局视图。 +- SubAgent 只接收当前 `SubTask` 的任务切片。 +- `SubTask` 中应包含 Team Master 下发的 `contextSummary`。 +- Worker 从 `TaskPoolPort` claim 子任务。 +- 任务成功/失败写回时必须校验 `agentId` 与任务领取者一致。 + +### 4.3 工具上下文 + +含义: + +- 当前角色可用工具列表 +- 工具 schema +- 工具执行结果 + +隔离策略: + +- 继续沿用 `TeamMasterToolPolicy` 与 `SubAgentToolPolicy`。 +- SubAgent 不拥有调度类工具。 +- SubAgent 工具调用历史保留在自己的 memory 中。 +- Team Master 只消费子任务汇总结果,不消费 SubAgent 完整工具历史。 + +### 4.4 执行状态上下文 + +含义: + +- ReAct/CodeAct 循环中的 plan +- inProgress +- todo +- lastFailure +- pending tool results +- 重复工具调用检测状态 + +隔离策略: + +- 第一版依赖每次 `AgentCoordinator.execute` 的局部状态自然隔离。 +- 通过独立 SubAgent memoryId,避免执行状态卡片进入父 Agent 历史。 +- 后续如要 resume,再把执行状态纳入 `AgentTeamExecutionContext` 或独立状态仓储。 + +### 4.5 环境上下文 + +含义: + +- workspace +- sandbox +- session sandbox +- 当前项目目录 +- shell/browser/python 执行环境 + +隔离策略: + +- 第一版允许 SubAgent 共享父 session 的 workspace/sandbox。 +- 共享环境不等于共享聊天 memory。 +- `parentSessionId` 用于环境归属。 +- `memoryId` 用于聊天历史归属。 + +### 4.6 身份上下文 + +含义: + +- role +- parentSessionId +- groupId +- taskId +- agentId +- depth +- memoryId + +隔离策略: + +- 新增显式 `AgentTeamExecutionContext`。 +- 每次 Team Master 或 SubAgent 执行都构造上下文对象。 +- 第一版 `depth` 对 SubAgent 固定为 `1`。 +- 后续用于防递归、日志追踪、事件 metadata、消息路由。 + +### 4.7 消息上下文 + +含义: + +- `AgentMessage` +- mailbox unread/read 状态 +- fromAgentId/toAgentId +- message type +- content + +隔离策略: + +- 当前阶段保留 message bus 雏形。 +- 第一版上下文隔离不强制实现动态消息注入。 +- 后续增强时,Worker 拉取 mailbox 后应把消息渲染为 SubAgent prompt 的一部分,而不是只标记已读。 + +### 4.8 结果上下文 + +含义: + +- summary +- detail +- evidence +- errorMessage +- status + +隔离策略: + +- SubAgent 输出结构化结果。 +- Team Master 只读取 `SubTaskExecutionOutput` / `TaskGroupResult`。 +- Team Master 不直接读取 SubAgent 完整 memory 或完整工具调用轨迹。 + +## 5. 分阶段开发计划 + +## 阶段 A:聊天上下文 memoryId 隔离 + +目标: + +- SubAgent 使用明确的独立 memoryId。 +- 不再直接使用裸 `taskId` 作为 SubAgent conversationId。 + +建议改动: + +1. 新增 `AgentTeamMemoryIds`。 +2. 提供 SubAgent memoryId 生成方法。 +3. 修改 `SubAgentExecutionService`,调用 role runtime 时使用生成后的 memoryId。 + +建议类: + +```text +src/main/java/com/openmanus/agentteam/application/AgentTeamMemoryIds.java +``` + +建议 API: + +```java +public final class AgentTeamMemoryIds { + + public static String subAgent(String parentSessionId, String groupId, String taskId, String agentId) { + return String.join(":", + "agentteam", + safe(parentSessionId), + safe(groupId), + safe(taskId), + safe(agentId)); + } +} +``` + +验收标准: + +1. SubAgent memoryId 以 `agentteam:` 开头。 +2. SubAgent memoryId 包含 `groupId/taskId/agentId`。 +3. SubAgent memoryId 不等于父 `conversationId`。 +4. 现有单 Agent 回归测试不受影响。 + +建议测试: + +- `SubAgentExecutionIsolationTest` +- 新增 `AgentTeamMemoryIdsTest` + +## 阶段 B:任务上下文切片 + +目标: + +- SubTask 显式携带 Team Master 下发的必要背景。 +- SubAgent prompt 只注入当前子任务切片。 + +建议改动: + +1. 为 `SubTaskPlan` 增加 `contextSummary`。 +2. 为 `SubTask` 增加 `contextSummary`。 +3. 调整 `TaskDecompositionService` 的解析与兜底逻辑。 +4. 调整 `subagent-execution.prompt.md`,注入 `contextSummary`。 +5. 调整 `SubAgentExecutionService.buildSubTaskPrompt`。 + +`contextSummary` 第一版来源: + +- 优先使用模型拆解 JSON 中每个子任务的 `contextSummary`。 +- 如果缺失,则使用父任务输入生成简单兜底背景。 + +兜底示例: + +```text +Parent request: +{{userInput}} + +Team instruction: +Complete only this assigned subtask. Return concise evidence and result for Team Master aggregation. +``` + +验收标准: + +1. SubAgent prompt 包含当前任务的 `title/description/contextSummary`。 +2. SubAgent prompt 不包含其他 SubTask 的 description。 +3. 当 LLM 拆解结果缺少 `contextSummary` 时,系统能生成兜底上下文。 + +建议测试: + +- `TaskDecompositionServiceTest` +- `SubAgentExecutionIsolationTest` +- `MasterAgentOrchestratorTest` + +## 阶段 C:显式执行上下文 + +目标: + +- 将 role、parentSessionId、groupId、taskId、agentId、depth、memoryId 收束到统一对象。 +- 为后续日志、事件、消息、恢复提供稳定入口。 + +建议新增: + +```text +src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionContext.java +``` + +建议结构: + +```java +public record AgentTeamExecutionContext( + AgentTeamRole role, + String parentSessionId, + String groupId, + String taskId, + String agentId, + int depth, + String memoryId +) { + + public static AgentTeamExecutionContext subAgent( + String parentSessionId, + String groupId, + String taskId, + String agentId + ) { + return new AgentTeamExecutionContext( + AgentTeamRole.SUB_AGENT, + parentSessionId, + groupId, + taskId, + agentId, + 1, + AgentTeamMemoryIds.subAgent(parentSessionId, groupId, taskId, agentId) + ); + } +} +``` + +建议改动: + +1. 调整 `AgentTeamRoleExecutionPort`,新增基于 context 的执行方法。 +2. 调整 `AgentTeamRoleExecutionService`,从 context 读取 role 和 memoryId。 +3. 调整 `SubAgentExecutionService`,使用 context 构造 prompt 和执行请求。 +4. 在 MDC 或日志中补充 context 字段。 + +验收标准: + +1. SubAgent 执行入口不再只传 `AgentTeamRole + conversationId`。 +2. 测试中可以断言 SubAgent context 的 role、memoryId、agentId。 +3. 日志中能追踪 parentSessionId、groupId、taskId、agentId。 + +建议测试: + +- 新增 `AgentTeamExecutionContextTest` +- 调整 `SubAgentExecutionIsolationTest` +- 调整 `AgentTeamRoleExecutionServiceTest` 或补充现有测试 + +## 阶段 D:任务状态写入所有权校验 + +目标: + +- SubAgent 只能写回自己 claim 的任务。 +- 避免多个 worker 或异常路径污染其他任务状态。 + +建议改动: + +1. 调整 `TaskPoolPort`: + +```java +void markSucceeded(String taskId, String agentId, String summary, String detail); + +void markFailed(String taskId, String agentId, String errorMessage); +``` + +2. 调整 `InMemoryTaskPool` 实现。 +3. 在写回前校验任务当前领取者。 +4. 调整 `SubAgentWorker.executeClaimedTask` 调用。 + +建议规则: + +```text +PENDING 任务不能直接 markSucceeded/markFailed。 +未被当前 agentId claim 的任务不能被当前 agentId 写回。 +已完成任务不能再次写回,除非后续明确引入 retry/reopen。 +``` + +验收标准: + +1. 正常领取任务的 worker 可以写回成功/失败。 +2. 非领取者写回任务会失败。 +3. 未领取任务不能直接写回成功。 +4. 现有并行任务测试仍通过。 + +建议测试: + +- `InMemoryTaskPoolTest` +- `MasterAgentOrchestratorTest` + +## 阶段 E:结果上下文结构化 + +目标: + +- SubAgent 输出更适合 Team Master 汇总。 +- 减少父流程读取 SubAgent 完整执行历史的需求。 + +建议改动: + +1. 扩展 `SubTaskExecutionOutput`。 +2. 在 SubAgent prompt 中约束输出格式。 +3. 在 `SubAgentExecutionService` 中做最小解析或保守降级。 +4. `DefaultResultAggregationService` 优先消费结构化字段。 + +可选结构: + +```java +public record SubTaskExecutionOutput( + String summary, + String detail, + List evidence, + String errorMessage +) { +} +``` + +第一版可保留兼容: + +- 如果无法解析结构化输出,则继续使用原始文本作为 `detail`。 +- `summary` 仍使用当前摘要逻辑兜底。 + +验收标准: + +1. SubAgent 结果包含 summary。 +2. 可选 evidence 能被聚合服务展示。 +3. 解析失败不影响主链路完成。 + +## 阶段 F:消息上下文注入 + +目标: + +- 让现有 `AgentMessageBusPort` 从“只存消息”演进为“可影响 SubAgent 执行”的消息机制。 + +建议改动: + +1. 调整 `SubAgentWorker.drainMailbox`,返回 unread messages。 +2. 将 unread messages 传给 `SubAgentExecutionService`。 +3. 在 `buildSubTaskPrompt` 中注入 mailbox context。 +4. 只在成功注入后标记消息已读,避免消息丢失。 + +建议 prompt 片段: + +```text +[Messages From Team Master] +- {{message}} +[/Messages From Team Master] +``` + +验收标准: + +1. 发给某个 agentId 的消息只被该 agent 消费。 +2. unread 消息能出现在 SubAgent prompt。 +3. 消息注入后被标记为已读。 +4. 无消息时不影响当前执行链路。 + +建议测试: + +- `InMemoryAgentMessageBusTest` +- 新增 `SubAgentMailboxContextTest` + +## 阶段 G:可观测性与文档收口 + +目标: + +- 让上下文隔离行为可测试、可排查、可说明。 + +建议改动: + +1. 日志中输出 context 摘要: + - role + - parentSessionId + - groupId + - taskId + - agentId + - memoryId +2. execution event metadata 中补充 context 字段。 +3. 更新 `docs/DEVELOPMENT_PROGRESS.md`。 +4. 补充上下文隔离测试说明。 + +验收标准: + +1. compile 通过。 +2. 默认 test 通过。 +3. 上下文隔离相关测试覆盖: + - memoryId 隔离 + - task prompt 切片 + - task ownership 校验 + - mailbox 注入 + +## 6. 推荐实施顺序 + +建议按以下顺序推进: + +1. 阶段 A:聊天 memoryId 隔离。 +2. 阶段 B:任务上下文切片。 +3. 阶段 C:显式执行上下文。 +4. 阶段 D:任务状态写入所有权校验。 +5. 阶段 E:结果上下文结构化。 +6. 阶段 F:消息上下文注入。 +7. 阶段 G:可观测性与文档收口。 + +其中 A-D 是本轮上下文隔离 MVP 的核心。E-G 可以在 MVP 稳定后继续增强。 + +## 7. 第一版建议验收口径 + +功能验收: + +1. Team Master 拆分出的每个 SubTask 都带有必要背景上下文。 +2. SubAgent 执行时使用独立 memoryId。 +3. SubAgent prompt 只包含当前子任务切片。 +4. SubAgent 成功/失败只能写回自己领取的任务。 +5. Team Master 可以聚合多个 SubAgent 的结果并返回最终输出。 + +测试验收: + +1. `./scripts/mvnw-local.sh -q -DskipTests compile` +2. `./scripts/mvnw-local.sh -q -DskipITs test` +3. 新增上下文隔离相关测试通过。 + +架构验收: + +1. 上下文隔离逻辑收口在 `agentteam` 模块。 +2. 不污染普通单 Agent 默认主链路。 +3. 不把 SubAgent 的完整聊天历史塞回父 Agent memory。 +4. 不为了未来扩展提前引入复杂调度框架。 + +## 8. 后续增强方向 + +当上下文隔离 MVP 稳定后,再评估: + +1. SubAgent resume。 +2. completed SubAgent 唤醒。 +3. XML 或结构化 task notification。 +4. 更细粒度的上下文摘要器。 +5. 长任务 auto-background。 +6. prompt cache / Fork Subagent 优化。 +7. 持久化 TaskPool / MessageBus。 + +## 9. 当前结论 + +提示词隔离和工具隔离完成之后,下一步不应继续堆叠更多 Team 能力,而应优先补齐上下文隔离。 + +本阶段最小闭环是: + +```text +SubAgent 独立 memoryId ++ SubTask contextSummary ++ AgentTeamExecutionContext ++ task ownership 校验 +``` + +这四项完成后,`agentteam` 才真正具备清晰的父子 Agent 运行边界。后续再做消息驱动通信、失败恢复、自动后台化,会有稳定落点。 diff --git a/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md "b/src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_DEVELOPMENT_PLAN.md" similarity index 100% rename from src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_DEVELOPMENT_PLAN.md rename to "src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_DEVELOPMENT_PLAN.md" diff --git a/src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md "b/src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_ISOLATION_PLAN.md" similarity index 100% rename from src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_ISOLATION_PLAN.md rename to "src/main/java/com/openmanus/agentteam/docs/\346\217\220\347\244\272\350\257\215\344\270\216\345\267\245\345\205\267\351\232\224\347\246\273/AGENT_TEAM_ISOLATION_PLAN.md" From 904b967f01cfaba8364eee2e5804b03750be9765 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Fri, 29 May 2026 22:44:29 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9subagent?= =?UTF-8?q?=E7=9A=84=E4=BC=9A=E8=AF=9D=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AgentTeamMemoryIds.java | 29 +++++++++++++++++++ .../AgentTeamRoleExecutionPort.java | 2 +- .../AgentTeamRoleExecutionService.java | 10 +++---- .../application/MasterAgentOrchestrator.java | 5 ++-- .../application/SubAgentExecutionService.java | 16 +++++++--- .../agentteam/domain/model/SubTask.java | 11 ++++++- .../application/AgentTeamMemoryIdsTest.java | 28 ++++++++++++++++++ .../MasterAgentOrchestratorTest.java | 4 +-- .../SubAgentExecutionIsolationTest.java | 18 ++++++++---- .../DefaultResultAggregationServiceTest.java | 8 ++--- .../service/DefaultTaskGroupManagerTest.java | 4 +-- .../TaskGroupStatusCalculatorTest.java | 12 ++++---- .../agentteam/infra/InMemoryTaskPoolTest.java | 4 +-- 13 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamMemoryIds.java create mode 100644 src/test/java/com/openmanus/agentteam/application/AgentTeamMemoryIdsTest.java diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamMemoryIds.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamMemoryIds.java new file mode 100644 index 0000000..eb775e1 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamMemoryIds.java @@ -0,0 +1,29 @@ +package com.openmanus.agentteam.application; + +/** + * Stable memory-id factory for agent-team scoped runtimes. + */ +public final class AgentTeamMemoryIds { + + private static final String PREFIX = "agentteam"; + private static final String BLANK_SEGMENT = "_"; + + private AgentTeamMemoryIds() { + } + + public static String subAgent(String parentSessionId, String groupId, String taskId, String agentId) { + return String.join(":", + PREFIX, + segment(parentSessionId), + segment(groupId), + segment(taskId), + segment(agentId)); + } + + private static String segment(String value) { + if (value == null || value.isBlank()) { + return BLANK_SEGMENT; + } + return value.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java index 6076cd2..59e7599 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java @@ -5,5 +5,5 @@ */ public interface AgentTeamRoleExecutionPort { - String executeSync(AgentTeamRole role, String input, String conversationId); + String executeSync(AgentTeamRole role, String input, String memoryId); } diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java index dbfc75d..9f3d7f5 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java @@ -18,16 +18,16 @@ public AgentTeamRoleExecutionService(AgentTeamCoordinatorFactory coordinatorFact } @Override - public String executeSync(AgentTeamRole role, String input, String conversationId) { + public String executeSync(AgentTeamRole role, String input, String memoryId) { if (input == null || input.isBlank()) { throw new IllegalArgumentException("input cannot be null or blank"); } - Object memoryId = conversationId != null && !conversationId.isBlank() - ? conversationId + Object runtimeMemoryId = memoryId != null && !memoryId.isBlank() + ? memoryId : UUID.randomUUID(); - try (MDC.MDCCloseable ignored = MDC.putCloseable("sessionId", String.valueOf(memoryId))) { + try (MDC.MDCCloseable ignored = MDC.putCloseable("sessionId", String.valueOf(runtimeMemoryId))) { AgentCoordinator coordinator = coordinatorFactory.create(role); - return coordinator.execute(input, memoryId); + return coordinator.execute(input, runtimeMemoryId); } } } diff --git a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java index 8be2dd6..798a0b0 100644 --- a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java +++ b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java @@ -70,7 +70,7 @@ public String execute(String userInput, String conversationId) { "team-master", userInput ); - List subTasks = materializeSubTasks(taskGroup.getGroupId(), plan.subTasks()); + List subTasks = materializeSubTasks(taskGroup.getGroupId(), taskGroup.getParentTaskId(), plan.subTasks()); log.info( "TeamMaster created task group: groupId={}, conversationId={}, subTaskCount={}", taskGroup.getGroupId(), @@ -103,13 +103,14 @@ public String execute(String userInput, String conversationId) { return renderFinalAnswer(taskGroup, snapshot, result); } - private List materializeSubTasks(String groupId, List subTaskPlans) { + private List materializeSubTasks(String groupId, String parentSessionId, List subTaskPlans) { List subTasks = new ArrayList<>(); long now = System.currentTimeMillis(); for (SubTaskPlan plan : subTaskPlans) { subTasks.add(new SubTask( UUID.randomUUID().toString(), groupId, + parentSessionId, plan.title(), plan.description(), now diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java index 8498b76..c81dc1a 100644 --- a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java @@ -27,21 +27,29 @@ public SubAgentExecutionService( public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { String prompt = buildSubTaskPrompt(subTask, agentId); + String memoryId = AgentTeamMemoryIds.subAgent( + subTask.getParentSessionId(), + subTask.getGroupId(), + subTask.getTaskId(), + agentId + ); log.info( - "SubAgentExecution dispatching to role-scoped runtime: agentId={}, groupId={}, taskId={}, title={}", + "SubAgentExecution dispatching to role-scoped runtime: agentId={}, groupId={}, taskId={}, title={}, memoryId={}", agentId, subTask.getGroupId(), subTask.getTaskId(), - subTask.getTitle() + subTask.getTitle(), + memoryId ); - String result = roleExecutionPort.executeSync(AgentTeamRole.SUB_AGENT, prompt, subTask.getTaskId()); + String result = roleExecutionPort.executeSync(AgentTeamRole.SUB_AGENT, prompt, memoryId); String summary = summarize(result); log.info( - "SubAgentExecution finished runtime call: agentId={}, groupId={}, taskId={}, title={}, summary={}", + "SubAgentExecution finished runtime call: agentId={}, groupId={}, taskId={}, title={}, memoryId={}, summary={}", agentId, subTask.getGroupId(), subTask.getTaskId(), subTask.getTitle(), + memoryId, summary ); return new SubTaskExecutionOutput(summary, result); diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java index 4d6362e..d1b7a1d 100644 --- a/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java @@ -10,6 +10,7 @@ public class SubTask { private final String taskId; private final String groupId; + private final String parentSessionId; private final String title; private final String description; private final long createdAt; @@ -22,9 +23,17 @@ public class SubTask { private long startedAt; private long finishedAt; - public SubTask(String taskId, String groupId, String title, String description, long createdAt) { + public SubTask( + String taskId, + String groupId, + String parentSessionId, + String title, + String description, + long createdAt + ) { this.taskId = taskId; this.groupId = groupId; + this.parentSessionId = parentSessionId == null ? "" : parentSessionId.trim(); this.title = title == null ? "" : title.trim(); this.description = description == null ? "" : description.trim(); this.createdAt = createdAt; diff --git a/src/test/java/com/openmanus/agentteam/application/AgentTeamMemoryIdsTest.java b/src/test/java/com/openmanus/agentteam/application/AgentTeamMemoryIdsTest.java new file mode 100644 index 0000000..f6f48d0 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/AgentTeamMemoryIdsTest.java @@ -0,0 +1,28 @@ +package com.openmanus.agentteam.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AgentTeamMemoryIds Tests") +class AgentTeamMemoryIdsTest { + + @Test + @DisplayName("should build stable subagent memory id") + void shouldBuildStableSubAgentMemoryId() { + String memoryId = AgentTeamMemoryIds.subAgent("conv-1", "group-1", "task-1", "agent-1"); + + assertThat(memoryId).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); + assertThat(memoryId).startsWith("agentteam:"); + assertThat(memoryId).contains("group-1", "task-1", "agent-1"); + } + + @Test + @DisplayName("should use placeholders for blank segments") + void shouldUsePlaceholdersForBlankSegments() { + String memoryId = AgentTeamMemoryIds.subAgent(" ", null, "task-1", ""); + + assertThat(memoryId).isEqualTo("agentteam:_:_:task-1:_"); + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java index 548a1b8..3d907cc 100644 --- a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java +++ b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java @@ -59,8 +59,8 @@ public String subAgentExecutionPromptTemplate() { }; private SubAgentExecutionService subAgentExecutionService(AgentExecutionPort agentExecutionPort) { - AgentTeamRoleExecutionPort roleExecutionPort = (role, input, conversationId) -> - agentExecutionPort.executeSync(input, conversationId); + AgentTeamRoleExecutionPort roleExecutionPort = (role, input, memoryId) -> + agentExecutionPort.executeSync(input, memoryId); return new SubAgentExecutionService(roleExecutionPort, promptProvider); } diff --git a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java index 4f8c78a..8e68b1f 100644 --- a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java +++ b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java @@ -35,7 +35,14 @@ public String subAgentExecutionPromptTemplate() { } }; SubAgentExecutionService service = new SubAgentExecutionService(roleExecutionPort, promptProvider); - SubTask subTask = new SubTask("task-1", "group-1", "API", "Collect API requirements", System.currentTimeMillis()); + SubTask subTask = new SubTask( + "task-1", + "group-1", + "conv-1", + "API", + "Collect API requirements", + System.currentTimeMillis() + ); SubTaskExecutionOutput output = service.execute(subTask, "agent-1"); @@ -43,7 +50,8 @@ public String subAgentExecutionPromptTemplate() { assertThat(output.detail()).isEqualTo("done"); assertThat(roleExecutionPort.role).isEqualTo(AgentTeamRole.SUB_AGENT); assertThat(roleExecutionPort.input).isEqualTo("task=Collect API requirements"); - assertThat(roleExecutionPort.conversationId).isEqualTo("task-1"); + assertThat(roleExecutionPort.memoryId).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); + assertThat(roleExecutionPort.memoryId).isNotEqualTo("conv-1"); } private static final class RecordingRoleExecutionPort implements AgentTeamRoleExecutionPort { @@ -51,17 +59,17 @@ private static final class RecordingRoleExecutionPort implements AgentTeamRoleEx private final String result; private AgentTeamRole role; private String input; - private String conversationId; + private String memoryId; private RecordingRoleExecutionPort(String result) { this.result = result; } @Override - public String executeSync(AgentTeamRole role, String input, String conversationId) { + public String executeSync(AgentTeamRole role, String input, String memoryId) { this.role = role; this.input = input; - this.conversationId = conversationId; + this.memoryId = memoryId; return result; } } diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java index c06ff8b..efa7669 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java @@ -19,9 +19,9 @@ class DefaultResultAggregationServiceTest { @DisplayName("should aggregate success and failure results") void shouldAggregateSuccessAndFailureResults() { TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); - SubTask success = new SubTask("task-1", "group-1", "task A", "desc", 1L); - SubTask failed = new SubTask("task-2", "group-1", "task B", "desc", 1L); - SubTask pending = new SubTask("task-3", "group-1", "task C", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", 1L); + SubTask failed = new SubTask("task-2", "group-1", "parent-1", "task B", "desc", 1L); + SubTask pending = new SubTask("task-3", "group-1", "parent-1", "task C", "desc", 1L); success.claim("agent-1", 2L); success.markRunning(3L); @@ -46,7 +46,7 @@ void shouldAggregateSuccessAndFailureResults() { @DisplayName("should mark all succeeded when there are no failures") void shouldMarkAllSucceededWhenThereAreNoFailures() { TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); - SubTask success = new SubTask("task-1", "group-1", "task A", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", 1L); success.claim("agent-1", 2L); success.markRunning(3L); success.markSucceeded("summary A", "detail A", 4L); diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java index 5b2907d..e648148 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java @@ -37,8 +37,8 @@ void shouldCreateGroupAndPersistMetadata() { @DisplayName("should register subtasks and refresh snapshot") void shouldRegisterSubTasksAndRefreshSnapshot() { TaskGroup group = manager.createGroup("parent-1", "master-1", "split this task"); - SubTask first = new SubTask("task-1", group.getGroupId(), "task A", "desc A", 1L); - SubTask second = new SubTask("task-2", group.getGroupId(), "task B", "desc B", 1L); + SubTask first = new SubTask("task-1", group.getGroupId(), group.getParentTaskId(), "task A", "desc A", 1L); + SubTask second = new SubTask("task-2", group.getGroupId(), group.getParentTaskId(), "task B", "desc B", 1L); manager.registerSubTasks(group.getGroupId(), List.of(first, second)); diff --git a/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java index 2867b6a..4c5fea7 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java @@ -27,8 +27,8 @@ void shouldMarkEmptyGroupAsCreated() { @Test @DisplayName("should mark all-success group as completed") void shouldMarkAllSuccessGroupAsCompleted() { - SubTask first = new SubTask("task-1", "group-1", "A", "desc", 1L); - SubTask second = new SubTask("task-2", "group-1", "B", "desc", 1L); + SubTask first = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); + SubTask second = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); first.claim("agent-1", 2L); first.markRunning(3L); first.markSucceeded("ok", "detail", 4L); @@ -46,8 +46,8 @@ void shouldMarkAllSuccessGroupAsCompleted() { @Test @DisplayName("should mark mixed finished group as partial failed") void shouldMarkMixedFinishedGroupAsPartialFailed() { - SubTask success = new SubTask("task-1", "group-1", "A", "desc", 1L); - SubTask failed = new SubTask("task-2", "group-1", "B", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); + SubTask failed = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); success.claim("agent-1", 2L); success.markRunning(3L); success.markSucceeded("ok", "detail", 4L); @@ -65,8 +65,8 @@ void shouldMarkMixedFinishedGroupAsPartialFailed() { @Test @DisplayName("should mark claimed or running tasks as running") void shouldMarkClaimedOrRunningTasksAsRunning() { - SubTask claimed = new SubTask("task-1", "group-1", "A", "desc", 1L); - SubTask running = new SubTask("task-2", "group-1", "B", "desc", 1L); + SubTask claimed = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); + SubTask running = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); claimed.claim("agent-1", 2L); running.claim("agent-2", 2L); running.markRunning(3L); diff --git a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java index 599344b..a66a4e6 100644 --- a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java +++ b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java @@ -22,7 +22,7 @@ class InMemoryTaskPoolTest { void shouldClaimPendingTaskOnce() { InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); - SubTask subTask = new SubTask("task-1", "group-1", "A", "desc", 1L); + SubTask subTask = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); taskPool.submit(subTask); @@ -39,7 +39,7 @@ void shouldClaimPendingTaskOnce() { void shouldPreventDuplicateClaimUnderConcurrency() throws InterruptedException { InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); - taskPool.submit(new SubTask("task-1", "group-1", "A", "desc", 1L)); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L)); AtomicInteger successClaims = new AtomicInteger(); CountDownLatch ready = new CountDownLatch(2); From fbf8a97b937e6213efff3f7a842b07e9fcf82f32 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Mon, 1 Jun 2026 20:32:49 +0800 Subject: [PATCH 06/14] =?UTF-8?q?perf:=20=E5=AE=9E=E7=8E=B0=E5=A4=9Aagent?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E4=B9=8B=E4=BB=BB=E5=8A=A1=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/MasterAgentOrchestrator.java | 1 + .../application/SubAgentExecutionService.java | 3 +- .../application/TaskDecompositionService.java | 38 ++++++++++++-- .../AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" | 4 +- .../agentteam/domain/model/SubTask.java | 3 ++ .../agentteam/domain/model/SubTaskPlan.java | 3 +- .../agentteam/subagent-execution.prompt.md | 8 ++- .../MasterAgentOrchestratorTest.java | 11 +++-- .../SubAgentExecutionIsolationTest.java | 6 ++- .../TaskDecompositionServiceTest.java | 49 +++++++++++++++++++ .../DefaultResultAggregationServiceTest.java | 8 +-- .../service/DefaultTaskGroupManagerTest.java | 4 +- .../TaskGroupStatusCalculatorTest.java | 12 ++--- .../agentteam/infra/InMemoryTaskPoolTest.java | 4 +- 14 files changed, 123 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java index 798a0b0..d347242 100644 --- a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java +++ b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java @@ -113,6 +113,7 @@ private List materializeSubTasks(String groupId, String parentSessionId parentSessionId, plan.title(), plan.description(), + plan.contextSummary(), now )); } diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java index c81dc1a..deb6646 100644 --- a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java @@ -63,7 +63,8 @@ private String buildSubTaskPrompt(SubTask subTask, String agentId) { "taskId", safe(subTask.getTaskId()), "groupId", safe(subTask.getGroupId()), "taskTitle", safe(subTask.getTitle()), - "taskDescription", safe(subTask.getDescription()) + "taskDescription", safe(subTask.getDescription()), + "contextSummary", safe(subTask.getContextSummary()) ) ); } diff --git a/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java b/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java index e06e5da..92656d3 100644 --- a/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java +++ b/src/main/java/com/openmanus/agentteam/application/TaskDecompositionService.java @@ -65,10 +65,10 @@ public DecompositionPlan decompose(String userInput, int maxSubTasks) { DecompositionPlan llmPlan = tryLlmDecompose(userInput, maxSubTasks); if (llmPlan != null) { - return llmPlan; + return enrichContextSummary(llmPlan, userInput); } - return ruleBasedDecompose(userInput, maxSubTasks); + return enrichContextSummary(ruleBasedDecompose(userInput, maxSubTasks), userInput); } private DecompositionPlan tryLlmDecompose(String userInput, int maxSubTasks) { @@ -150,7 +150,8 @@ private List parseSubTasks(JsonNode node, int maxSubTasks) { if (title.isBlank()) { title = buildTitle(results.size() + 1, description); } - results.add(new SubTaskPlan(title, description)); + String contextSummary = normalizeReason(item.path("contextSummary").asText(null), ""); + results.add(new SubTaskPlan(title, description, contextSummary)); if (results.size() >= limit) { break; } @@ -218,6 +219,7 @@ private JsonNode buildResponseFormat(int maxSubTasks) { ObjectNode itemProperties = itemSchema.putObject("properties"); itemProperties.putObject("title").put("type", "string"); itemProperties.putObject("description").put("type", "string"); + itemProperties.putObject("contextSummary").put("type", "string"); ArrayNode itemRequired = itemSchema.putArray("required"); itemRequired.add("title"); @@ -256,7 +258,7 @@ private List extractCandidates(String userInput, int maxSubTasks) { if (!normalizedDescriptions.add(normalized)) { continue; } - results.add(new SubTaskPlan(buildTitle(results.size() + 1, normalized), normalized)); + results.add(new SubTaskPlan(buildTitle(results.size() + 1, normalized), normalized, "")); if (results.size() >= limit) { break; } @@ -287,7 +289,7 @@ private List normalizeSubTasks(List subTasks, int maxS if (title.isBlank()) { title = buildTitle(results.size() + 1, description); } - results.add(new SubTaskPlan(title, description)); + results.add(new SubTaskPlan(title, description, normalizeReason(subTask.contextSummary(), ""))); if (results.size() >= limit) { break; } @@ -324,6 +326,32 @@ private String buildTitle(int index, String description) { return "SubTask-" + index + ": " + compact; } + private DecompositionPlan enrichContextSummary(DecompositionPlan plan, String userInput) { + if (plan == null) { + return null; + } + List enriched = new ArrayList<>(); + for (SubTaskPlan subTask : plan.subTasks()) { + if (subTask == null) { + continue; + } + String contextSummary = normalizeReason(subTask.contextSummary(), fallbackContextSummary(userInput)); + enriched.add(new SubTaskPlan(subTask.title(), subTask.description(), contextSummary)); + } + return new DecompositionPlan(plan.parallelizable(), plan.reason(), enriched); + } + + private String fallbackContextSummary(String userInput) { + String request = normalizeReason(userInput, ""); + return """ + Parent request: + %s + + Team instruction: + Complete only this assigned subtask. Return concise evidence and result for Team Master aggregation. + """.formatted(request).trim(); + } + private String normalizeReason(String reason, String fallback) { if (reason == null || reason.isBlank()) { return fallback; diff --git "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" index d909219..94bce12 100644 --- "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" +++ "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" @@ -58,7 +58,7 @@ ## 4. 上下文分类与隔离策略 -### 4.1 聊天上下文 +### 4.1 聊天上下文(已完成) 含义: @@ -84,7 +84,7 @@ agentteam:{parentSessionId}:{groupId}:{taskId}:{agentId} 第一版可以先不做复杂转义,仅保证格式稳定。后续如 sessionId 中存在特殊字符,再补充 sanitize。 -### 4.2 任务上下文 +### 4.2 任务上下文(已完成) 含义: diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java index d1b7a1d..4b90936 100644 --- a/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTask.java @@ -13,6 +13,7 @@ public class SubTask { private final String parentSessionId; private final String title; private final String description; + private final String contextSummary; private final long createdAt; private TaskStatus status; private String assignedAgentId; @@ -29,6 +30,7 @@ public SubTask( String parentSessionId, String title, String description, + String contextSummary, long createdAt ) { this.taskId = taskId; @@ -36,6 +38,7 @@ public SubTask( this.parentSessionId = parentSessionId == null ? "" : parentSessionId.trim(); this.title = title == null ? "" : title.trim(); this.description = description == null ? "" : description.trim(); + this.contextSummary = contextSummary == null ? "" : contextSummary.trim(); this.createdAt = createdAt; this.status = TaskStatus.PENDING; } diff --git a/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java index 83a6e47..7eebb78 100644 --- a/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java +++ b/src/main/java/com/openmanus/agentteam/domain/model/SubTaskPlan.java @@ -3,10 +3,11 @@ /** * Candidate subtask emitted by decomposition. */ -public record SubTaskPlan(String title, String description) { +public record SubTaskPlan(String title, String description, String contextSummary) { public SubTaskPlan { title = title == null ? "" : title.trim(); description = description == null ? "" : description.trim(); + contextSummary = contextSummary == null ? "" : contextSummary.trim(); } } diff --git a/src/main/resources/prompts/agentteam/subagent-execution.prompt.md b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md index 09fa35d..b54e5bb 100644 --- a/src/main/resources/prompts/agentteam/subagent-execution.prompt.md +++ b/src/main/resources/prompts/agentteam/subagent-execution.prompt.md @@ -13,9 +13,13 @@ Subtask title: Subtask description: {{taskDescription}} +Task context summary: +{{contextSummary}} + Requirements: 1. Only work on this subtask. 2. Do not decompose work again or delegate more tasks. 3. Do not assume results from other subtasks. -4. If information is missing, explicitly state the blocker. -5. Start with a short conclusion, then provide the necessary details and evidence. +4. Use the provided context summary as the necessary parent-task background. +5. If information is missing, explicitly state the blocker. +6. Start with a short conclusion, then provide the necessary details and evidence. diff --git a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java index 3d907cc..c5cce93 100644 --- a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java +++ b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java @@ -54,6 +54,7 @@ public String subAgentExecutionPromptTemplate() { groupId={{groupId}} title={{taskTitle}} description={{taskDescription}} + contextSummary={{contextSummary}} """; } }; @@ -128,8 +129,8 @@ void shouldExecuteParallelSubTasksThroughSharedAgentExecutionPort() { "parallelizable": true, "reason": "Independent work items", "subTasks": [ - {"title": "API requirements", "description": "Collect API requirements"}, - {"title": "Deployment risks", "description": "Summarize deployment risks"} + {"title": "API requirements", "description": "Collect API requirements", "contextSummary": "Parent request: Please decompose this request\\nFocus: gather API needs"}, + {"title": "Deployment risks", "description": "Summarize deployment risks", "contextSummary": "Parent request: Please decompose this request\\nFocus: identify rollout risks"} ] } """), @@ -170,6 +171,8 @@ void shouldExecuteParallelSubTasksThroughSharedAgentExecutionPort() { assertThat(result).contains("failed: 0"); assertThat(result).contains("API requirements"); assertThat(result).contains("Deployment risks"); + verify(agentExecutionPort, atLeast(1)) + .executeSync(org.mockito.ArgumentMatchers.contains("contextSummary=Parent request: Please decompose this request"), anyString()); verify(agentExecutionPort, never()).executeSync(eq("Please decompose this request"), eq("conv-2")); verify(agentExecutionPort, atLeast(2)).executeSync(anyString(), anyString()); } finally { @@ -200,8 +203,8 @@ void shouldReportPartialFailureWhenOneSubTaskExecutionFails() { "parallelizable": true, "reason": "Independent work items", "subTasks": [ - {"title": "API requirements", "description": "Collect API requirements"}, - {"title": "Deployment risks", "description": "Summarize deployment risks"} + {"title": "API requirements", "description": "Collect API requirements", "contextSummary": "Parent request: Please decompose this request\\nFocus: gather API needs"}, + {"title": "Deployment risks", "description": "Summarize deployment risks", "contextSummary": "Parent request: Please decompose this request\\nFocus: identify rollout risks"} ] } """), diff --git a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java index 8e68b1f..aad99a2 100644 --- a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java +++ b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java @@ -31,7 +31,7 @@ public String subAgentSystemPromptTemplate() { @Override public String subAgentExecutionPromptTemplate() { - return "task={{taskDescription}}"; + return "task={{taskDescription}}\ncontext={{contextSummary}}"; } }; SubAgentExecutionService service = new SubAgentExecutionService(roleExecutionPort, promptProvider); @@ -41,6 +41,7 @@ public String subAgentExecutionPromptTemplate() { "conv-1", "API", "Collect API requirements", + "Parent request:\nBuild an API planning document", System.currentTimeMillis() ); @@ -49,7 +50,8 @@ public String subAgentExecutionPromptTemplate() { assertThat(output.summary()).isEqualTo("done"); assertThat(output.detail()).isEqualTo("done"); assertThat(roleExecutionPort.role).isEqualTo(AgentTeamRole.SUB_AGENT); - assertThat(roleExecutionPort.input).isEqualTo("task=Collect API requirements"); + assertThat(roleExecutionPort.input).contains("task=Collect API requirements"); + assertThat(roleExecutionPort.input).contains("context=Parent request:\nBuild an API planning document"); assertThat(roleExecutionPort.memoryId).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); assertThat(roleExecutionPort.memoryId).isNotEqualTo("conv-1"); } diff --git a/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java index d9b7885..b136fd6 100644 --- a/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java +++ b/src/test/java/com/openmanus/agentteam/application/TaskDecompositionServiceTest.java @@ -75,6 +75,9 @@ void shouldAcceptStructuredIndependentSubTasksFromLlm() { "Summarize deployment risks", "Write a short validation checklist" ); + assertThat(plan.subTasks()) + .extracting(item -> item.contextSummary()) + .allSatisfy(summary -> assertThat(summary).contains("Parent request:", "Team instruction:")); } @Test @@ -123,6 +126,9 @@ void shouldFallBackToRuleDecompositionWhenLlmFails() { assertThat(plan.parallelizable()).isTrue(); assertThat(plan.subTasks()).hasSize(3); + assertThat(plan.subTasks()) + .extracting(item -> item.contextSummary()) + .allSatisfy(summary -> assertThat(summary).contains("Parent request:", "Team instruction:")); } @Test @@ -170,4 +176,47 @@ void shouldRespectMaxSubTasksLimit() { assertThat(plan.parallelizable()).isTrue(); assertThat(plan.subTasks()).hasSize(2); } + + @Test + @DisplayName("should preserve llm context summary when provided") + void shouldPreserveLlmContextSummaryWhenProvided() { + AiChatModel aiChatModel = mock(AiChatModel.class); + when(aiChatModel.chat(any())).thenReturn(new AiChatResponse( + AiChatMessage.assistant(""" + { + "parallelizable": true, + "reason": "Independent work items", + "subTasks": [ + { + "title": "API requirements", + "description": "Collect API requirements", + "contextSummary": "Parent request: Build a service\\nFocus: API contract only" + }, + { + "title": "Deployment risks", + "description": "Summarize deployment risks", + "contextSummary": "Parent request: Build a service\\nFocus: rollout risk only" + } + ] + } + """), + null, + null, + null, + null, + null + )); + TaskDecompositionService service = + new TaskDecompositionService(aiChatModel, new ObjectMapper(), promptProvider); + + DecompositionPlan plan = service.decompose("Build a service", 5); + + assertThat(plan.parallelizable()).isTrue(); + assertThat(plan.subTasks()) + .extracting(item -> item.contextSummary()) + .containsExactly( + "Parent request: Build a service\nFocus: API contract only", + "Parent request: Build a service\nFocus: rollout risk only" + ); + } } diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java index efa7669..b1c93b7 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultResultAggregationServiceTest.java @@ -19,9 +19,9 @@ class DefaultResultAggregationServiceTest { @DisplayName("should aggregate success and failure results") void shouldAggregateSuccessAndFailureResults() { TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); - SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", 1L); - SubTask failed = new SubTask("task-2", "group-1", "parent-1", "task B", "desc", 1L); - SubTask pending = new SubTask("task-3", "group-1", "parent-1", "task C", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", "", 1L); + SubTask failed = new SubTask("task-2", "group-1", "parent-1", "task B", "desc", "", 1L); + SubTask pending = new SubTask("task-3", "group-1", "parent-1", "task C", "desc", "", 1L); success.claim("agent-1", 2L); success.markRunning(3L); @@ -46,7 +46,7 @@ void shouldAggregateSuccessAndFailureResults() { @DisplayName("should mark all succeeded when there are no failures") void shouldMarkAllSucceededWhenThereAreNoFailures() { TaskGroup group = new TaskGroup("group-1", "parent-1", "master-1", "request", 1L); - SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "task A", "desc", "", 1L); success.claim("agent-1", 2L); success.markRunning(3L); success.markSucceeded("summary A", "detail A", 4L); diff --git a/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java index e648148..8459f58 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/DefaultTaskGroupManagerTest.java @@ -37,8 +37,8 @@ void shouldCreateGroupAndPersistMetadata() { @DisplayName("should register subtasks and refresh snapshot") void shouldRegisterSubTasksAndRefreshSnapshot() { TaskGroup group = manager.createGroup("parent-1", "master-1", "split this task"); - SubTask first = new SubTask("task-1", group.getGroupId(), group.getParentTaskId(), "task A", "desc A", 1L); - SubTask second = new SubTask("task-2", group.getGroupId(), group.getParentTaskId(), "task B", "desc B", 1L); + SubTask first = new SubTask("task-1", group.getGroupId(), group.getParentTaskId(), "task A", "desc A", "", 1L); + SubTask second = new SubTask("task-2", group.getGroupId(), group.getParentTaskId(), "task B", "desc B", "", 1L); manager.registerSubTasks(group.getGroupId(), List.of(first, second)); diff --git a/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java index 4c5fea7..bd4afda 100644 --- a/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java +++ b/src/test/java/com/openmanus/agentteam/domain/service/TaskGroupStatusCalculatorTest.java @@ -27,8 +27,8 @@ void shouldMarkEmptyGroupAsCreated() { @Test @DisplayName("should mark all-success group as completed") void shouldMarkAllSuccessGroupAsCompleted() { - SubTask first = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); - SubTask second = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); + SubTask first = new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L); + SubTask second = new SubTask("task-2", "group-1", "parent-1", "B", "desc", "", 1L); first.claim("agent-1", 2L); first.markRunning(3L); first.markSucceeded("ok", "detail", 4L); @@ -46,8 +46,8 @@ void shouldMarkAllSuccessGroupAsCompleted() { @Test @DisplayName("should mark mixed finished group as partial failed") void shouldMarkMixedFinishedGroupAsPartialFailed() { - SubTask success = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); - SubTask failed = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); + SubTask success = new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L); + SubTask failed = new SubTask("task-2", "group-1", "parent-1", "B", "desc", "", 1L); success.claim("agent-1", 2L); success.markRunning(3L); success.markSucceeded("ok", "detail", 4L); @@ -65,8 +65,8 @@ void shouldMarkMixedFinishedGroupAsPartialFailed() { @Test @DisplayName("should mark claimed or running tasks as running") void shouldMarkClaimedOrRunningTasksAsRunning() { - SubTask claimed = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); - SubTask running = new SubTask("task-2", "group-1", "parent-1", "B", "desc", 1L); + SubTask claimed = new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L); + SubTask running = new SubTask("task-2", "group-1", "parent-1", "B", "desc", "", 1L); claimed.claim("agent-1", 2L); running.claim("agent-2", 2L); running.markRunning(3L); diff --git a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java index a66a4e6..3519eb2 100644 --- a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java +++ b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java @@ -22,7 +22,7 @@ class InMemoryTaskPoolTest { void shouldClaimPendingTaskOnce() { InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); - SubTask subTask = new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L); + SubTask subTask = new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L); taskPool.submit(subTask); @@ -39,7 +39,7 @@ void shouldClaimPendingTaskOnce() { void shouldPreventDuplicateClaimUnderConcurrency() throws InterruptedException { InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); - taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", 1L)); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); AtomicInteger successClaims = new AtomicInteger(); CountDownLatch ready = new CountDownLatch(2); From db22039aff0d30af36653fb5b88a8956dd268486 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Mon, 1 Jun 2026 21:17:25 +0800 Subject: [PATCH 07/14] =?UTF-8?q?chore:=20=E6=98=BE=E7=A4=BA=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentTeamExecutionContext.java | 46 +++++++++++++++++++ .../AgentTeamRoleExecutionPort.java | 2 +- .../AgentTeamRoleExecutionService.java | 31 +++++++++++-- .../application/SubAgentExecutionService.java | 8 ++-- .../AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" | 10 ++-- .../AgentTeamExecutionContextTest.java | 29 ++++++++++++ .../MasterAgentOrchestratorTest.java | 4 +- .../SubAgentExecutionIsolationTest.java | 19 ++++---- 8 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionContext.java create mode 100644 src/test/java/com/openmanus/agentteam/application/AgentTeamExecutionContextTest.java diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionContext.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionContext.java new file mode 100644 index 0000000..1e8e657 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionContext.java @@ -0,0 +1,46 @@ +package com.openmanus.agentteam.application; + +/** + * Stable execution identity for one agent-team runtime call. + */ +public record AgentTeamExecutionContext( + AgentTeamRole role, + String parentSessionId, + String groupId, + String taskId, + String agentId, + int depth, + String memoryId +) { + + public AgentTeamExecutionContext { + role = role == null ? AgentTeamRole.SUB_AGENT : role; + parentSessionId = normalize(parentSessionId); + groupId = normalize(groupId); + taskId = normalize(taskId); + agentId = normalize(agentId); + depth = Math.max(0, depth); + memoryId = normalize(memoryId); + } + + public static AgentTeamExecutionContext subAgent( + String parentSessionId, + String groupId, + String taskId, + String agentId + ) { + return new AgentTeamExecutionContext( + AgentTeamRole.SUB_AGENT, + parentSessionId, + groupId, + taskId, + agentId, + 1, + AgentTeamMemoryIds.subAgent(parentSessionId, groupId, taskId, agentId) + ); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java index 59e7599..d50c6cc 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionPort.java @@ -5,5 +5,5 @@ */ public interface AgentTeamRoleExecutionPort { - String executeSync(AgentTeamRole role, String input, String memoryId); + String executeSync(AgentTeamExecutionContext context, String input); } diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java index 9f3d7f5..5e6ae27 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamRoleExecutionService.java @@ -11,6 +11,12 @@ */ public class AgentTeamRoleExecutionService implements AgentTeamRoleExecutionPort { + @FunctionalInterface + private interface SafeCloseable extends AutoCloseable { + @Override + void close(); + } + private final AgentTeamCoordinatorFactory coordinatorFactory; public AgentTeamRoleExecutionService(AgentTeamCoordinatorFactory coordinatorFactory) { @@ -18,16 +24,31 @@ public AgentTeamRoleExecutionService(AgentTeamCoordinatorFactory coordinatorFact } @Override - public String executeSync(AgentTeamRole role, String input, String memoryId) { + public String executeSync(AgentTeamExecutionContext context, String input) { if (input == null || input.isBlank()) { throw new IllegalArgumentException("input cannot be null or blank"); } - Object runtimeMemoryId = memoryId != null && !memoryId.isBlank() - ? memoryId + AgentTeamExecutionContext executionContext = context == null + ? new AgentTeamExecutionContext(AgentTeamRole.SUB_AGENT, "", "", "", "", 0, "") + : context; + Object runtimeMemoryId = executionContext.memoryId() != null && !executionContext.memoryId().isBlank() + ? executionContext.memoryId() : UUID.randomUUID(); - try (MDC.MDCCloseable ignored = MDC.putCloseable("sessionId", String.valueOf(runtimeMemoryId))) { - AgentCoordinator coordinator = coordinatorFactory.create(role); + try (MDC.MDCCloseable ignoredSession = MDC.putCloseable("sessionId", String.valueOf(runtimeMemoryId)); + SafeCloseable ignoredGroup = putCloseable("agentTeamGroupId", executionContext.groupId()); + SafeCloseable ignoredTask = putCloseable("agentTeamTaskId", executionContext.taskId()); + SafeCloseable ignoredAgent = putCloseable("agentTeamAgentId", executionContext.agentId()); + SafeCloseable ignoredParent = putCloseable("agentTeamParentSessionId", executionContext.parentSessionId())) { + AgentCoordinator coordinator = coordinatorFactory.create(executionContext.role()); return coordinator.execute(input, runtimeMemoryId); } } + + private SafeCloseable putCloseable(String key, String value) { + if (value == null || value.isBlank()) { + return () -> { }; + } + MDC.MDCCloseable closeable = MDC.putCloseable(key, value); + return closeable::close; + } } diff --git a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java index deb6646..df8c2ec 100644 --- a/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java +++ b/src/main/java/com/openmanus/agentteam/application/SubAgentExecutionService.java @@ -27,7 +27,7 @@ public SubAgentExecutionService( public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { String prompt = buildSubTaskPrompt(subTask, agentId); - String memoryId = AgentTeamMemoryIds.subAgent( + AgentTeamExecutionContext context = AgentTeamExecutionContext.subAgent( subTask.getParentSessionId(), subTask.getGroupId(), subTask.getTaskId(), @@ -39,9 +39,9 @@ public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { subTask.getGroupId(), subTask.getTaskId(), subTask.getTitle(), - memoryId + context.memoryId() ); - String result = roleExecutionPort.executeSync(AgentTeamRole.SUB_AGENT, prompt, memoryId); + String result = roleExecutionPort.executeSync(context, prompt); String summary = summarize(result); log.info( "SubAgentExecution finished runtime call: agentId={}, groupId={}, taskId={}, title={}, memoryId={}, summary={}", @@ -49,7 +49,7 @@ public SubTaskExecutionOutput execute(SubTask subTask, String agentId) { subTask.getGroupId(), subTask.getTaskId(), subTask.getTitle(), - memoryId, + context.memoryId(), summary ); return new SubTaskExecutionOutput(summary, result); diff --git "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" index 94bce12..223d2a9 100644 --- "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" +++ "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" @@ -58,7 +58,7 @@ ## 4. 上下文分类与隔离策略 -### 4.1 聊天上下文(已完成) +### 4.1 聊天上下文 含义: @@ -84,7 +84,7 @@ agentteam:{parentSessionId}:{groupId}:{taskId}:{agentId} 第一版可以先不做复杂转义,仅保证格式稳定。后续如 sessionId 中存在特殊字符,再补充 sanitize。 -### 4.2 任务上下文(已完成) +### 4.2 任务上下文 含义: @@ -205,7 +205,7 @@ agentteam:{parentSessionId}:{groupId}:{taskId}:{agentId} ## 5. 分阶段开发计划 -## 阶段 A:聊天上下文 memoryId 隔离 +## 阶段 A:聊天上下文 memoryId 隔离(已完成) 目标: @@ -252,7 +252,7 @@ public final class AgentTeamMemoryIds { - `SubAgentExecutionIsolationTest` - 新增 `AgentTeamMemoryIdsTest` -## 阶段 B:任务上下文切片 +## 阶段 B:任务上下文切片(已完成) 目标: @@ -294,7 +294,7 @@ Complete only this assigned subtask. Return concise evidence and result for Team - `SubAgentExecutionIsolationTest` - `MasterAgentOrchestratorTest` -## 阶段 C:显式执行上下文 +## 阶段 C:显式执行上下文(已完成) 目标: diff --git a/src/test/java/com/openmanus/agentteam/application/AgentTeamExecutionContextTest.java b/src/test/java/com/openmanus/agentteam/application/AgentTeamExecutionContextTest.java new file mode 100644 index 0000000..1c17377 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/AgentTeamExecutionContextTest.java @@ -0,0 +1,29 @@ +package com.openmanus.agentteam.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AgentTeamExecutionContext Tests") +class AgentTeamExecutionContextTest { + + @Test + @DisplayName("should build subagent execution context with stable runtime identity") + void shouldBuildSubAgentExecutionContextWithStableRuntimeIdentity() { + AgentTeamExecutionContext context = AgentTeamExecutionContext.subAgent( + "conv-1", + "group-1", + "task-1", + "agent-1" + ); + + assertThat(context.role()).isEqualTo(AgentTeamRole.SUB_AGENT); + assertThat(context.parentSessionId()).isEqualTo("conv-1"); + assertThat(context.groupId()).isEqualTo("group-1"); + assertThat(context.taskId()).isEqualTo("task-1"); + assertThat(context.agentId()).isEqualTo("agent-1"); + assertThat(context.depth()).isEqualTo(1); + assertThat(context.memoryId()).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); + } +} diff --git a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java index c5cce93..a95cb71 100644 --- a/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java +++ b/src/test/java/com/openmanus/agentteam/application/MasterAgentOrchestratorTest.java @@ -60,8 +60,8 @@ public String subAgentExecutionPromptTemplate() { }; private SubAgentExecutionService subAgentExecutionService(AgentExecutionPort agentExecutionPort) { - AgentTeamRoleExecutionPort roleExecutionPort = (role, input, memoryId) -> - agentExecutionPort.executeSync(input, memoryId); + AgentTeamRoleExecutionPort roleExecutionPort = (context, input) -> + agentExecutionPort.executeSync(input, context.memoryId()); return new SubAgentExecutionService(roleExecutionPort, promptProvider); } diff --git a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java index aad99a2..480dc34 100644 --- a/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java +++ b/src/test/java/com/openmanus/agentteam/application/SubAgentExecutionIsolationTest.java @@ -49,29 +49,32 @@ public String subAgentExecutionPromptTemplate() { assertThat(output.summary()).isEqualTo("done"); assertThat(output.detail()).isEqualTo("done"); - assertThat(roleExecutionPort.role).isEqualTo(AgentTeamRole.SUB_AGENT); + assertThat(roleExecutionPort.context.role()).isEqualTo(AgentTeamRole.SUB_AGENT); + assertThat(roleExecutionPort.context.parentSessionId()).isEqualTo("conv-1"); + assertThat(roleExecutionPort.context.groupId()).isEqualTo("group-1"); + assertThat(roleExecutionPort.context.taskId()).isEqualTo("task-1"); + assertThat(roleExecutionPort.context.agentId()).isEqualTo("agent-1"); + assertThat(roleExecutionPort.context.depth()).isEqualTo(1); assertThat(roleExecutionPort.input).contains("task=Collect API requirements"); assertThat(roleExecutionPort.input).contains("context=Parent request:\nBuild an API planning document"); - assertThat(roleExecutionPort.memoryId).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); - assertThat(roleExecutionPort.memoryId).isNotEqualTo("conv-1"); + assertThat(roleExecutionPort.context.memoryId()).isEqualTo("agentteam:conv-1:group-1:task-1:agent-1"); + assertThat(roleExecutionPort.context.memoryId()).isNotEqualTo("conv-1"); } private static final class RecordingRoleExecutionPort implements AgentTeamRoleExecutionPort { private final String result; - private AgentTeamRole role; + private AgentTeamExecutionContext context; private String input; - private String memoryId; private RecordingRoleExecutionPort(String result) { this.result = result; } @Override - public String executeSync(AgentTeamRole role, String input, String memoryId) { - this.role = role; + public String executeSync(AgentTeamExecutionContext context, String input) { + this.context = context; this.input = input; - this.memoryId = memoryId; return result; } } From e1e6ead5fa00b444ad9b2f29b975882139dafa8b Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Tue, 2 Jun 2026 20:34:17 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=E9=94=99=E8=AF=AF=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...entTeamConversationApplicationService.java | 32 +---- .../application/AgentTeamErrorSupport.java | 47 ++++++++ .../application/AgentTeamException.java | 25 ++++ .../AgentTeamExecutionFailedException.java | 17 +++ ...mExecutionStreamingApplicationService.java | 35 ++---- .../InvalidTaskStateTransitionException.java | 13 ++ .../application/MasterAgentOrchestrator.java | 114 +++++++++++------- .../TaskOwnershipViolationException.java | 13 ++ .../AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" | 2 +- .../agentteam/domain/port/TaskPoolPort.java | 4 +- .../agentteam/infra/InMemoryTaskPool.java | 99 +++++++++------ .../agentteam/infra/SubAgentWorker.java | 37 ++++-- .../domain/model/ExecutionErrorCodes.java | 3 + .../openmanus/infra/web/AgentController.java | 7 +- ...eamConversationApplicationServiceTest.java | 46 +++++++ .../agentteam/infra/InMemoryTaskPoolTest.java | 91 ++++++++++++++ 16 files changed, 436 insertions(+), 149 deletions(-) create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamErrorSupport.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamException.java create mode 100644 src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionFailedException.java create mode 100644 src/main/java/com/openmanus/agentteam/application/InvalidTaskStateTransitionException.java create mode 100644 src/main/java/com/openmanus/agentteam/application/TaskOwnershipViolationException.java create mode 100644 src/test/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationServiceTest.java diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java index 9e0c8e4..2b73911 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationService.java @@ -66,7 +66,7 @@ public CompletableFuture> chat(String message, String conver } return CompletableFuture.supplyAsync(() -> executeSyncWithMonitoring(message, sessionId, userId), asyncExecutor) - .exceptionally(error -> completeFailure(sessionId, unwrapException(error))) + .exceptionally(error -> completeFailure(sessionId, AgentTeamErrorSupport.unwrap(error))) .whenComplete((ignored, throwable) -> sessionExecutionGuard.release(sessionId)); } @@ -99,7 +99,8 @@ private Map completeSuccess(String sessionId, String response) { } private Map completeFailure(String sessionId, Throwable error) { - String message = safeErrorMessage(error); + String message = AgentTeamErrorSupport.safeMessage(error); + String errorCode = AgentTeamErrorSupport.errorCode(error); executionEventPort.endExecutionTracking(sessionId, "执行出错: " + message, false); executionEventPort.recordError(sessionId, EXECUTION_COORDINATOR, EXECUTION_ERROR, message); executionEventPort.endExecution( @@ -109,29 +110,7 @@ private Map completeFailure(String sessionId, Throwable error) { "执行出错: " + message, "ERROR" ); - return errorResult(sessionId, message); - } - - private static Throwable unwrapException(Throwable throwable) { - if (throwable == null) { - return new IllegalStateException("unknown error"); - } - Throwable current = throwable; - while (current.getCause() != null && current.getCause() != current) { - current = current.getCause(); - } - return current; - } - - private static String safeErrorMessage(Throwable throwable) { - if (throwable == null) { - return "unknown error"; - } - String message = throwable.getMessage(); - if (message == null || message.isBlank()) { - return "unknown error"; - } - return message.trim(); + return errorResult(sessionId, message, errorCode); } private static Map successResult(String sessionId, String response) { @@ -143,9 +122,10 @@ private static Map successResult(String sessionId, String respon return result; } - private static Map errorResult(String sessionId, String message) { + private static Map errorResult(String sessionId, String message, String errorCode) { Map errorResult = new HashMap<>(); errorResult.put("error", message); + errorResult.put("errorCode", errorCode); errorResult.put("conversationId", sessionId); return errorResult; } diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamErrorSupport.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamErrorSupport.java new file mode 100644 index 0000000..4a4ef55 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamErrorSupport.java @@ -0,0 +1,47 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; + +/** + * Shared error helpers for the agent-team execution path. + */ +public final class AgentTeamErrorSupport { + + private AgentTeamErrorSupport() { + } + + public static Throwable unwrap(Throwable throwable) { + if (throwable == null) { + return new AgentTeamExecutionFailedException("agentteam execution failed"); + } + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + public static String safeMessage(Throwable throwable) { + if (throwable == null) { + return "agentteam execution failed"; + } + String message = throwable.getMessage(); + if (message == null || message.isBlank()) { + return "agentteam execution failed"; + } + return message.trim(); + } + + public static String errorCode(Throwable throwable) { + Throwable actual = unwrap(throwable); + if (actual instanceof AgentTeamException agentTeamException) { + return agentTeamException.getErrorCode(); + } + return ExecutionErrorCodes.INTERNAL_ERROR; + } + + public static String errorType(Throwable throwable) { + Throwable actual = unwrap(throwable); + return actual.getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamException.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamException.java new file mode 100644 index 0000000..64eabfe --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamException.java @@ -0,0 +1,25 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.infra.exception.OpenManusException; + +/** + * Base runtime exception for agent-team execution path. + */ +public class AgentTeamException extends OpenManusException { + + private final String errorCode; + + public AgentTeamException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public AgentTeamException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionFailedException.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionFailedException.java new file mode 100644 index 0000000..4e32f5a --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionFailedException.java @@ -0,0 +1,17 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; + +/** + * Raised when the agent-team execution pipeline fails unexpectedly. + */ +public class AgentTeamExecutionFailedException extends AgentTeamException { + + public AgentTeamExecutionFailedException(String message) { + super(ExecutionErrorCodes.AGENTTEAM_EXECUTION_FAILED, message); + } + + public AgentTeamExecutionFailedException(String message, Throwable cause) { + super(ExecutionErrorCodes.AGENTTEAM_EXECUTION_FAILED, message, cause); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java index 425d766..5863799 100644 --- a/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java +++ b/src/main/java/com/openmanus/agentteam/application/AgentTeamExecutionStreamingApplicationService.java @@ -147,9 +147,17 @@ void executeExecutionInternal( log.info("AgentTeam execution completed: sessionId={}, durationMs={}", sessionId, executionTimeMs); sendExecutionResult(executionTopic, sessionId, userInput, result, "SUCCESS", endTime, executionTimeMs); } catch (RuntimeException exception) { - Throwable actualError = unwrapException(exception); - String errorMessage = safeErrorMessage(actualError); - log.error("AgentTeam execution failed: sessionId={}", sessionId, exception); + Throwable actualError = AgentTeamErrorSupport.unwrap(exception); + String errorMessage = AgentTeamErrorSupport.safeMessage(actualError); + String errorCode = AgentTeamErrorSupport.errorCode(actualError); + log.error( + "AgentTeam execution failed: sessionId={}, errorCode={}, errorType={}, error={}", + sessionId, + errorCode, + AgentTeamErrorSupport.errorType(actualError), + errorMessage, + exception + ); executionEventPort.endExecutionTracking(sessionId, "执行出错: " + errorMessage, false); executionEventPort.recordError(sessionId, EXECUTION_COORDINATOR, EXECUTION_ERROR, errorMessage); executionEventPort.endExecution( @@ -219,25 +227,4 @@ private void removeListenerSafely(String sessionId, ExecutionEventPort.Listener } } - private static Throwable unwrapException(Throwable throwable) { - if (throwable == null) { - return new IllegalStateException("unknown error"); - } - Throwable current = throwable; - while (current.getCause() != null && current.getCause() != current) { - current = current.getCause(); - } - return current; - } - - private static String safeErrorMessage(Throwable throwable) { - if (throwable == null) { - return "unknown error"; - } - String message = throwable.getMessage(); - if (message == null || message.isBlank()) { - return "unknown error"; - } - return message.trim(); - } } diff --git a/src/main/java/com/openmanus/agentteam/application/InvalidTaskStateTransitionException.java b/src/main/java/com/openmanus/agentteam/application/InvalidTaskStateTransitionException.java new file mode 100644 index 0000000..d27e2ec --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/InvalidTaskStateTransitionException.java @@ -0,0 +1,13 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; + +/** + * Raised when a subtask attempts an invalid lifecycle transition. + */ +public class InvalidTaskStateTransitionException extends AgentTeamException { + + public InvalidTaskStateTransitionException(String message) { + super(ExecutionErrorCodes.AGENTTEAM_TASK_STATE_INVALID, message); + } +} diff --git a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java index d347242..925d4d3 100644 --- a/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java +++ b/src/main/java/com/openmanus/agentteam/application/MasterAgentOrchestrator.java @@ -52,55 +52,79 @@ public MasterAgentOrchestrator( } public String execute(String userInput, String conversationId) { - DecompositionPlan plan = decompositionService.decompose(userInput, maxSubTasksPerGroup); - log.info( - "TeamMaster decomposition finished: parallelizable={}, subTaskCount={}, reason={}", - plan.parallelizable(), - plan.subTasks() == null ? 0 : plan.subTasks().size(), - plan.reason() - ); - if (!plan.parallelizable()) { - log.info("TeamMaster falling back to single-agent execution: conversationId={}", conversationId); - return agentExecutionPort.executeSync(userInput, conversationId); - } + try { + DecompositionPlan plan = decompositionService.decompose(userInput, maxSubTasksPerGroup); + log.info( + "TeamMaster decomposition finished: parallelizable={}, subTaskCount={}, reason={}", + plan.parallelizable(), + plan.subTasks() == null ? 0 : plan.subTasks().size(), + plan.reason() + ); + if (!plan.parallelizable()) { + log.info("TeamMaster falling back to single-agent execution: conversationId={}", conversationId); + return agentExecutionPort.executeSync(userInput, conversationId); + } - workerManager.ensureStarted(); - TaskGroup taskGroup = taskGroupManager.createGroup( - conversationId == null || conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId, - "team-master", - userInput - ); - List subTasks = materializeSubTasks(taskGroup.getGroupId(), taskGroup.getParentTaskId(), plan.subTasks()); - log.info( - "TeamMaster created task group: groupId={}, conversationId={}, subTaskCount={}", - taskGroup.getGroupId(), - conversationId, - subTasks.size() - ); - taskGroupManager.registerSubTasks(taskGroup.getGroupId(), subTasks); - for (SubTask subTask : subTasks) { + workerManager.ensureStarted(); + TaskGroup taskGroup = taskGroupManager.createGroup( + conversationId == null || conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId, + "team-master", + userInput + ); + List subTasks = materializeSubTasks(taskGroup.getGroupId(), taskGroup.getParentTaskId(), plan.subTasks()); log.info( - "TeamMaster submitting subtask to pool: groupId={}, taskId={}, title={}", + "TeamMaster created task group: groupId={}, conversationId={}, subTaskCount={}", taskGroup.getGroupId(), - subTask.getTaskId(), - subTask.getTitle() + conversationId, + subTasks.size() ); - taskPoolPort.submit(subTask); - } + taskGroupManager.registerSubTasks(taskGroup.getGroupId(), subTasks); + for (SubTask subTask : subTasks) { + log.info( + "TeamMaster submitting subtask to pool: groupId={}, taskId={}, title={}", + taskGroup.getGroupId(), + subTask.getTaskId(), + subTask.getTitle() + ); + taskPoolPort.submit(subTask); + } - TaskGroupSnapshot snapshot = waitForCompletion(taskGroup.getGroupId()); - TaskGroupResult result = resultAggregationService.aggregate( - taskGroup, - taskPoolPort.findByGroupId(taskGroup.getGroupId()) - ); - log.info( - "TeamMaster aggregation finished: groupId={}, status={}, successCount={}, failedCount={}", - taskGroup.getGroupId(), - snapshot.status(), - snapshot.succeededTasks(), - snapshot.failedTasks() - ); - return renderFinalAnswer(taskGroup, snapshot, result); + TaskGroupSnapshot snapshot = waitForCompletion(taskGroup.getGroupId()); + TaskGroupResult result = resultAggregationService.aggregate( + taskGroup, + taskPoolPort.findByGroupId(taskGroup.getGroupId()) + ); + log.info( + "TeamMaster aggregation finished: groupId={}, status={}, successCount={}, failedCount={}", + taskGroup.getGroupId(), + snapshot.status(), + snapshot.succeededTasks(), + snapshot.failedTasks() + ); + return renderFinalAnswer(taskGroup, snapshot, result); + } catch (AgentTeamException exception) { + log.error( + "TeamMaster orchestration failed: conversationId={}, errorCode={}, errorType={}, error={}", + conversationId, + exception.getErrorCode(), + exception.getClass().getSimpleName(), + exception.getMessage(), + exception + ); + throw exception; + } catch (RuntimeException exception) { + log.error( + "TeamMaster orchestration failed: conversationId={}, errorType={}, error={}", + conversationId, + exception.getClass().getSimpleName(), + AgentTeamErrorSupport.safeMessage(exception), + exception + ); + throw new AgentTeamExecutionFailedException( + "AgentTeam orchestration failed: " + AgentTeamErrorSupport.safeMessage(exception), + exception + ); + } } private List materializeSubTasks(String groupId, String parentSessionId, List subTaskPlans) { @@ -130,7 +154,7 @@ private TaskGroupSnapshot waitForCompletion(String groupId) { Thread.sleep(masterPollIntervalMillis); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - throw new IllegalStateException("master polling interrupted", exception); + throw new AgentTeamExecutionFailedException("master polling interrupted", exception); } } } diff --git a/src/main/java/com/openmanus/agentteam/application/TaskOwnershipViolationException.java b/src/main/java/com/openmanus/agentteam/application/TaskOwnershipViolationException.java new file mode 100644 index 0000000..bb82831 --- /dev/null +++ b/src/main/java/com/openmanus/agentteam/application/TaskOwnershipViolationException.java @@ -0,0 +1,13 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; + +/** + * Raised when a task writeback is attempted by a non-owner agent. + */ +public class TaskOwnershipViolationException extends AgentTeamException { + + public TaskOwnershipViolationException(String message) { + super(ExecutionErrorCodes.AGENTTEAM_TASK_OWNERSHIP_VIOLATION, message); + } +} diff --git "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" index 223d2a9..7b13be1 100644 --- "a/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" +++ "b/src/main/java/com/openmanus/agentteam/docs/\344\270\212\344\270\213\346\226\207\351\232\224\347\246\273/AGENT_TEAM_CONTEXT_ISOLATION_PLAN.md" @@ -358,7 +358,7 @@ public record AgentTeamExecutionContext( - 调整 `SubAgentExecutionIsolationTest` - 调整 `AgentTeamRoleExecutionServiceTest` 或补充现有测试 -## 阶段 D:任务状态写入所有权校验 +## 阶段 D:任务状态写入所有权校验(已完成) 目标: diff --git a/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java b/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java index 297e1bf..d52f86c 100644 --- a/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java +++ b/src/main/java/com/openmanus/agentteam/domain/port/TaskPoolPort.java @@ -13,9 +13,9 @@ public interface TaskPoolPort { void markRunning(String taskId, String agentId); - void markSucceeded(String taskId, String summary, String detail); + void markSucceeded(String taskId, String agentId, String summary, String detail); - void markFailed(String taskId, String errorMessage); + void markFailed(String taskId, String agentId, String errorMessage); Optional findById(String taskId); diff --git a/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java index b1a484b..15e6b71 100644 --- a/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java +++ b/src/main/java/com/openmanus/agentteam/infra/InMemoryTaskPool.java @@ -1,5 +1,7 @@ package com.openmanus.agentteam.infra; +import com.openmanus.agentteam.application.InvalidTaskStateTransitionException; +import com.openmanus.agentteam.application.TaskOwnershipViolationException; import com.openmanus.agentteam.domain.model.SubTask; import com.openmanus.agentteam.domain.model.TaskStatus; import com.openmanus.agentteam.domain.port.TaskGroupRepositoryPort; @@ -77,51 +79,49 @@ public Optional claimNext(String agentId) { @Override public void markRunning(String taskId, String agentId) { - repository.findSubTask(taskId).ifPresent(task -> { - if (task.getAssignedAgentId() == null || task.getAssignedAgentId().equals(agentId)) { - task.markRunning(System.currentTimeMillis()); - repository.saveSubTask(task); - log.info( - "TaskPool mark running: agentId={}, groupId={}, taskId={}, title={}", - agentId, - task.getGroupId(), - task.getTaskId(), - task.getTitle() - ); - } - }); + SubTask task = requireOwnedTask(taskId, agentId); + requireStatus(task, TaskStatus.CLAIMED, "Only claimed task can be marked running"); + task.markRunning(System.currentTimeMillis()); + repository.saveSubTask(task); + log.info( + "TaskPool mark running: agentId={}, groupId={}, taskId={}, title={}", + agentId, + task.getGroupId(), + task.getTaskId(), + task.getTitle() + ); } @Override - public void markSucceeded(String taskId, String summary, String detail) { - repository.findSubTask(taskId).ifPresent(task -> { - task.markSucceeded(summary, detail, System.currentTimeMillis()); - repository.saveSubTask(task); - log.info( - "TaskPool mark succeeded: agentId={}, groupId={}, taskId={}, title={}, summary={}", - task.getAssignedAgentId(), - task.getGroupId(), - task.getTaskId(), - task.getTitle(), - summarize(summary) - ); - }); + public void markSucceeded(String taskId, String agentId, String summary, String detail) { + SubTask task = requireOwnedTask(taskId, agentId); + requireStatus(task, TaskStatus.RUNNING, "Only running task can be marked succeeded"); + task.markSucceeded(summary, detail, System.currentTimeMillis()); + repository.saveSubTask(task); + log.info( + "TaskPool mark succeeded: agentId={}, groupId={}, taskId={}, title={}, summary={}", + task.getAssignedAgentId(), + task.getGroupId(), + task.getTaskId(), + task.getTitle(), + summarize(summary) + ); } @Override - public void markFailed(String taskId, String errorMessage) { - repository.findSubTask(taskId).ifPresent(task -> { - task.markFailed(errorMessage, System.currentTimeMillis()); - repository.saveSubTask(task); - log.warn( - "TaskPool mark failed: agentId={}, groupId={}, taskId={}, title={}, error={}", - task.getAssignedAgentId(), - task.getGroupId(), - task.getTaskId(), - task.getTitle(), - summarize(errorMessage) - ); - }); + public void markFailed(String taskId, String agentId, String errorMessage) { + SubTask task = requireOwnedTask(taskId, agentId); + requireStatus(task, TaskStatus.RUNNING, "Only running task can be marked failed"); + task.markFailed(errorMessage, System.currentTimeMillis()); + repository.saveSubTask(task); + log.warn( + "TaskPool mark failed: agentId={}, groupId={}, taskId={}, title={}, error={}", + task.getAssignedAgentId(), + task.getGroupId(), + task.getTaskId(), + task.getTitle(), + summarize(errorMessage) + ); } @Override @@ -134,6 +134,27 @@ public List findByGroupId(String groupId) { return repository.findSubTasksByGroupId(groupId); } + private SubTask requireOwnedTask(String taskId, String agentId) { + SubTask task = repository.findSubTask(taskId) + .orElseThrow(() -> new InvalidTaskStateTransitionException("Task not found: " + taskId)); + if (task.getAssignedAgentId() == null || task.getAssignedAgentId().isBlank()) { + throw new InvalidTaskStateTransitionException("Task has not been claimed yet: " + taskId); + } + if (!task.getAssignedAgentId().equals(agentId)) { + throw new TaskOwnershipViolationException("Task is not owned by agent: " + agentId); + } + if (task.getStatus().isTerminal()) { + throw new InvalidTaskStateTransitionException("Task already finished: " + taskId); + } + return task; + } + + private void requireStatus(SubTask task, TaskStatus expectedStatus, String message) { + if (task.getStatus() != expectedStatus) { + throw new InvalidTaskStateTransitionException(message + ", currentStatus=" + task.getStatus()); + } + } + private String summarize(String value) { if (value == null || value.isBlank()) { return ""; diff --git a/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java index 442ba6e..fb43de9 100644 --- a/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java +++ b/src/main/java/com/openmanus/agentteam/infra/SubAgentWorker.java @@ -1,5 +1,6 @@ package com.openmanus.agentteam.infra; +import com.openmanus.agentteam.application.AgentTeamErrorSupport; import com.openmanus.agentteam.application.SubAgentExecutionService; import com.openmanus.agentteam.application.SubTaskExecutionOutput; import com.openmanus.agentteam.domain.model.AgentMessage; @@ -84,7 +85,7 @@ private void executeClaimedTask(SubTask subTask) { ); taskPoolPort.markRunning(subTask.getTaskId(), agentId); SubTaskExecutionOutput output = executionService.execute(subTask, agentId); - taskPoolPort.markSucceeded(subTask.getTaskId(), output.summary(), output.detail()); + taskPoolPort.markSucceeded(subTask.getTaskId(), agentId, output.summary(), output.detail()); log.info( "SubAgentWorker completed task: agentId={}, groupId={}, taskId={}, title={}, summary={}", agentId, @@ -94,14 +95,35 @@ private void executeClaimedTask(SubTask subTask) { summarize(output.summary()) ); } catch (Exception exception) { - taskPoolPort.markFailed(subTask.getTaskId(), safeMessage(exception)); + handleExecutionFailure(subTask, exception); + } + } + + private void handleExecutionFailure(SubTask subTask, Exception exception) { + String errorMessage = AgentTeamErrorSupport.safeMessage(exception); + try { + taskPoolPort.markFailed(subTask.getTaskId(), agentId, errorMessage); log.warn( - "SubAgentWorker failed task: agentId={}, groupId={}, taskId={}, title={}, error={}", + "SubAgentWorker failed task: agentId={}, groupId={}, taskId={}, title={}, errorType={}, error={}", + agentId, + subTask.getGroupId(), + subTask.getTaskId(), + subTask.getTitle(), + AgentTeamErrorSupport.errorType(exception), + errorMessage + ); + } catch (RuntimeException writebackException) { + log.error( + "SubAgentWorker failure writeback rejected: agentId={}, groupId={}, taskId={}, title={}, executionErrorType={}, executionError={}, writebackErrorType={}, writebackError={}", agentId, subTask.getGroupId(), subTask.getTaskId(), subTask.getTitle(), - safeMessage(exception) + AgentTeamErrorSupport.errorType(exception), + errorMessage, + AgentTeamErrorSupport.errorType(writebackException), + AgentTeamErrorSupport.safeMessage(writebackException), + writebackException ); } } @@ -115,13 +137,6 @@ private void sleepQuietly(long millis) { } } - private String safeMessage(Exception exception) { - if (exception == null || exception.getMessage() == null || exception.getMessage().isBlank()) { - return "subagent execution failed"; - } - return exception.getMessage().trim(); - } - private String summarize(String value) { if (value == null || value.isBlank()) { return ""; diff --git a/src/main/java/com/openmanus/domain/model/ExecutionErrorCodes.java b/src/main/java/com/openmanus/domain/model/ExecutionErrorCodes.java index 7e2a229..89c0778 100644 --- a/src/main/java/com/openmanus/domain/model/ExecutionErrorCodes.java +++ b/src/main/java/com/openmanus/domain/model/ExecutionErrorCodes.java @@ -10,6 +10,9 @@ private ExecutionErrorCodes() { public static final String ASYNC_SUBMIT_REJECTED = "ASYNC_SUBMIT_REJECTED"; public static final String ASYNC_SUBMIT_EXCEPTION = "ASYNC_SUBMIT_EXCEPTION"; public static final String INTERNAL_ERROR = "INTERNAL_ERROR"; + public static final String AGENTTEAM_TASK_OWNERSHIP_VIOLATION = "AGENTTEAM_TASK_OWNERSHIP_VIOLATION"; + public static final String AGENTTEAM_TASK_STATE_INVALID = "AGENTTEAM_TASK_STATE_INVALID"; + public static final String AGENTTEAM_EXECUTION_FAILED = "AGENTTEAM_EXECUTION_FAILED"; // Fallback-only code for controller status mapping when upstream returns an unknown business error code. // It is not a stable output of ExecutionStreamingApplicationService in normal execution paths. public static final String UNKNOWN_ERROR = "UNKNOWN_ERROR"; diff --git a/src/main/java/com/openmanus/infra/web/AgentController.java b/src/main/java/com/openmanus/infra/web/AgentController.java index 3a57bfe..651fdc7 100644 --- a/src/main/java/com/openmanus/infra/web/AgentController.java +++ b/src/main/java/com/openmanus/infra/web/AgentController.java @@ -165,11 +165,16 @@ private HttpStatus resolveErrorStatus(String errorCode, String error) { if (ExecutionErrorCodes.SESSION_BUSY.equals(errorCode)) { return HttpStatus.CONFLICT; } + if (ExecutionErrorCodes.AGENTTEAM_TASK_OWNERSHIP_VIOLATION.equals(errorCode) + || ExecutionErrorCodes.AGENTTEAM_TASK_STATE_INVALID.equals(errorCode)) { + return HttpStatus.CONFLICT; + } if (ExecutionErrorCodes.ASYNC_SUBMIT_REJECTED.equals(errorCode) || ExecutionErrorCodes.ASYNC_SUBMIT_EXCEPTION.equals(errorCode)) { return HttpStatus.SERVICE_UNAVAILABLE; } - if (ExecutionErrorCodes.INTERNAL_ERROR.equals(errorCode)) { + if (ExecutionErrorCodes.INTERNAL_ERROR.equals(errorCode) + || ExecutionErrorCodes.AGENTTEAM_EXECUTION_FAILED.equals(errorCode)) { return HttpStatus.INTERNAL_SERVER_ERROR; } diff --git a/src/test/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationServiceTest.java b/src/test/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationServiceTest.java new file mode 100644 index 0000000..4cb4a52 --- /dev/null +++ b/src/test/java/com/openmanus/agentteam/application/AgentTeamConversationApplicationServiceTest.java @@ -0,0 +1,46 @@ +package com.openmanus.agentteam.application; + +import com.openmanus.domain.model.ExecutionErrorCodes; +import com.openmanus.domain.service.ExecutionEventPort; +import com.openmanus.domain.service.SessionExecutionGuard; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.Executor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("AgentTeamConversationApplicationService Tests") +class AgentTeamConversationApplicationServiceTest { + + @Test + @DisplayName("should expose ownership violation error code instead of unknown error") + void shouldExposeOwnershipViolationErrorCodeInsteadOfUnknownError() { + MasterAgentOrchestrator orchestrator = mock(MasterAgentOrchestrator.class); + when(orchestrator.execute(anyString(), anyString())) + .thenThrow(new TaskOwnershipViolationException("Task is not owned by agent: subagent-2")); + + ExecutionEventPort executionEventPort = mock(ExecutionEventPort.class); + SessionExecutionGuard sessionExecutionGuard = mock(SessionExecutionGuard.class); + when(sessionExecutionGuard.tryAcquire("conv-1")).thenReturn(true); + Executor executor = Runnable::run; + + AgentTeamConversationApplicationService service = new AgentTeamConversationApplicationService( + new AgentTeamApplicationService(orchestrator), + executionEventPort, + sessionExecutionGuard, + executor + ); + + Map result = service.chat("hello", "conv-1", true).join(); + + assertThat(result).containsEntry("errorCode", ExecutionErrorCodes.AGENTTEAM_TASK_OWNERSHIP_VIOLATION); + assertThat(String.valueOf(result.get("error"))).contains("Task is not owned by agent"); + verify(sessionExecutionGuard).release("conv-1"); + } +} diff --git a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java index 3519eb2..4d843e3 100644 --- a/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java +++ b/src/test/java/com/openmanus/agentteam/infra/InMemoryTaskPoolTest.java @@ -1,5 +1,7 @@ package com.openmanus.agentteam.infra; +import com.openmanus.agentteam.application.InvalidTaskStateTransitionException; +import com.openmanus.agentteam.application.TaskOwnershipViolationException; import com.openmanus.agentteam.domain.model.SubTask; import com.openmanus.agentteam.domain.model.TaskStatus; import org.junit.jupiter.api.DisplayName; @@ -12,6 +14,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("InMemoryTaskPool Tests") @@ -67,4 +70,92 @@ void shouldPreventDuplicateClaimUnderConcurrency() throws InterruptedException { assertThat(successClaims.get()).isEqualTo(1); } + + @Test + @DisplayName("should allow owner to complete claimed task through running state") + void shouldAllowOwnerToCompleteClaimedTaskThroughRunningState() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + SubTask claimed = taskPool.claimNext("agent-1").orElseThrow(); + taskPool.markRunning(claimed.getTaskId(), "agent-1"); + taskPool.markSucceeded(claimed.getTaskId(), "agent-1", "done", "detail"); + + SubTask stored = taskPool.findById(claimed.getTaskId()).orElseThrow(); + assertThat(stored.getStatus()).isEqualTo(TaskStatus.SUCCEEDED); + assertThat(stored.getResultSummary()).isEqualTo("done"); + assertThat(stored.getResultDetail()).isEqualTo("detail"); + } + + @Test + @DisplayName("should reject non owner when marking running") + void shouldRejectNonOwnerWhenMarkingRunning() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + SubTask claimed = taskPool.claimNext("agent-1").orElseThrow(); + + assertThatThrownBy(() -> taskPool.markRunning(claimed.getTaskId(), "agent-2")) + .isInstanceOf(TaskOwnershipViolationException.class) + .hasMessageContaining("Task is not owned by agent"); + } + + @Test + @DisplayName("should reject non owner when writing success result") + void shouldRejectNonOwnerWhenWritingSuccessResult() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + SubTask claimed = taskPool.claimNext("agent-1").orElseThrow(); + taskPool.markRunning(claimed.getTaskId(), "agent-1"); + + assertThatThrownBy(() -> taskPool.markSucceeded(claimed.getTaskId(), "agent-2", "done", "detail")) + .isInstanceOf(TaskOwnershipViolationException.class) + .hasMessageContaining("Task is not owned by agent"); + } + + @Test + @DisplayName("should reject unclaimed task writeback") + void shouldRejectUnclaimedTaskWriteback() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + assertThatThrownBy(() -> taskPool.markSucceeded("task-1", "agent-1", "done", "detail")) + .isInstanceOf(InvalidTaskStateTransitionException.class) + .hasMessageContaining("Task has not been claimed yet"); + } + + @Test + @DisplayName("should reject success before running") + void shouldRejectSuccessBeforeRunning() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + SubTask claimed = taskPool.claimNext("agent-1").orElseThrow(); + + assertThatThrownBy(() -> taskPool.markSucceeded(claimed.getTaskId(), "agent-1", "done", "detail")) + .isInstanceOf(InvalidTaskStateTransitionException.class) + .hasMessageContaining("Only running task can be marked succeeded"); + } + + @Test + @DisplayName("should reject rewrite after task finished") + void shouldRejectRewriteAfterTaskFinished() { + InMemoryTaskGroupRepository repository = new InMemoryTaskGroupRepository(); + InMemoryTaskPool taskPool = new InMemoryTaskPool(repository); + taskPool.submit(new SubTask("task-1", "group-1", "parent-1", "A", "desc", "", 1L)); + + SubTask claimed = taskPool.claimNext("agent-1").orElseThrow(); + taskPool.markRunning(claimed.getTaskId(), "agent-1"); + taskPool.markSucceeded(claimed.getTaskId(), "agent-1", "done", "detail"); + + assertThatThrownBy(() -> taskPool.markFailed(claimed.getTaskId(), "agent-1", "boom")) + .isInstanceOf(InvalidTaskStateTransitionException.class) + .hasMessageContaining("Task already finished"); + } } From c9200dbadf07bf52c4ac26383a178025455f8957 Mon Sep 17 00:00:00 2001 From: SiCheng Zhang <964389211@qq.com> Date: Tue, 9 Jun 2026 20:40:11 +0800 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=A4=9Aagent=E6=8C=89=E9=92=AE=E3=80=81git=20worktre?= =?UTF-8?q?e=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 12 +- pom.xml | 23 + .../docs/AGENT_TEAM_GIT_WORKTREE_PLAN.md | 623 ++++++++++++++++++ .../domain/model/GitRepositoryRuntime.java | 20 + .../domain/model/GitWorktreeInfo.java | 12 + .../port/GitWorktreeProvisioningPort.java | 21 + .../agentteam/infra/GitCommandResult.java | 8 + .../agentteam/infra/GitCommandRunner.java | 9 + .../GitWorktreeProvisioningException.java | 15 + .../infra/GitWorktreeProvisioningService.java | 275 ++++++++ .../infra/ProcessGitCommandRunner.java | 45 ++ .../GitWorktreeProvisioningServiceTest.java | 107 +++ 12 files changed, 1169 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/openmanus/agentteam/docs/AGENT_TEAM_GIT_WORKTREE_PLAN.md create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/GitRepositoryRuntime.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/model/GitWorktreeInfo.java create mode 100644 src/main/java/com/openmanus/agentteam/domain/port/GitWorktreeProvisioningPort.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/GitCommandResult.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/GitCommandRunner.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/GitWorktreeProvisioningException.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/GitWorktreeProvisioningService.java create mode 100644 src/main/java/com/openmanus/agentteam/infra/ProcessGitCommandRunner.java create mode 100644 src/test/java/com/openmanus/agentteam/infra/GitWorktreeProvisioningServiceTest.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cae297..b373426 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ export default function App(): JSX.Element { const [state, dispatch] = useReducer(workflowReducer, initialWorkflowState); const [input, setInput] = useState(''); const [browserMode, setBrowserMode] = useState<'web' | 'snapshot' | 'vnc'>('web'); + const [useAgentTeam, setUseAgentTeam] = useState(true); const [useProxy, setUseProxy] = useState(true); const [showToolPanel, setShowToolPanel] = useState(true); const [activeToolTab, setActiveToolTab] = useState<'search' | 'status' | 'output'>('search'); @@ -100,7 +101,7 @@ export default function App(): JSX.Element { const startData = await startWorkflow({ input: content, sessionId: state.sessionId || undefined, - agentTeam: true + agentTeam: useAgentTeam }); const sessionId = startData.session_id || startData.sessionId || ''; const topic = startData.topic || ''; @@ -359,6 +360,15 @@ export default function App(): JSX.Element { placeholder="Type a message, Ctrl/⌘+Enter to send" />
+
void sendMessage(event)}> + setTargetRepositoryPath(event.target.value)} + placeholder="Target repository path for Team Coding (optional)" + disabled={state.loading} + />