From e8b857f3ae58c797910add0cc728afaf9c6fc716 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 02:28:47 +0800 Subject: [PATCH 01/19] =?UTF-8?q?docs(multi-agent):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=A4=9A=E6=99=BA=E8=83=BD=E4=BD=93=E6=A8=A1=E5=BC=8F=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=96=87=E6=A1=A37=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/multi_agent_mode/01_overview.md | 227 +++++++++ docs/multi_agent_mode/02_message_protocol.md | 249 ++++++++++ docs/multi_agent_mode/03_tool_definitions.md | 470 ++++++++++++++++++ docs/multi_agent_mode/04_message_routing.md | 222 +++++++++ docs/multi_agent_mode/05_data_model.md | 304 +++++++++++ .../06_implementation_plan.md | 235 +++++++++ .../07_existing_code_analysis.md | 277 +++++++++++ 7 files changed, 1984 insertions(+) create mode 100644 docs/multi_agent_mode/01_overview.md create mode 100644 docs/multi_agent_mode/02_message_protocol.md create mode 100644 docs/multi_agent_mode/03_tool_definitions.md create mode 100644 docs/multi_agent_mode/04_message_routing.md create mode 100644 docs/multi_agent_mode/05_data_model.md create mode 100644 docs/multi_agent_mode/06_implementation_plan.md create mode 100644 docs/multi_agent_mode/07_existing_code_analysis.md diff --git a/docs/multi_agent_mode/01_overview.md b/docs/multi_agent_mode/01_overview.md new file mode 100644 index 0000000..f02193b --- /dev/null +++ b/docs/multi_agent_mode/01_overview.md @@ -0,0 +1,227 @@ +# 多智能体模式总体架构 + +> 文档状态:设计草案 +> 适用范围:宿主机模式(host mode),不覆盖 docker/web 模式 +> 隔离原则:与现有对话系统完全隔离,复用底层能力但上层独立 + +--- + +## 1. 设计目标 + +在 Astrion 中引入一个实验性的「多智能体模式」: + +- 主智能体固定扮演 **Team Leader**,负责任务拆解、调度、协调、回答子智能体提问。 +- 支持预置角色与自定义角色,每个角色可以有多个实例(`RoleName_1`、`RoleName_2`)。 +- 子智能体之间、子智能体与主智能体之间可以双向通信。 +- 所有通信遵循统一的消息格式与路由规则。 +- 与现有单智能体模式完全隔离,不影响现有代码与用户体验。 + +--- + +## 2. 核心设计原则 + +### 2.1 完全隔离 + +- 数据目录隔离:`~/.astrion/astrion/host/mutiagents/` +- 页面隔离:新页面 `/multiagent/new` +- 入口隔离:登录页单独按钮「多智能体模式 beta」 +- 代码隔离:所有新增代码放在 `modules/multi_agent/`、`docs/multi_agent_mode/`、`static/src/views/MultiAgentView.vue` 等独立位置 +- 现有文件如需改造,优先复制一份再改,不直接修改 + +### 2.2 能复用则复用 + +- 模型调用:复用 `DeepSeekClient` +- 工具执行链路:复用 `WebTerminal.handle_tool_call` 的底层执行能力 +- 文件/终端/搜索等工具:复用现有工具实现 +- 对话持久化格式:复用 `ConversationManager` 的存储格式 +- 沙箱/权限机制:复用现有 `evaluate_tool_permission` 与宿主机沙箱 + +### 2.3 子智能体即完整对话 + +每个子智能体是一个独立的、完整的对话上下文: + +- 有自己的 `messages` 列表 +- 有自己的系统提示词 +- 有自己的工具列表 +- 自然输出 assistant 消息 +- 支持被主智能体/其他子智能体在运行中插入消息引导 +- 任务结束 = 本轮自然停止输出,但上下文保留 + +--- + +## 3. 角色与实例 + +### 3.1 角色(Role) + +角色由 Markdown + YAML Frontmatter 定义,存储在: + +``` +~/.astrion/astrion/host/mutiagents/agents/.md +``` + +示例 `ui-operator.md`: + +```markdown +--- +id: ui-operator +name: UI Operator +description: 负责前端设计、UI 还原、配色方案 +model: qwen3-max +thinking_mode: fast +--- + +你是团队的前端设计专家。你擅长: +- 根据需求设计 UI 界面 +- 制定配色方案 +- 输出设计文档和前端代码 + +工作风格: +- 先分析需求,再给出设计方案 +- 输出简洁明确的设计说明 +``` + +### 3.2 实例(Agent) + +- `role_id`:角色类型,如 `ui-operator` +- `agent_id`:这个角色的第几个实例,从 1 开始 +- 显示名:`{Role Name}_{agent_id}`,如 `UI Operator_1`、`Full-Stack Engineer_1` +- 同一个 `role_id` 可以有多个实例:UI Operator_1、UI Operator_2 +- 不同 `role_id` 的实例序号独立:Full-Stack Engineer_1 与 UI Operator_1 可以同时存在 +- 主智能体固定显示名为 `Team Leader`,没有 agent_id + +--- + +## 4. 总体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户(Web 前端) │ +│ /multiagent/new 页面 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MultiAgentTerminal │ +│ (基于 MainTerminal 复制改造) │ +│ - 工具列表:主工具 + 多智能体专用工具 │ +│ - 子智能体管理器:MultiAgentSubAgentManager │ +│ - 对话存储:指向 mutiagents/conversations/ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Agent 1 │ │ Agent 2 │ │ Agent 3 │ + │UIOperator│ │ Full- │ │ Code │ + │ 1 │ │ Stack │ │ Reviewer │ + │ │ │Engineer1│ │ 1 │ + └──────────┘ └──────────┘ └──────────┘ + │ │ │ + └─────────────────┴─────────────────┘ + │ + ▼ + ┌───────────────────────────────┐ + │ MessageRouter │ + │ 根据接收方状态决定: │ + │ - inline 插入工具结果后 │ + │ - 空闲时作为 user 消息触发新任务│ + └───────────────────────────────┘ +``` + +--- + +## 5. 主智能体职责 + +主智能体 = Team Leader,其系统提示词中明确: + +- 除非任务极其简单或明确不需要,否则主动拆解任务并调用子智能体。 +- 为每个子任务选择最合适的角色(role_id)。 +- 子智能体可以向你提问,你必须及时通过 `answer_sub_agent_question` 回答。 +- 子智能体之间可以互相沟通,你负责监督整体进度。 +- 如果子智能体的中间输出需要干预,使用 `send_message_to_sub_agent` 引导。 + +--- + +## 6. 子智能体职责 + +子智能体系统提示词由两部分组成: + +1. **基础 prompt**:通用团队规则 +2. **自定义 prompt**:角色专属设定 + +基础 prompt 中明确: + +- 你是智能体集群团队的一员。 +- 不要频繁输出内容,不重要内容会污染主智能体上下文。 +- 只汇报关键步骤。 +- 任务完成后给出详细结论。 +- 需要主智能体决策时,使用 `ask_master`。 +- 需要与其他子智能体沟通时,使用 `ask_other_agent` / `answer_other_agent`。 +- 如果向其他子智能体提问,必须同时直接向主智能体输出汇报。 + +--- + +## 7. 与现有系统的边界 + +| 现有系统 | 多智能体模式处理方式 | +|---------|---------------------| +| 现有对话页面 | 不改动,新增 `/multiagent/new` | +| 现有 `MainTerminal` | 不改动,复制为 `MultiAgentTerminal` | +| 现有 `SubAgentManager` | 不改动,继承创建 `MultiAgentSubAgentManager` | +| 现有 `SubAgentTask` | 不改动,继承创建 `MultiAgentSubAgentTask` | +| 现有 `ConversationManager` | 不改动,多智能体模式用自己的存储目录 | +| 现有后台通知池机制 | 参考其设计,多智能体模式实现自己的消息路由 | +| 现有工具执行链路 | 复用 `execute_tool_for_sub_agent` | +| 现有沙箱/权限 | 复用,不额外限制子智能体工具参数 | + +--- + +## 8. 入口与数据目录 + +### 8.1 入口 + +登录页面增加按钮:「多智能体模式 beta」。 + +点击后进入 `/multiagent/new` 页面,加载 `MultiAgentTerminal`。 + +### 8.2 数据目录 + +``` +~/.astrion/astrion/host/mutiagents/ +├── agents/ # 角色定义 +├── conversations/ # 多智能体会话 +│ └── / +│ ├── metadata.json +│ ├── messages.json # Team Leader 对话 +│ └── agents/ +│ └── / +│ ├── metadata.json +│ └── messages.json # 子智能体完整对话记录 +└── state.json # 全局状态 +``` + +子智能体对话记录的保存精度、时机、格式与主智能体对话记录一致。 + +--- + +## 9. 实现顺序(非严格 phase) + +1. 创建独立目录与数据存储结构 +2. 实现角色配置加载与预置角色 +3. 实现 `MultiAgentSubAgentTask` / `MultiAgentSubAgentManager` +4. 实现 `MultiAgentTerminal` 与多智能体工具定义 +5. 实现消息路由(inline / idle / 通知池) +6. 实现前端 `/multiagent/new` 页面 +7. 登录页加入口按钮 +8. 联调测试 + +--- + +## 10. 相关文档 + +- `02_message_protocol.md`:统一消息格式 +- `03_tool_definitions.md`:工具定义 +- `04_message_routing.md`:消息路由机制 +- `05_data_model.md`:数据模型 +- `06_implementation_plan.md`:实现细节 +- `07_existing_code_analysis.md`:现有代码分析 diff --git a/docs/multi_agent_mode/02_message_protocol.md b/docs/multi_agent_mode/02_message_protocol.md new file mode 100644 index 0000000..44c5863 --- /dev/null +++ b/docs/multi_agent_mode/02_message_protocol.md @@ -0,0 +1,249 @@ +# 多智能体模式消息协议 + +> 所有插入对话的 user 消息必须遵循统一格式。 +> 不使用 `[系统通知|xxx]` 前缀,改用自然语言前缀。 + +--- + +## 1. 消息格式模板 + +所有通信 user 消息都使用以下结构: + +``` +来自 {显示名} 的{消息类型} +id: {消息id} + +<{显示名}> +<{标签}> +{内容} + + +``` + +### 字段说明 + +| 字段 | 说明 | 示例 | +|------|------|------| +| 显示名 | `{Role Name}_{agent_id}` 或 `Team Leader` | `UI Operator_1`、`Full-Stack Engineer_1` | +| 消息类型 | 任务发布 / 任务进度输出 / 任务完成汇报 / 提问 / 消息 / 回答 | 见下表 | +| 消息id | 本次消息的全局唯一标识,用于 answer 时引用 | `ask_fse_001`、`msg_uio_003` | +| 标签 | Task / Output / Ask / Message / Answer | 与消息类型对应 | +| 内容 | 消息正文 | 任意文本 | + +### 消息类型与标签对应表 + +| 消息类型 | 标签 | 发送方 | 接收方 | +|---------|------|--------|--------| +| 任务发布 | `` | Team Leader | 子智能体 | +| 任务进度输出 | `` | 子智能体 | Team Leader | +| 任务完成汇报 | `` | 子智能体 | Team Leader | +| 提问 | `` | 子智能体 | Team Leader / 其他子智能体 | +| 消息 | `` | Team Leader / 子智能体 | 子智能体 | +| 回答 | `` | 子智能体 | 子智能体(返回到 ask 工具结果) | + +--- + +## 2. 主智能体 → 子智能体 + +### 2.1 任务发布 + +当主智能体通过 `create_sub_agent` 或 `send_message_to_sub_agent` 向子智能体发布任务时使用。 + +``` +来自 Team Leader 的任务发布 +id: task_001 + + + +请为项目设计前端配色方案,输出 design.md 到 sub_agent_results/design/。 + + +``` + +### 2.2 后续消息 / 运行中引导 + +当主智能体在子智能体运行期间通过 `send_message_to_sub_agent` 插入引导消息时使用。 + +``` +来自 Team Leader 的消息 +id: msg_tl_002 + + + +先不要创建 API,先确认一下现有的 auth 模块是否可复用。 + + +``` + +### 2.3 主智能体问子智能体(ask_sub_agent) + +当主智能体通过 `ask_sub_agent` 向子智能体提问时使用。 + +``` +来自 Team Leader 的提问 +id: ask_tl_001 + + + +你预计还需要多久完成? + + +``` + +子智能体的回答返回到 `ask_sub_agent` 工具结果中,不插入主智能体对话。 + +--- + +## 3. 子智能体 → 主智能体 + +### 3.1 任务进度输出 + +子智能体在自然输出中汇报进度时,由后端捕获并插入主智能体对话。 + +``` +来自 UI Operator_1 的任务进度输出 +id: out_uio_001 + + + +我现在开始分析现有设计风格和用户需求... + + +``` + +### 3.2 任务完成汇报 + +子智能体本轮自然结束输出时,最后一条输出作为完成汇报插入主智能体对话。 + +``` +来自 UI Operator_1 的任务完成汇报 +id: out_uio_002 + + + +我完成了前端配色的设计,生成了 design.md,主色调为蓝色系。 + + +``` + +**注意**:子智能体不调用 `report_progress` 或 `finish_task` 工具。自然的 assistant 输出即表示进度或完成。 + +### 3.3 子智能体向主智能体提问 + +子智能体通过 `ask_master` 工具向主智能体提问。 + +``` +来自 Full-Stack Engineer_1 的提问 +id: ask_fse_001 + + + +我该怎么处理版本冲突问题?项目里当前用的是什么分支策略? + + +``` + +主智能体通过 `answer_sub_agent_question` 工具回答,回答内容返回到 `ask_master` 工具结果中。 + +--- + +## 4. 子智能体 → 子智能体 + +### 4.1 A 向 B 提问 + +子智能体 A 通过 `ask_other_agent` 向子智能体 B 提问。 + +``` +来自 UI Operator_1 的提问 +id: ask_uio_001 + + + +API 接口已经确定了吗?我需要接口字段来设计表单。 + + +``` + +这条消息插入到 B 的 user 消息中。 + +### 4.2 B 回答 A + +子智能体 B 通过 `answer_other_agent` 回答 A。 + +``` +来自 Full-Stack Engineer_1 的回答 +id: ans_fse_001 + + + +已经确定,见 api.md。用户注册接口为 POST /api/users,字段为... + + +``` + +回答返回到 A 的 `ask_other_agent` 工具结果中。 + +### 4.3 A 向 B 发送普通消息 + +子智能体 A 通过 `ask_other_agent` 问 B,但内容不是需要回答的问题,而是通知。 + +``` +来自 UI Operator_1 的消息 +id: msg_uio_003 + + + +前端页面已经 ready,可以开始对接了。 + + +``` + +B 可以直接通过自然输出回复,回复会返回到 A 的 `ask_other_agent` 工具结果中。 + +--- + +## 5. ID 生成规则 + +| 消息类型 | ID 前缀 | 示例 | +|---------|---------|------| +| 任务发布 | `task_` | `task_001` | +| 任务进度输出 | `out_` | `out_uio_001` | +| 任务完成汇报 | `out_` | `out_uio_002` | +| 子智能体问主智能体 | `ask_` | `ask_fse_001` | +| 主智能体问子智能体 | `ask_` | `ask_tl_001` | +| 子智能体间提问 | `ask_` | `ask_uio_001` | +| 子智能体间回答 | `ans_` | `ans_fse_001` | +| 普通消息 | `msg_` | `msg_tl_002` | + +ID 需要保证在多智能体会话内全局唯一。 + +--- + +## 6. 前端渲染 + +前端消息气泡只显示三部分: + +1. 角色:如 `UI Operator_1` +2. 目的/动作:如 `任务进度输出`、`提问`、`消息` +3. 内容:XML 标签内的文本 + +不显示 XML 标签本身。 + +示例渲染: + +``` +┌────────────────────────────────────┐ +│ UI Operator_1 任务进度输出 │ +│ │ +│ 我现在开始分析现有设计风格... │ +└────────────────────────────────────┘ +``` + +--- + +## 7. 关键约束 + +1. 所有插入对话的 user 消息都必须包含完整的 XML 包裹,便于后端解析和前端渲染。 +2. `id` 放在 XML 外面,不放在 `<>` 属性中。 +3. 子智能体之间的通信不强制同步到主智能体,但子智能体必须在提示词中被要求:向其他子智能体提问时,必须同时直接向主智能体输出汇报。 +4. 回答类消息(`answer_sub_agent_question`、`answer_other_agent`)不插入 user 消息,只返回到对应 ask 工具的结果中。 diff --git a/docs/multi_agent_mode/03_tool_definitions.md b/docs/multi_agent_mode/03_tool_definitions.md new file mode 100644 index 0000000..e2c7f4f --- /dev/null +++ b/docs/multi_agent_mode/03_tool_definitions.md @@ -0,0 +1,470 @@ +# 多智能体模式工具定义 + +> 主智能体工具基于现有主智能体工具,移除旧版子智能体工具,新增多智能体专用工具。 +> 子智能体工具保留现有 8 个基础工具,新增多智能体通信工具。 +> 所有工具定义集中在 `modules/multi_agent/tools/` 目录下,不修改现有 `core/main_terminal_parts/tools_definition/`。 + +--- + +## 1. 主智能体(Team Leader)工具 + +主智能体 = Team Leader,其工具列表 = 现有主智能体工具(去掉旧版子智能体工具) + 多智能体专用工具。 + +### 1.1 从现有工具中移除 + +以下旧版子智能体工具不在多智能体模式中使用: + +- `create_sub_agent`(旧版) +- `close_sub_agent` +- `terminate_sub_agent`(旧版) +- `get_sub_agent_status`(旧版) + +### 1.2 新增/替换的多智能体工具 + +#### `create_sub_agent` + +创建并启动一个子智能体实例。 + +```json +{ + "type": "function", + "function": { + "name": "create_sub_agent", + "description": "创建一个子智能体实例并启动它。一个角色可以有多个实例,如 UI Operator_1、UI Operator_2。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "这个角色的第几个实例,从 1 开始。同一 role_id 下每个编号只能用一次。" + }, + "role_id": { + "type": "string", + "description": "角色 ID,如 ui-operator、full-stack-engineer。" + }, + "task": { + "type": "string", + "description": "任务描述,会作为给子智能体的首条任务发布消息。" + }, + "run_in_background": { + "type": "boolean", + "description": "是否后台运行。多智能体模式下通常直接运行(false),因为需要观察输出。" + }, + "timeout_seconds": { + "type": "integer", + "description": "超时时间,默认 600 秒。" + }, + "thinking_mode": { + "type": "string", + "enum": ["fast", "thinking"], + "description": "思考模式,不指定则使用角色配置。" + }, + "model_key": { + "type": "string", + "description": "模型 key,不指定则使用角色配置。" + } + }, + "required": ["agent_id", "role_id", "task"] + } + } +} +``` + +#### `terminate_sub_agent` + +强制终止指定子智能体实例。 + +```json +{ + "type": "function", + "function": { + "name": "terminate_sub_agent", + "description": "强制终止指定子智能体实例。终止后无法恢复,但已生成的文件保留。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "要终止的子智能体实例编号。" + } + }, + "required": ["agent_id"] + } + } +} +``` + +#### `send_message_to_sub_agent` + +向子智能体发送消息或运行中引导。不等待回答。 + +```json +{ + "type": "function", + "function": { + "name": "send_message_to_sub_agent", + "description": "向指定子智能体发送消息。如果子智能体正在运行,消息会插入到当前输出流中作为引导;如果子智能体空闲,消息会触发新一轮运行。用于运行中纠正、补充上下文、追加指令。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "目标子智能体实例编号。" + }, + "message": { + "type": "string", + "description": "要发送的消息内容。" + } + }, + "required": ["agent_id", "message"] + } + } +} +``` + +#### `ask_sub_agent` + +向子智能体提问并等待回答。 + +```json +{ + "type": "function", + "function": { + "name": "ask_sub_agent", + "description": "向指定子智能体提问并等待其回答。适用于需要子智能体给出明确答复的场景。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "目标子智能体实例编号。" + }, + "question": { + "type": "string", + "description": "问题内容。" + }, + "question_id": { + "type": "string", + "description": "问题唯一 ID,子智能体回答时会引用。" + } + }, + "required": ["agent_id", "question", "question_id"] + } + } +} +``` + +#### `answer_sub_agent_question` + +回答子智能体向主智能体提出的问题。 + +```json +{ + "type": "function", + "function": { + "name": "answer_sub_agent_question", + "description": "回答子智能体提出的问题。回答会返回到子智能体 ask_master 工具的结果中。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "提问的子智能体实例编号。" + }, + "question_id": { + "type": "string", + "description": "问题 ID,与 ask_master 时一致。" + }, + "answer": { + "type": "string", + "description": "回答内容。" + } + }, + "required": ["agent_id", "question_id", "answer"] + } + } +} +``` + +#### `list_active_sub_agents` + +查询当前多智能体会话中所有活跃/可通信的子智能体。 + +```json +{ + "type": "function", + "function": { + "name": "list_active_sub_agents", + "description": "查询当前多智能体会话中所有活跃或可通信的子智能体列表,包括运行中和空闲的实例。", + "parameters": { + "type": "object", + "properties": {} + } + } +} +``` + +返回示例: + +```json +{ + "success": true, + "agents": [ + { + "agent_id": 1, + "role_id": "ui-operator", + "display_name": "UI Operator_1", + "status": "running", + "summary": "设计前端配色方案" + }, + { + "agent_id": 2, + "role_id": "full-stack-engineer", + "display_name": "Full-Stack Engineer_1", + "status": "idle", + "summary": "等待 API 接口确认" + } + ] +} +``` + +#### `get_sub_agent_status` + +查询指定子智能体的详细状态。 + +```json +{ + "type": "function", + "function": { + "name": "get_sub_agent_status", + "description": "查询指定子智能体实例的详细状态、统计和最近输出。", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "子智能体实例编号。" + } + }, + "required": ["agent_id"] + } + } +} +``` + +#### `create_custom_agent` + +创建并保存自定义角色。 + +```json +{ + "type": "function", + "function": { + "name": "create_custom_agent", + "description": "创建一个自定义角色并保存到本地,后续可通过 role_id 调用。", + "parameters": { + "type": "object", + "properties": { + "role_id": { + "type": "string", + "description": "角色 ID,唯一标识。" + }, + "name": { + "type": "string", + "description": "角色显示名。" + }, + "description": { + "type": "string", + "description": "角色简短描述。" + }, + "model": { + "type": "string", + "description": "默认使用的模型。" + }, + "thinking_mode": { + "type": "string", + "enum": ["fast", "thinking"], + "description": "默认思考模式。" + }, + "prompt": { + "type": "string", + "description": "角色专属 prompt。" + } + }, + "required": ["role_id", "name", "description", "prompt"] + } + } +} +``` + +#### `list_agents` + +列出所有可用角色。 + +```json +{ + "type": "function", + "function": { + "name": "list_agents", + "description": "列出所有可用的预置和自定义角色。", + "parameters": { + "type": "object", + "properties": {} + } + } +} +``` + +--- + +## 2. 子智能体工具 + +子智能体保留现有 8 个基础工具: + +- `read_file` +- `write_file` +- `edit_file` +- `run_command` +- `web_search` +- `extract_webpage` +- `search_workspace` +- `read_mediafile` + +新增以下多智能体通信工具: + +### 2.1 `ask_master` + +向主智能体(Team Leader)提问,阻塞等待回答。 + +```json +{ + "type": "function", + "function": { + "name": "ask_master", + "description": "向主智能体(Team Leader)提问。主智能体回答后会将结果返回到此工具调用中。", + "parameters": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "问题内容。" + }, + "question_id": { + "type": "string", + "description": "问题唯一 ID。" + } + }, + "required": ["question", "question_id"] + } + } +} +``` + +### 2.2 `ask_other_agent` + +向其他子智能体提问或发送消息,阻塞等待回复。 + +```json +{ + "type": "function", + "function": { + "name": "ask_other_agent", + "description": "向其他子智能体提问或发送消息。对方回复后会将结果返回到此工具调用中。注意:向其他子智能体提问时,必须同时直接向主智能体输出汇报。", + "parameters": { + "type": "object", + "properties": { + "target_agent_id": { + "type": "integer", + "description": "目标子智能体实例编号。" + }, + "content": { + "type": "string", + "description": "提问或消息内容。" + }, + "message_id": { + "type": "string", + "description": "消息唯一 ID。" + } + }, + "required": ["target_agent_id", "content", "message_id"] + } + } +} +``` + +### 2.3 `answer_other_agent` + +回答其他子智能体的问题。 + +```json +{ + "type": "function", + "function": { + "name": "answer_other_agent", + "description": "回答其他子智能体的问题。回答会返回到对方 ask_other_agent 工具的结果中。", + "parameters": { + "type": "object", + "properties": { + "source_agent_id": { + "type": "integer", + "description": "提问方的子智能体实例编号。" + }, + "message_id": { + "type": "string", + "description": "对方提问时的 message_id。" + }, + "answer": { + "type": "string", + "description": "回答内容。" + } + }, + "required": ["source_agent_id", "message_id", "answer"] + } + } +} +``` + +### 2.4 `list_active_sub_agents` + +查询当前活跃子智能体。 + +```json +{ + "type": "function", + "function": { + "name": "list_active_sub_agents", + "description": "查询当前多智能体会话中所有活跃或可通信的子智能体。", + "parameters": { + "type": "object", + "properties": {} + } + } +} +``` + +--- + +## 3. 工具与消息映射 + +| 动作 | 工具 | 发送方 | 接收方形式 | +|------|------|--------|-----------| +| 创建子智能体 | `create_sub_agent` | Team Leader | 启动实例,首条消息以任务发布形式插入 | +| 终止子智能体 | `terminate_sub_agent` | Team Leader | 强制停止实例 | +| 向子智能体发消息/引导 | `send_message_to_sub_agent` | Team Leader | 插入子智能体 user 消息(inline 或触发新任务) | +| Team Leader 问子智能体 | `ask_sub_agent` | Team Leader | 插入子智能体 user 消息,等待回复 | +| 子智能体问 Team Leader | `ask_master` | 子智能体 | 插入 Team Leader user 消息,等待回答 | +| Team Leader 回答子智能体 | `answer_sub_agent_question` | Team Leader | 返回到 `ask_master` 工具结果 | +| 子智能体 A 问 B | `ask_other_agent` | A | 插入 B 的 user 消息(inline 或触发新任务) | +| 子智能体 B 回答 A | `answer_other_agent` | B | 返回到 A 的 `ask_other_agent` 工具结果 | +| 查询活跃子智能体 | `list_active_sub_agents` | 任意 | 返回列表 | +| 子智能体输出/汇报 | 自然 assistant 输出 | 子智能体 | 捕获后插入 Team Leader user 消息 | + +--- + +## 4. 工具定义文件位置 + +- 主智能体工具:`modules/multi_agent/tools/master_tools.py` +- 子智能体工具:`modules/multi_agent/tools/agent_tools.py` +- 工具处理:`modules/multi_agent/tools/tool_handlers.py` + +不修改现有 `core/main_terminal_parts/tools_definition.py` 或 `modules/sub_agent/toolkit.py`。 diff --git a/docs/multi_agent_mode/04_message_routing.md b/docs/multi_agent_mode/04_message_routing.md new file mode 100644 index 0000000..c52a5c9 --- /dev/null +++ b/docs/multi_agent_mode/04_message_routing.md @@ -0,0 +1,222 @@ +# 多智能体模式消息路由机制 + +> 消息路由由**接收方**决定:根据接收方当前状态,选择 inline 插入工具结果后,或作为 user 消息触发新一轮任务。 +> 参考现有后台任务通知池机制实现,保证消息不丢失、不错乱、可批量处理。 + +--- + +## 1. 核心思想 + +### 1.1 接收方决定插入方式 + +当一条消息需要发送给某个子智能体(或主智能体)时,路由层只关心接收方当前处于什么状态: + +| 接收方状态 | 插入方式 | 效果 | +|-----------|---------|------| +| 正在运行中,且当前在某次工具调用中阻塞等待 | 把消息作为该工具调用的结果返回 | 子智能体在当前轮次继续执行,立即看到消息 | +| 正在运行中,但不在阻塞等待状态 | 把消息 inline 插入到当前对话上下文末尾 | 子智能体下一轮输出时自然看到 | +| 空闲状态(本轮已自然结束) | 把消息作为普通 user 消息插入 | 触发子智能体新一轮运行 | + +### 1.2 参考现有后台通知池 + +现有后台任务通知机制: + +- **运行期间(inline)**:`server/chat_flow_tool_loop.py` 中 `execute_tool_calls` 末尾调用 `process_sub_agent_updates(..., inline=True)`,把已完成的子智能体任务一次性插入当前 `messages`,不触发新任务。 +- **停止输出后(polling)**:`server/chat_flow_task_main.py` 中 `handle_task_with_sender` 结尾启动 `run_completion_poll`,统一轮询子智能体 + 后台命令完成通知,批量插入 user 消息,只触发一个后续任务。 + +多智能体模式的消息路由借鉴此机制: + +- 每个子智能体有自己的「待处理消息队列」。 +- 子智能体每次进入可接收消息的状态时,从队列中取出所有消息,按顺序处理。 +- 避免逐条消息触发多次「工作 → 停止 → 再工作」循环。 + +--- + +## 2. 消息路由状态机 + +### 2.1 子智能体状态 + +``` + ┌─────────────┐ + │ idle │ 空闲 + └──────┬──────┘ + │ create_sub_agent / send_message_to_sub_agent + ▼ + ┌─────────────┐ + │ running │ 运行中 + │ (normal) │ 正常输出 + └──────┬──────┘ + │ 调用 ask_master / ask_other_agent + ▼ + ┌─────────────┐ + │ waiting │ 阻塞等待回答 + │ (asking) │ + └──────┬──────┘ + │ 收到 answer / 自然结束 + ▼ + ┌─────────────┐ + │ completed │ 本轮结束(上下文保留) + └─────────────┘ +``` + +### 2.2 路由决策 + +```python +def route_message(target_agent_id, message): + agent = get_agent(target_agent_id) + + if agent.state == "waiting" and agent.pending_tool_call: + # 目标正在阻塞等待回答:直接返回到工具结果 + agent.resolve_pending_tool_call(message) + return "resolved" + + if agent.state == "running": + # 目标正在运行:inline 插入到当前对话上下文末尾 + agent.inject_inline_message(message) + return "injected_inline" + + if agent.state in ("idle", "completed"): + # 目标空闲:作为 user 消息插入,触发新一轮运行 + agent.inject_user_message(message) + return "triggered_new_turn" +``` + +--- + +## 3. 关键场景分析 + +### 3.1 子智能体正在输出,主智能体要引导 + +场景: +- UI Operator_1 正在运行,输出了「接下来我会创建新 API...」 +- Team Leader 要立即干预:「先不要创建 API,先确认现有 auth 模块是否可复用。」 + +处理: +- UI Operator_1 状态为 `running`(正常输出,不在阻塞等待) +- `send_message_to_sub_agent` 的消息 inline 插入到 UI Operator_1 的 `messages` 末尾 +- UI Operator_1 下一轮模型调用时会看到这条 user 消息 + +### 3.2 子智能体正在等待回答 + +场景: +- Full-Stack Engineer_1 调用了 `ask_master`,等待 Team Leader 回答 +- Team Leader 调用 `answer_sub_agent_question` + +处理: +- Full-Stack Engineer_1 状态为 `waiting` +- 回答直接返回到 `ask_master` 工具结果中 +- Full-Stack Engineer_1 继续执行 + +### 3.3 子智能体已经完成,主智能体追加任务 + +场景: +- Full-Stack Engineer_1 已经自然结束输出,状态为 `completed` +- Team Leader 要追加新任务 + +处理: +- `send_message_to_sub_agent` 的消息作为普通 user 消息插入 +- 触发 Full-Stack Engineer_1 新一轮运行 + +### 3.4 边界情况:子智能体正在进行最后一轮输出 + +场景: +- UI Operator_1 正在输出最后一段话,后面没有工具调用了 +- Team Leader 此时调用 `send_message_to_sub_agent` + +处理: +- 如果消息到达时 UI Operator_1 还在运行:尝试 inline 插入 +- 但由于这是最后一轮,后面没有工具调用了,inline 的消息不会被模型看到 +- 因此需要在 UI Operator_1 本轮任务结束后,把这条消息作为触发新一轮任务的 user 消息发送 + +实现要点: +- 路由层维护每个子智能体的「待处理消息队列」 +- 子智能体任务自然结束时,检查队列 +- 如果有待处理消息,立即作为 user 消息触发新一轮运行 + +### 3.5 子智能体 A 问 B,B 正在运行 + +场景: +- UI Operator_1 调用 `ask_other_agent(target=2)` 问 Full-Stack Engineer_1 +- Full-Stack Engineer_1 正在运行中 + +处理: +- 如果 Full-Stack Engineer_1 处于 `running` 状态:inline 插入 user 消息 +- Full-Stack Engineer_1 下一轮输出时看到问题 +- 如果 Full-Stack Engineer_1 调用 `answer_other_agent`:回答返回到 UI Operator_1 的 `ask_other_agent` 工具结果 + +--- + +## 4. 待处理消息队列 + +每个子智能体维护一个待处理消息队列 `pending_messages`。 + +```python +@dataclass +class PendingMessage: + id: str + source_display_name: str + source_agent_id: Optional[int] + target_agent_id: int + message_type: str # task / output / ask / message / answer + content: str + question_id: Optional[str] # 用于 answer 匹配 + created_at: float +``` + +### 4.1 队列消费时机 + +1. 子智能体每次模型调用前,先检查队列,把待处理消息合并到 `messages` 中 +2. 子智能体从 `waiting` 状态恢复时,优先消费回答类消息 +3. 子智能体自然结束时,如果有剩余待处理消息,立即触发新一轮运行 + +### 4.2 批量消费 + +参考现有通知池,每次消费时尽可能一次性取出所有可消费消息: + +```python +def consume_pending_messages(agent): + messages = agent.pending_messages.drain_all() + for msg in messages: + formatted = format_message(msg) + agent.messages.append({"role": "user", "content": formatted}) + return len(messages) +``` + +--- + +## 5. 与现有通知池的对比 + +| 维度 | 现有后台通知池 | 多智能体消息路由 | +|------|--------------|----------------| +| 触发源 | 子智能体/后台命令完成 | 子智能体间/主智能体向子智能体发消息 | +| 接收方 | 主智能体对话 | 子智能体对话或主智能体对话 | +| 插入方式 | inline / 触发新任务 | inline / 返回到工具结果 / 触发新任务 | +| 批量处理 | `_collect_pending_completion_notices` 一次性取多条 | 每个子智能体维护自己的待处理队列 | +| 持久化 | 直接插入对话历史 | 先进入队列,再按状态消费并持久化 | + +--- + +## 6. 消息路由实现位置 + +- 核心路由逻辑:`modules/multi_agent/message_router.py` +- 待处理队列:`MultiAgentSubAgentTask.pending_messages` +- 状态管理:`MultiAgentSubAgentTask.state` +- 工具调用等待:`MultiAgentSubAgentTask.pending_tool_calls` + +--- + +## 7. 防丢失机制 + +1. 每条消息都有唯一 `id` +2. 消息进入队列时立即持久化到子智能体 metadata +3. 消费完成后从队列移除并持久化 +4. 子智能体恢复时从 metadata 加载未消费消息 +5. 回答类消息通过 `question_id` / `message_id` 精确匹配 + +--- + +## 8. 关键代码参考 + +- `server/chat_flow_task_support.py`:`inject_runtime_user_message`、`process_sub_agent_updates` +- `server/chat_flow_task_main.py`:`_collect_pending_completion_notices`、`poll_completion_notifications`、`_dispatch_completion_user_notice` +- `server/chat_flow_tool_loop.py`:`execute_tool_calls` 末尾的 `process_sub_agent_updates(..., inline=True)` diff --git a/docs/multi_agent_mode/05_data_model.md b/docs/multi_agent_mode/05_data_model.md new file mode 100644 index 0000000..2d427f9 --- /dev/null +++ b/docs/multi_agent_mode/05_data_model.md @@ -0,0 +1,304 @@ +# 多智能体模式数据模型 + +> 所有数据存储在 `~/.astrion/astrion/host/mutiagents/` 下,与现有系统完全隔离。 +> 子智能体对话记录的保存精度、时机、格式与主智能体对话记录一致。 + +--- + +## 1. 目录结构 + +``` +~/.astrion/astrion/host/mutiagents/ +├── agents/ # 角色定义 +│ ├── ui-operator.md +│ ├── full-stack-engineer.md +│ ├── code-reviewer.md +│ └── researcher.md +├── conversations/ # 多智能体会话 +│ └── / +│ ├── metadata.json # 会话级元数据 +│ ├── messages.json # Team Leader 对话 +│ └── agents/ +│ └── / +│ ├── metadata.json # 子智能体元数据 +│ └── messages.json # 子智能体完整对话 +└── state.json # 全局状态 +``` + +--- + +## 2. 角色定义(Agent Role) + +文件路径:`agents/.md` + +格式:YAML Frontmatter + Markdown 正文 + +```markdown +--- +id: ui-operator +name: UI Operator +description: 负责前端设计、UI 还原、配色方案 +model: qwen3-max +thinking_mode: fast +--- + +你是团队的前端设计专家... +``` + +### Frontmatter 字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | string | 是 | 角色唯一标识,文件名也使用此 id | +| `name` | string | 是 | 角色显示名 | +| `description` | string | 是 | 角色简短描述 | +| `model` | string | 否 | 默认模型 key | +| `thinking_mode` | string | 否 | fast / thinking | + +### 正文 + +正文部分为角色专属 prompt,会拼接在基础 prompt 之后。 + +--- + +## 3. 全局状态(state.json) + +```json +{ + "version": "1.0", + "last_conversation_id": "conv_xxx", + "agent_id_counter": 5, + "created_at": "2026-07-11T21:00:00", + "updated_at": "2026-07-11T21:30:00" +} +``` + +| 字段 | 说明 | +|------|------| +| `version` | 数据格式版本 | +| `last_conversation_id` | 最近一次会话 id | +| `agent_id_counter` | 全局实例编号计数器(可选) | + +--- + +## 4. 会话元数据(metadata.json) + +```json +{ + "id": "conv_20260711_210000_001", + "title": "多智能体任务:设计前端", + "created_at": "2026-07-11T21:00:00", + "updated_at": "2026-07-11T21:30:00", + "project_path": "/Users/jojo/project", + "model_key": "qwen3-max", + "thinking_mode": true, + "run_mode": "thinking", + "status": "active", + "agent_count": 3, + "agent_ids": [1, 2, 3] +} +``` + +| 字段 | 说明 | +|------|------| +| `id` | 会话 id | +| `title` | 会话标题 | +| `created_at` | 创建时间 | +| `updated_at` | 更新时间 | +| `project_path` | 关联项目路径 | +| `model_key` | Team Leader 使用的模型 | +| `thinking_mode` | 是否思考模式 | +| `run_mode` | fast / thinking / deep | +| `status` | active / archived | +| `agent_count` | 当前子智能体数量 | +| `agent_ids` | 当前子智能体 agent_id 列表 | + +--- + +## 5. 主智能体对话(messages.json) + +与现有 `ConversationManager` 保存格式一致。 + +```json +{ + "id": "conv_20260711_210000_001", + "title": "多智能体任务:设计前端", + "created_at": "2026-07-11T21:00:00", + "updated_at": "2026-07-11T21:30:00", + "messages": [ + { + "role": "user", + "content": "帮我设计一个前端页面", + "message_id": "msg_user_001", + "metadata": { + "message_source": "user", + "visibility": "chat", + "starts_work": true + } + }, + { + "role": "assistant", + "content": "我来安排 UI Operator 处理这个任务...", + "message_id": "msg_assistant_001", + "tool_calls": [...] + }, + { + "role": "user", + "content": "来自 UI Operator_1 的任务完成汇报\nid: out_uio_001\n\n\n\n我完成了前端配色的设计...\n\n", + "message_id": "msg_auto_001", + "metadata": { + "message_source": "sub_agent", + "visibility": "compact", + "starts_work": false, + "agent_id": 1 + } + } + ], + "metadata": { ... }, + "token_statistics": { ... } +} +``` + +--- + +## 6. 子智能体元数据(agents//metadata.json) + +```json +{ + "agent_id": 1, + "role_id": "ui-operator", + "display_name": "UI Operator_1", + "status": "running", + "created_at": "2026-07-11T21:05:00", + "updated_at": "2026-07-11T21:15:00", + "task_summary": "设计前端配色方案", + "model_key": "qwen3-max", + "thinking_mode": "fast", + "pending_messages": [ + { + "id": "msg_tl_001", + "source_display_name": "Team Leader", + "source_agent_id": null, + "message_type": "message", + "content": "先不要创建 API...", + "created_at": 1752251700.0 + } + ], + "pending_tool_calls": [ + { + "tool_call_id": "tc_ask_001", + "tool_name": "ask_master", + "question_id": "ask_uio_001", + "created_at": 1752251800.0 + } + ] +} +``` + +| 字段 | 说明 | +|------|------| +| `agent_id` | 实例编号 | +| `role_id` | 角色 id | +| `display_name` | 显示名 | +| `status` | running / waiting / idle / completed / terminated | +| `task_summary` | 当前任务摘要 | +| `pending_messages` | 待处理消息队列 | +| `pending_tool_calls` | 正在等待回答的工具调用 | + +--- + +## 7. 子智能体对话(agents//messages.json) + +保存精度、时机、格式与主智能体对话完全一致。 + +```json +{ + "agent_id": 1, + "role_id": "ui-operator", + "display_name": "UI Operator_1", + "created_at": "2026-07-11T21:05:00", + "updated_at": "2026-07-11T21:15:00", + "messages": [ + { + "role": "system", + "content": "你是智能体集群团队的一员..." + }, + { + "role": "user", + "content": "来自 Team Leader 的任务发布\nid: task_001\n\n\n\n请为项目设计前端配色方案...\n\n", + "message_id": "msg_user_001" + }, + { + "role": "assistant", + "content": "我先分析一下现有设计风格...", + "message_id": "msg_assistant_001" + }, + { + "role": "user", + "content": "来自 Team Leader 的消息\nid: msg_tl_001\n\n\n\n先不要创建 API...\n\n", + "message_id": "msg_user_002" + }, + { + "role": "assistant", + "content": "好的,我先确认现有 auth 模块...", + "message_id": "msg_assistant_002" + } + ] +} +``` + +--- + +## 8. 持久化策略 + +### 8.1 保存时机 + +- 每次模型调用前保存子智能体 metadata 和 messages +- 每次工具调用后保存 +- 每次状态变更时保存 +- 每次收到外部消息并处理后保存 + +### 8.2 原子写入 + +参考 `ConversationManager._atomic_write_json`,使用临时文件 + replace: + +```python +def _atomic_write_json(path: Path, data: dict): + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) +``` + +### 8.3 索引 + +参考 `ConversationManager` 的索引机制,在 `conversations/index.json` 中维护会话列表: + +```json +{ + "conv_20260711_210000_001": { + "title": "多智能体任务:设计前端", + "created_at": "2026-07-11T21:00:00", + "updated_at": "2026-07-11T21:30:00", + "agent_count": 3 + } +} +``` + +--- + +## 9. 与现有 ConversationManager 的关系 + +多智能体模式实现自己的 `MultiAgentConversationStore`,但: + +- 数据格式与 `ConversationManager` 保持一致 +- 文件结构与 `ConversationManager` 类似 +- 不修改 `utils/conversation_manager/` 任何文件 +- 可以复制 `ConversationManager` 的 CRUD 代码到 `MultiAgentConversationStore` + +--- + +## 10. 实现位置 + +- 角色存储:`modules/multi_agent/agent_store.py` +- 会话存储:`modules/multi_agent/conversation_store.py` +- 全局状态:`modules/multi_agent/manager.py` diff --git a/docs/multi_agent_mode/06_implementation_plan.md b/docs/multi_agent_mode/06_implementation_plan.md new file mode 100644 index 0000000..d20f036 --- /dev/null +++ b/docs/multi_agent_mode/06_implementation_plan.md @@ -0,0 +1,235 @@ +# 多智能体模式实现计划 + +> 实现顺序不作为严格 phase,而是按依赖关系自然推进。 +> 核心原则:完全隔离、复制改造、底层复用。 + +--- + +## 1. 目录与文件规划 + +### 1.1 后端代码 + +``` +modules/multi_agent/ +├── __init__.py +├── terminal.py # MultiAgentTerminal +├── manager.py # 多智能体会话与全局状态管理 +├── agent_store.py # 角色配置加载与保存 +├── conversation_store.py # 多智能体会话存储 +├── message_router.py # 消息路由与待处理队列 +├── sub_agent_task.py # MultiAgentSubAgentTask +├── sub_agent_manager.py # MultiAgentSubAgentManager +├── prompts.py # prompt 模板 +└── tools/ + ├── __init__.py + ├── master_tools.py # Team Leader 工具定义 + ├── agent_tools.py # 子智能体工具定义 + └── tool_handlers.py # 工具处理函数 +``` + +### 1.2 前端代码 + +``` +static/src/views/MultiAgentView.vue +static/src/components/multi-agent/ +├── ChatPanel.vue +├── AgentList.vue +├── MessageBubble.vue +└── CreateAgentDialog.vue +``` + +### 1.3 设计文档 + +``` +docs/multi_agent_mode/ +├── 01_overview.md +├── 02_message_protocol.md +├── 03_tool_definitions.md +├── 04_message_routing.md +├── 05_data_model.md +├── 06_implementation_plan.md +└── 07_existing_code_analysis.md +``` + +### 1.4 数据目录 + +``` +~/.astrion/astrion/host/mutiagents/ +├── agents/ +├── conversations/ +└── state.json +``` + +--- + +## 2. 实现步骤 + +### Step 1:基础设施 + +1. 创建 `modules/multi_agent/` 包 +2. 创建 `docs/multi_agent_mode/` 目录(已完成) +3. 创建 `~/.astrion/astrion/host/mutiagents/` 数据目录 +4. 预置 4 个角色文件到 `agents/` + +### Step 2:角色配置 + +1. 实现 `agent_store.py` + - 加载角色 Frontmatter + - 保存自定义角色 + - 列出所有角色 + - 构建完整 prompt(基础 prompt + 自定义 prompt) + +2. 预置角色: + - `ui-operator` + - `full-stack-engineer` + - `code-reviewer` + - `researcher` + +### Step 3:子智能体改造 + +1. 实现 `MultiAgentSubAgentTask`(继承 `SubAgentTask`) + - 覆盖 `_run_loop`,使用多智能体工具列表 + - 覆盖系统提示词生成 + - 捕获 assistant 输出并转发到主智能体 + - 维护 `pending_messages` 队列 + - 维护 `pending_tool_calls` 等待列表 + - 支持 inline 消息注入 + - 支持自然结束(不依赖 finish_task) + +2. 实现 `MultiAgentSubAgentManager`(继承 `SubAgentManager`) + - 创建 `MultiAgentSubAgentTask` 实例 + - 管理所有子智能体状态 + - 提供查询活跃子智能体接口 + - 提供终止实例接口 + +### Step 4:消息路由 + +1. 实现 `message_router.py` + - `route_message(target_agent_id, message)` + - 根据目标状态选择 inline / 工具结果返回 / 触发新任务 + - 处理边界情况(最后一轮输出时收到消息) + - 批量消费待处理消息 + +2. 实现消息格式化函数 + - `format_task_message(display_name, content)` + - `format_output_message(display_name, content)` + - `format_ask_message(display_name, content, message_id)` + - `format_message_message(display_name, content, message_id)` + - `format_answer_message(display_name, content, message_id)` + +### Step 5:主智能体 Terminal + +1. 实现 `MultiAgentTerminal`(基于 `MainTerminal` 复制) + - 移除旧版子智能体工具 + - 添加多智能体专用工具 + - 使用 `MultiAgentSubAgentManager` + - 使用 `MultiAgentConversationStore` + - 实现 `answer_sub_agent_question` 等工具处理 + +2. 实现工具处理函数 + - `create_sub_agent` + - `terminate_sub_agent` + - `send_message_to_sub_agent` + - `ask_sub_agent` + - `answer_sub_agent_question` + - `list_active_sub_agents` + - `get_sub_agent_status` + - `create_custom_agent` + - `list_agents` + +### Step 6:会话存储 + +1. 实现 `MultiAgentConversationStore` + - 复制 `ConversationManager` 的 CRUD 逻辑 + - 数据目录指向 `~/.astrion/astrion/host/mutiagents/conversations/` + - 保存主智能体对话 + - 保存每个子智能体对话 + +### Step 7:前端页面 + +1. 创建 `MultiAgentView.vue` + - 类似现有 ChatView,但专门用于多智能体模式 + - 支持消息气泡按角色/类型渲染 + - 支持显示活跃子智能体列表 + - 支持创建子智能体 + +2. 创建组件 + - `MessageBubble.vue`:渲染统一消息格式 + - `AgentList.vue`:显示子智能体状态 + - `CreateAgentDialog.vue`:创建自定义角色 + +3. 添加路由 `/multiagent/new` + +### Step 8:入口 + +1. 登录页添加「多智能体模式 beta」按钮 +2. 点击后跳转到 `/multiagent/new` +3. 初始化 `MultiAgentTerminal` + +### Step 9:联调测试 + +1. 测试创建子智能体 +2. 测试子智能体自然输出转发 +3. 测试 `send_message_to_sub_agent` 运行中引导 +4. 测试 `ask_master` / `answer_sub_agent_question` +5. 测试 `ask_other_agent` / `answer_other_agent` +6. 测试子智能体对话持久化 +7. 测试角色创建与保存 + +--- + +## 3. 代码隔离清单 + +| 现有文件 | 处理方式 | +|---------|---------| +| `core/main_terminal.py` | 复制为 `modules/multi_agent/terminal.py` | +| `core/main_terminal_parts/tools_definition/agent_tools.py` | 复制改造为 `modules/multi_agent/tools/master_tools.py` | +| `core/main_terminal_parts/tools_execution.py` | 复制需要的部分到 `modules/multi_agent/terminal.py` | +| `modules/sub_agent/manager.py` | 继承创建 `modules/multi_agent/sub_agent_manager.py` | +| `modules/sub_agent/task.py` | 继承创建 `modules/multi_agent/sub_agent_task.py` | +| `modules/sub_agent/toolkit.py` | 复制改造为 `modules/multi_agent/tools/agent_tools.py` | +| `modules/sub_agent/prompts.py` | 复制改造为 `modules/multi_agent/prompts.py` | +| `utils/conversation_manager/*.py` | 复制改造为 `modules/multi_agent/conversation_store.py` | +| `server/chat_flow.py` | 参考实现,为 `/multiagent` 创建新的 API 入口 | +| `server/chat_flow_task_support.py` | 参考 `inject_runtime_user_message` 实现多智能体消息注入 | +| `static/src/views/ChatView.vue` | 复制改造为 `static/src/views/MultiAgentView.vue` | + +--- + +## 4. 复用点清单 + +| 能力 | 复用方式 | +|------|---------| +| 模型调用 | `DeepSeekClient` | +| 工具执行底层 | `WebTerminal.handle_tool_call` 的执行链路 | +| 文件/终端/搜索等工具 | 现有工具函数 | +| 沙箱/权限 | `evaluate_tool_permission` | +| 对话存储格式 | `ConversationManager` 的 JSON 结构 | +| 原子写入 | `_atomic_write_json` 模式 | +| 前端组件 | 复制 ChatView 改造 | + +--- + +## 5. 风险与应对 + +| 风险 | 应对 | +|------|------| +| 子智能体消息丢失 | 待处理队列持久化 + 唯一 id + 消费确认 | +| 子智能体状态错乱 | 状态机清晰 + reconcile 机制 | +| 主智能体上下文爆炸 | 子智能体只汇报关键步骤,详细内容放文件 | +| 多实例并发冲突 | 每个实例独立上下文,独立存储 | +| 与现有代码耦合 | 严格隔离,复制改造 | + +--- + +## 6. 验收标准 + +1. 登录页有「多智能体模式 beta」按钮 +2. 点击后进入 `/multiagent/new` +3. 可以创建至少 2 个不同角色的子智能体 +4. 子智能体输出自动显示在主对话中 +5. Team Leader 可以运行中引导子智能体 +6. 子智能体可以向 Team Leader 提问并收到回答 +7. 子智能体可以向其他子智能体提问并收到回答 +8. 子智能体对话完整保存,刷新后可恢复 +9. 现有单智能体模式不受影响 diff --git a/docs/multi_agent_mode/07_existing_code_analysis.md b/docs/multi_agent_mode/07_existing_code_analysis.md new file mode 100644 index 0000000..73b8bf3 --- /dev/null +++ b/docs/multi_agent_mode/07_existing_code_analysis.md @@ -0,0 +1,277 @@ +# 多智能体模式:现有代码分析 + +> 本文档梳理实现多智能体模式需要理解的现有代码,为复制改造提供依据。 + +--- + +## 1. 子智能体实现 + +### 1.1 核心文件 + +``` +modules/sub_agent/ +├── __init__.py +├── manager.py # SubAgentManager +├── task.py # SubAgentTask +├── toolkit.py # 工具定义 +├── prompts.py # 提示词 +├── state.py # 状态管理 +├── stats.py # 统计 +├── creation.py # 创建参数 +└── tools.py # 本地工具实现 +``` + +### 1.2 SubAgentManager(manager.py) + +- 在主进程内以独立事件循环运行子智能体协程 +- `create_sub_agent()` 创建并启动任务 +- `wait_for_completion()` 阻塞等待完成 +- `terminate_sub_agent()` 强制终止 +- `poll_updates()` 检查已完成任务 +- `execute_tool_for_sub_agent()` 代理工具执行,复用主进程链路 +- `tasks` 字典保存所有任务状态 + +多智能体模式可继承点: +- 创建 `MultiAgentSubAgentManager`,重写 `create_sub_agent` 以创建 `MultiAgentSubAgentTask` +- 扩展任务记录字段:`role_id`、`display_name`、`pending_messages`、`pending_tool_calls` +- 扩展查询接口:`list_active_sub_agents()`、`get_sub_agent_status()` + +### 1.3 SubAgentTask(task.py) + +- `_run_loop()`:LLM 主循环,最多 50 轮 +- `_call_model()`:流式调用模型 +- `_execute_tool()`:通过 manager 执行工具 +- `_finalize_task()`:任务结束时保存 output.json、stats.json、conversation.json +- `FINISH_TOOL`:必须调用 finish_task 才结束 + +多智能体模式改造点: +- 移除 `FINISH_TOOL` 依赖,自然结束输出即任务完成 +- 在 `_run_loop` 中每轮模型调用前消费 `pending_messages` +- 捕获 assistant 输出并转发到主智能体对话 +- 新增 `ask_master`、`ask_other_agent`、`answer_other_agent` 工具处理 +- 维护 `pending_tool_calls`,支持阻塞等待回答 + +### 1.4 工具定义(toolkit.py) + +现有 8 个工具: +- `read_file` +- `write_file` +- `edit_file` +- `run_command` +- `web_search` +- `extract_webpage` +- `search_workspace` +- `read_mediafile` + +多智能体模式可复用这些工具定义,新增通信工具。 + +### 1.5 提示词(prompts.py) + +- `build_user_message()`:给子智能体的任务消息 +- `build_system_prompt()`:子智能体系统提示词 + +多智能体模式需要: +- 新的 `build_system_prompt()`,包含团队规则 +- 新的 `build_user_message()`,使用统一消息格式 +- 角色 prompt 拼接函数 + +--- + +## 2. 后台任务通知池机制 + +### 2.1 核心文件 + +- `server/chat_flow_task_support.py` +- `server/chat_flow_task_main.py` +- `server/chat_flow_tool_loop.py` + +### 2.2 inject_runtime_user_message + +位置:`server/chat_flow_task_support.py` + +功能:运行期/空闲期统一向对话插入一条 user 消息。 + +关键参数: +- `web_terminal`:终端实例 +- `messages`:当前运行中的消息列表(inline 时用) +- `text`:消息文本 +- `source`:消息来源,如 `sub_agent` +- `inline`:True 表示运行期插入,不触发新任务 +- `persist`:是否持久化到对话历史 + +多智能体模式参考点: +- 子智能体输出转发到主智能体时,使用类似机制 +- 但消息格式改为统一 XML 格式,不使用 `[系统通知|sub_agent]` 前缀 + +### 2.3 process_sub_agent_updates + +位置:`server/chat_flow_task_support.py` + +功能:轮询子智能体完成状态,把结果插入当前对话上下文。 + +两种模式: +- `inline=True`:运行期间,插入 `messages` 不触发新任务 +- `inline=False`:空闲期间,触发后续任务 + +多智能体模式参考点: +- 子智能体自然输出捕获后,可以 inline 插入主智能体 `messages` +- 子智能体空闲时收到的消息,作为 user 消息触发新任务 + +### 2.4 _collect_pending_completion_notices + +位置:`server/chat_flow_task_main.py` + +功能:从子智能体和后台命令两路统一取出所有待通知项,按时间排序,一次性处理。 + +关键设计: +- 取出时即就地标记 `notified`,防止重复消费 +- 返回多条通知时,前 N-1 条作为前置通知,最后一条触发新任务 + +多智能体模式参考点: +- 每个子智能体维护自己的待处理消息队列 +- 消费时批量取出,避免多次触发新任务 +- 使用唯一 id 标记已消费 + +### 2.5 poll_completion_notifications + +位置:`server/chat_flow_task_main.py` + +功能:统一轮询器,单工作区只 spawn 一个,避免并发冲突。 + +多智能体模式参考点: +- 多智能体模式也需要一个类似的通知/消息处理循环 +- 但消息源更多(Team Leader → Agent、Agent → Agent、Agent → Team Leader) + +--- + +## 3. 主智能体工具调用链路 + +### 3.1 工具定义 + +位置:`core/main_terminal_parts/tools_definition/agent_tools.py` + +现有主智能体子智能体工具: +- `create_sub_agent` +- `close_sub_agent` +- `terminate_sub_agent` +- `get_sub_agent_status` + +多智能体模式需要移除这些,新增自己的工具定义。 + +### 3.2 工具执行 + +位置:`core/main_terminal_parts/tools_execution.py` 中 `handle_tool_call` + +- 参数预检查 +- 权限检查 +- 根据 tool_name 分发到具体处理函数 +- 返回 JSON 字符串 + +多智能体模式参考点: +- `MultiAgentTerminal.handle_tool_call` 中增加多智能体工具的分支 +- 子智能体工具处理函数放在 `modules/multi_agent/tools/tool_handlers.py` + +### 3.3 WebTerminal 广播 + +位置:`core/web_terminal.py` + +- `handle_tool_call()` 覆盖父类,广播 `tool_execution_start` / `tool_status` / `tool_execution_complete` +- `broadcast()` 发送 WebSocket 事件 + +多智能体模式参考点: +- `MultiAgentTerminal` 可继承 `WebTerminal` 或 `MainTerminal` +- 保留广播能力 + +--- + +## 4. 对话上下文管理 + +### 4.1 ContextManager + +位置:`utils/context_manager/` + +- `ConversationMixin.start_new_conversation()`:创建新对话 +- `ConversationMixin.load_conversation_by_id()`:加载对话 +- `ConversationMixin.save_current_conversation()`:保存当前对话 + +多智能体模式参考点: +- 创建 `MultiAgentContextManager` 或直接用 `MultiAgentConversationStore` +- 保存格式与现有 `messages.json` 一致 + +### 4.2 ConversationManager + +位置:`utils/conversation_manager/` + +- `crud_mixin.py`:`create_conversation`、`save_conversation`、`load_conversation` +- `path_mixin.py`:文件路径管理 +- `index_mixin.py`:索引管理 + +多智能体模式参考点: +- 复制 `ConversationManager` 的实现到 `MultiAgentConversationStore` +- 修改数据目录为 `~/.astrion/astrion/host/mutiagents/conversations/` + +--- + +## 5. Skill 归档机制 + +### 5.1 核心文件 + +`modules/skills_manager.py` + +### 5.2 关键函数 + +- `validate_skill_directory()`:验证 skill 目录 +- `archive_skill_directory()`:移动 skill 到归档目录 +- `_parse_frontmatter()`:解析 YAML Frontmatter +- `_scan_skills_catalog()`:扫描 skill 目录 + +多智能体模式参考点: +- 角色配置使用类似的 Frontmatter 格式 +- 角色归档目录:`~/.astrion/astrion/host/mutiagents/agents/` +- 可以复制 `_parse_frontmatter`、`_scan_skills_catalog` 的实现到 `agent_store.py` + +--- + +## 6. 前端消息渲染 + +### 6.1 现有机制 + +- `server/chat_flow_task_support.py` 中 `inject_runtime_user_message` 发送 `user_message` 事件 +- 前端 `messaging.ts` 处理 `user_message` 事件 +- 根据 `message_source`、`visibility`、`starts_work` 等 metadata 渲染 + +### 6.2 多智能体模式改造点 + +- 多智能体消息也发送 `user_message` 事件 +- 前端根据消息内容中的 XML 解析出角色、类型、内容 +- 渲染为特殊气泡,不显示 XML 标签 + +--- + +## 7. 关键接口总结 + +| 能力 | 现有实现位置 | 多智能体模式实现位置 | +|------|------------|-------------------| +| 子智能体创建/管理 | `modules/sub_agent/manager.py` | `modules/multi_agent/sub_agent_manager.py` | +| 子智能体运行循环 | `modules/sub_agent/task.py` | `modules/multi_agent/sub_agent_task.py` | +| 子智能体工具 | `modules/sub_agent/toolkit.py` | `modules/multi_agent/tools/agent_tools.py` | +| 子智能体提示词 | `modules/sub_agent/prompts.py` | `modules/multi_agent/prompts.py` | +| 主智能体工具定义 | `core/main_terminal_parts/tools_definition/agent_tools.py` | `modules/multi_agent/tools/master_tools.py` | +| 主智能体工具执行 | `core/main_terminal_parts/tools_execution.py` | `modules/multi_agent/terminal.py` | +| 运行时消息注入 | `server/chat_flow_task_support.py` | `modules/multi_agent/message_router.py` | +| 通知池轮询 | `server/chat_flow_task_main.py` | `modules/multi_agent/message_router.py` | +| 对话存储 | `utils/conversation_manager/` | `modules/multi_agent/conversation_store.py` | +| 角色归档 | `modules/skills_manager.py` | `modules/multi_agent/agent_store.py` | +| 前端页面 | `static/src/views/ChatView.vue` | `static/src/views/MultiAgentView.vue` | + +--- + +## 8. 实现注意事项 + +1. **不要修改现有文件**:所有改造在 `modules/multi_agent/` 中完成,通过继承或复制实现。 +2. **状态同步**:子智能体状态变更时需要同步更新 `metadata.json`。 +3. **消息不丢失**:待处理消息队列需要持久化。 +4. **上下文隔离**:每个子智能体独立 messages,不要互相污染。 +5. **模型工具列表**:每轮模型调用前需要重新构造工具列表,确保多智能体通信工具可用。 +6. **自然结束检测**:子智能体某轮没有工具调用且 assistant 输出为空时,认为本轮结束。 +7. **阻塞问答超时**:`ask_master` / `ask_other_agent` 需要设置合理超时,避免永久阻塞。 From 811974d6e71b132fffb1abaf24fc927a6ae98be8 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 03:26:02 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(multi-agent):=20=E5=9C=A8=E7=8E=B0?= =?UTF-8?q?=E6=9C=89=E6=9E=B6=E6=9E=84=E4=B8=8A=E7=9B=B4=E6=8E=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=A4=9A=E6=99=BA=E8=83=BD=E4=BD=93=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 放弃完全隔离策略,改为在现有 MainTerminal/SubAgentManager/SubAgentTask 主链路 按对话级开关 metadata.multi_agent_mode=true 增加多智能体分支。 新增模块: - modules/multi_agent/__init__.py: 模块入口 - modules/multi_agent/role_store.py: 角色 Markdown Frontmatter 解析与归档 - modules/multi_agent/state.py: 多智能体会话状态机与消息格式化 - modules/multi_agent/prompts.py: 主智能体(Team Leader) + 子智能体提示词 - modules/multi_agent/tools.py: 9 个主智能体工具 + 4 个子智能体工具定义 - server/multi_agent.py: /multiagent/new 页面 + /api/multiagent/* 蓝图 现有代码改动: - modules/sub_agent/task.py: 扩展 multi_agent_mode/multi_agent_state/display_name 字段, 增加 ask_master/ask_other_agent/answer_other_agent/list_active_sub_agents 工具处理逻辑, 子智能体自然结束 assistant 输出即本轮结束(不调用 finish_task),上下文保留。 - modules/sub_agent/manager.py: create_sub_agent 增加 multi_agent_mode/role_id/display_name 参数, 增加 get_or_create_multi_agent_state/get_multi_agent_state/inject_message_to_sub_agent/_on_multi_agent_task_done 方法。 - core/main_terminal_parts/tools_definition/agent_tools.py: 多智能体模式下用 modules.multi_agent.tools 替换旧版工具集。 - core/main_terminal_parts/context/messages.py: 多智能体模式下追加 Team Leader 系统提示词。 - core/main_terminal_parts/tools_execution.py: create_sub_agent handler 增加多智能体分支,新增 send_message_to_sub_agent/ask_sub_agent/answer_sub_agent_question/create_custom_agent/list_agents/list_active_sub_agents handler。 - core/web_terminal.py: load_conversation 时检测 metadata.multi_agent_mode 设置 self.multi_agent_mode。 - server/app_legacy.py: 注册 multi_agent_bp 蓝图。 前端改动: - static/src/auth/LoginApp.vue: 登录页增加'多智能体模式(beta)'按钮 - static/src/app/methods/ui/route.ts: 识别 /multiagent/new 和 /multiagent/conv_xxx 路径,进入多智能体模式并创建带 metadata.multi_agent_mode=true 的对话 - static/src/app/state.ts: 增加 multiAgentMode 状态字段 数据: - ~/.astrion/astrion/host/mutiagents/agents/: 4 个预置角色 ui-operator / full-stack-engineer / code-reviewer / researcher - ~/.astrion/astrion/host/mutiagents/conversations/: 会话数据 验证:所有 Python 文件语法检查通过;冒烟测试 test.test_server_refactor_smoke 6 项全通过;前端构建通过(6.04s);模块导入与功能断言测试全部通过。 --- core/main_terminal_parts/context/messages.py | 10 + .../tools_definition/agent_tools.py | 8 + core/main_terminal_parts/tools_execution.py | 275 +++++++++++--- core/web_terminal.py | 8 + modules/multi_agent/__init__.py | 53 +++ modules/multi_agent/prompts.py | 156 ++++++++ modules/multi_agent/role_store.py | 208 +++++++++++ modules/multi_agent/state.py | 339 ++++++++++++++++++ modules/multi_agent/tools.py | 323 +++++++++++++++++ modules/sub_agent/manager.py | 111 +++++- modules/sub_agent/task.py | 129 ++++++- server/app_legacy.py | 2 + server/multi_agent.py | 199 ++++++++++ static/src/app/methods/ui/route.ts | 82 +++++ static/src/app/state.ts | 2 + static/src/auth/LoginApp.vue | 13 + 16 files changed, 1866 insertions(+), 52 deletions(-) create mode 100644 modules/multi_agent/__init__.py create mode 100644 modules/multi_agent/prompts.py create mode 100644 modules/multi_agent/role_store.py create mode 100644 modules/multi_agent/state.py create mode 100644 modules/multi_agent/tools.py create mode 100644 server/multi_agent.py diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index 4306034..dfc9ea5 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -278,6 +278,16 @@ class MessagesMixin: "frozen_disabled_tools_prompt", lambda: self._format_disabled_tool_notice() or "", ) + + # 多智能体模式提示词:当会话 metadata.multi_agent_mode 为真时追加 + if getattr(self, "multi_agent_mode", False): + try: + from modules.multi_agent.prompts import build_multi_agent_master_prompt + multi_agent_prompt = build_multi_agent_master_prompt(self.project_path, base="") + if multi_agent_prompt: + messages.append({"role": "system", "content": multi_agent_prompt}) + except Exception as exc: + logger.warning(f"[messages] 注入多智能体 prompt 失败: {exc}") if disabled_notice: messages.append({ "role": "system", diff --git a/core/main_terminal_parts/tools_definition/agent_tools.py b/core/main_terminal_parts/tools_definition/agent_tools.py index 0c3189d..700bcf5 100644 --- a/core/main_terminal_parts/tools_definition/agent_tools.py +++ b/core/main_terminal_parts/tools_definition/agent_tools.py @@ -91,6 +91,14 @@ DISABLE_LENGTH_CHECK = True class ToolsDefinitionAgentToolsMixin: def _build_agent_tools(self) -> List[Dict]: + # 多智能体模式下,主智能体不再使用旧版的 4 个子智能体工具 + # 而是用 modules/multi_agent/tools.build_master_tools_for_conversation() 返回的新集 + if getattr(self, "multi_agent_mode", False): + try: + from modules.multi_agent.tools import build_master_tools_for_conversation + return build_master_tools_for_conversation() + except Exception as exc: + logger.warning(f"[tools] 加载多智能体工具失败,回退旧版: {exc}") return [ { "type": "function", diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index f80e16e..e6838bd 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1728,66 +1728,245 @@ class MainTerminalToolsExecutionMixin: ) elif tool_name == "create_sub_agent": - result = self.sub_agent_manager.create_sub_agent( - agent_id=arguments.get("agent_id"), - summary=arguments.get("summary", ""), - task=arguments.get("task", ""), - deliverables_dir=arguments.get("deliverables_dir", ""), - run_in_background=arguments.get("run_in_background", False), - timeout_seconds=arguments.get("timeout_seconds"), - thinking_mode=arguments.get("thinking_mode"), - conversation_id=self.context_manager.current_conversation_id - ) + # 多智能体模式:create_sub_agent 走新签名,需要 role_id/display_name/multi_agent_mode + if getattr(self, "multi_agent_mode", False): + role_id = arguments.get("role_id") + if not role_id: + result = {"success": False, "error": "多智能体模式下 create_sub_agent 必须指定 role_id"} + else: + try: + from modules.multi_agent.role_store import load_preset_role + from modules.multi_agent.prompts import build_multi_agent_sub_agent_prompt + role = load_preset_role(role_id) + if not role: + result = {"success": False, "error": f"角色不存在: {role_id}"} + else: + conv_id = self.context_manager.current_conversation_id + multi_agent_state = self.sub_agent_manager.get_or_create_multi_agent_state(conv_id) + # 分配 agent_id:如果未传入则自动递增 + agent_id = arguments.get("agent_id") + if not agent_id: + agent_id = multi_agent_state.next_agent_id_for_role(role_id) + # 构造显示名 + display_name = role.display_name(int(agent_id)) + # 构造多智能体版系统提示词 + workspace_path = str(getattr(self, "project_path", "")) + system_prompt = build_multi_agent_sub_agent_prompt(role.body_prompt, display_name, workspace_path) + # 构造 task_message(作为 Team Leader 的任务发布) + from modules.multi_agent.state import build_master_dispatch_text + task_message = build_master_dispatch_text(arguments.get("task", "")) + # 加载并覆盖作业:multi_agent_mode/sub_agent_manager.create_sub_agent 的作业路径 + summary_text = (arguments.get("summary") or f"{role.name}作业")[:80] + deliverables_dir = arguments.get("deliverables_dir", f"sub_agent_results/agent_{agent_id}") + thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast" + # 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用) + result = self.sub_agent_manager.create_sub_agent( + agent_id=int(agent_id), + summary=summary_text, + task=arguments.get("task", ""), + deliverables_dir=deliverables_dir, + run_in_background=bool(arguments.get("run_in_background", True)), + timeout_seconds=arguments.get("timeout_seconds"), + conversation_id=conv_id, + model_key=role.model_key, + thinking_mode=thinking_mode, + multi_agent_mode=True, + role_id=role_id, + display_name=display_name, + ) + # 在多智能体模式下,主进程 create_sub_agent 总是后台启动, + # 主智能体不需要阻塞等待,而是通过子智能体输出转发拿进度。 + except Exception as exc: + logger.exception("[multi_agent] create_sub_agent failed") + result = {"success": False, "error": str(exc)} + else: + result = self.sub_agent_manager.create_sub_agent( + agent_id=arguments.get("agent_id"), + summary=arguments.get("summary", ""), + task=arguments.get("task", ""), + deliverables_dir=arguments.get("deliverables_dir", ""), + run_in_background=arguments.get("run_in_background", False), + timeout_seconds=arguments.get("timeout_seconds"), + thinking_mode=arguments.get("thinking_mode"), + conversation_id=self.context_manager.current_conversation_id + ) - # 如果不是后台运行,阻塞等待完成 - if not arguments.get("run_in_background", False) and result.get("success"): - task_id = result.get("task_id") - wait_result = self.sub_agent_manager.wait_for_completion( - task_id=task_id, - timeout_seconds=arguments.get("timeout_seconds") - ) - # 合并结果:保留创建元数据,使用执行结果作为主体展示, - # 避免 wait_result 里的 success=False + 旧 message 导致 - # 「create_sub_agent 失败:子智能体 X 已创建」的误导性文案。 - creation_meta = { - "agent_id": result.get("agent_id"), - "task_id": result.get("task_id"), - "deliverables_dir": result.get("deliverables_dir"), - "run_in_background": False, - } - execution_message = ( - wait_result.get("message") - or wait_result.get("system_message") - or result.get("message") - ) - result = { - **result, - **wait_result, - **creation_meta, - "message": execution_message, - } - # 阻塞式执行不需要额外插入 system 消息 - result.pop("system_message", None) - # 标记已通知,避免后续轮询再插入 system 消息 - try: - task = self.sub_agent_manager.tasks.get(task_id) - if isinstance(task, dict): - task["notified"] = True - task["updated_at"] = time.time() - self.sub_agent_manager._save_state() - except Exception: - pass + # 如果不是后台运行,阻塞等待完成 + if not arguments.get("run_in_background", False) and result.get("success"): + task_id = result.get("task_id") + wait_result = self.sub_agent_manager.wait_for_completion( + task_id=task_id, + timeout_seconds=arguments.get("timeout_seconds") + ) + # 合并结果:保留创建元数据,使用执行结果作为主体展示, + # 避免 wait_result 里的 success=False + 旧 message 导致 + # 「create_sub_agent 失败:子智能体 X 已创建」的误导性文案。 + creation_meta = { + "agent_id": result.get("agent_id"), + "task_id": result.get("task_id"), + "deliverables_dir": result.get("deliverables_dir"), + "run_in_background": False, + } + execution_message = ( + wait_result.get("message") + or wait_result.get("system_message") + or result.get("message") + ) + result = { + **result, + **wait_result, + **creation_meta, + "message": execution_message, + } + # 阻塞式执行不需要额外插入 system 消息 + result.pop("system_message", None) + # 标记已通知,避免后续轮询再插入 system 消息 + try: + task = self.sub_agent_manager.tasks.get(task_id) + if isinstance(task, dict): + task["notified"] = True + task["updated_at"] = time.time() + self.sub_agent_manager._save_state() + except Exception: + pass elif tool_name == "terminate_sub_agent": result = self.sub_agent_manager.terminate_sub_agent( agent_id=arguments.get("agent_id") ) + # 多智能体模式:同步状态到 MultiAgentState + if getattr(self, "multi_agent_mode", False): + try: + conv_id = self.context_manager.current_conversation_id + state = self.sub_agent_manager.get_multi_agent_state(conv_id) + if state: + state.mark_status(int(arguments.get("agent_id")), "terminated") + except Exception: + pass elif tool_name == "get_sub_agent_status": result = self.sub_agent_manager.get_sub_agent_status( agent_ids=arguments.get("agent_ids", []) ) + # 多智能体模式专属工具:send_message_to_sub_agent / ask_sub_agent / answer_sub_agent_question / create_custom_agent / list_agents / list_active_sub_agents + elif tool_name == "send_message_to_sub_agent": + if not getattr(self, "multi_agent_mode", False): + result = {"success": False, "error": "该工具仅在多智能体模式下可用"} + else: + try: + from modules.multi_agent.state import build_master_message_to_sub_agent + agent_id = int(arguments.get("agent_id", 0)) + message = arguments.get("message", "") + conv_id = self.context_manager.current_conversation_id + state = self.sub_agent_manager.get_multi_agent_state(conv_id) + if not state: + result = {"success": False, "error": "多智能体状态未就绪"} + else: + # 构造消息文本并插入子对话 + text = build_master_message_to_sub_agent(message) + ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) + if not ok: + result = {"success": False, "error": f"子智能体 {agent_id} 不存在或已结束"} + else: + result = {"success": True, "agent_id": agent_id} + except Exception as exc: + result = {"success": False, "error": str(exc)} + + elif tool_name == "ask_sub_agent": + if not getattr(self, "multi_agent_mode", False): + result = {"success": False, "error": "该工具仅在多智能体模式下可用"} + else: + try: + from modules.multi_agent.state import format_multi_agent_message, TYPE_ASK + agent_id = int(arguments.get("agent_id", 0)) + question = arguments.get("question", "") + timeout = int(arguments.get("timeout_seconds", 600)) + conv_id = self.context_manager.current_conversation_id + state = self.sub_agent_manager.get_multi_agent_state(conv_id) + if not state: + result = {"success": False, "error": "多智能体状态未就绪"} + else: + # 构造提问并插入子对话(子智能体下一轮 assistant 输出作为回答插入主对话) + text = format_multi_agent_message( + display_name="Team Leader", + msg_type=TYPE_ASK, + content=question, + target=state.get_instance(agent_id).display_name if state.get_instance(agent_id) else f"Agent_{agent_id}", + ) + ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) + if not ok: + result = {"success": False, "error": f"子智能体 {agent_id} 不存在"} + else: + # 阻塞等待子智能体下一轮输出作为回答 + answer = await state.wait_for_answer( + question_id=f"ask_sub_agent_{int(time.time())}", + agent_id=agent_id, + timeout=timeout, + ) + result = {"success": True, "answer": answer} + except asyncio.TimeoutError: + result = {"success": False, "error": "等待子智能体回答超时"} + except Exception as exc: + result = {"success": False, "error": str(exc)} + + elif tool_name == "answer_sub_agent_question": + if not getattr(self, "multi_agent_mode", False): + result = {"success": False, "error": "该工具仅在多智能体模式下可用"} + else: + try: + question_id = arguments.get("question_id", "") + answer = arguments.get("answer", "") + conv_id = self.context_manager.current_conversation_id + state = self.sub_agent_manager.get_multi_agent_state(conv_id) + if not state: + result = {"success": False, "error": "多智能体状态未就绪"} + else: + ok = state.provide_answer(question_id, answer) + result = {"success": bool(ok), "question_id": question_id} + except Exception as exc: + result = {"success": False, "error": str(exc)} + + elif tool_name == "create_custom_agent": + try: + from modules.multi_agent.role_store import RoleConfig, save_custom_role, list_roles + role_id = arguments.get("role_id", "").strip() + name = arguments.get("name", "").strip() + body_prompt = arguments.get("body_prompt", "").strip() + description = arguments.get("description", "").strip() + thinking_mode_arg = arguments.get("thinking_mode", "fast") + if not role_id or not name or not body_prompt: + result = {"success": False, "error": "role_id/name/body_prompt 必填"} + else: + existing_ids = {r.role_id for r in list_roles()} + if role_id in existing_ids: + result = {"success": False, "error": f"角色 {role_id} 已存在"} + else: + role = RoleConfig(role_id=role_id, name=name, description=description, body_prompt=body_prompt, thinking_mode=thinking_mode_arg) + f = save_custom_role(role) + result = {"success": True, "role_id": role_id, "file": str(f)} + except Exception as exc: + result = {"success": False, "error": str(exc)} + + elif tool_name == "list_agents": + try: + from modules.multi_agent.role_store import list_roles + roles = list_roles() + result = {"success": True, "roles": [r.to_dict() for r in roles]} + except Exception as exc: + result = {"success": False, "error": str(exc)} + + elif tool_name == "list_active_sub_agents": + try: + conv_id = self.context_manager.current_conversation_id + state = self.sub_agent_manager.get_multi_agent_state(conv_id) + if not state: + result = {"success": True, "agents": []} + else: + result = {"success": True, "agents": [a.to_dict() for a in state.list_all()]} + except Exception as exc: + result = {"success": False, "error": str(exc)} + elif tool_name == "trigger_easter_egg": result = self.easter_egg_manager.trigger_effect(arguments.get("effect")) diff --git a/core/web_terminal.py b/core/web_terminal.py index dc24349..7f7cbbd 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -347,6 +347,14 @@ class WebTerminal(MainTerminal): self.pending_permission_mode = str(meta.get("pending_permission_mode") or "").strip().lower() or None self.pending_execution_mode = str(meta.get("pending_execution_mode") or "").strip().lower() or None self.pending_network_permission = str(meta.get("pending_network_permission") or "").strip().lower() or None + # 多智能体模式:以会话 metadata.multi_agent_mode 为唯一切换开关 + self.multi_agent_mode = bool(meta.get("multi_agent_mode", False)) + # 同步主智能体 sub_agent_manager 的 开关 + try: + if hasattr(self, "sub_agent_manager"): + self.sub_agent_manager.multi_agent_mode = self.multi_agent_mode + except Exception: + pass except Exception: pass # 重置相关状态 diff --git a/modules/multi_agent/__init__.py b/modules/multi_agent/__init__.py new file mode 100644 index 0000000..d6adab6 --- /dev/null +++ b/modules/multi_agent/__init__.py @@ -0,0 +1,53 @@ +"""多智能体模式核心实现。 + +由 conversations metadata.multi_agent_mode = true 触发启用, +不另起隔离链路,直接复用现有 MainTerminal、SubAgentManager、SubAgentTask +但通过对话级开关注入多智能体版工具集、prompt、消息路由。 + +关键组件: +- RoleConfig:角色 Markdown Frontmatter 解析与归档 +- MultiAgentState:一个多智能体会话的运行态状态机 +- 消息格式构造:format_multi_agent_message +- 工具定义:master 侧、sub_agent 侧扩展 +- prompt 构造:build_multi_agent_master_prompt / build_multi_agent_sub_agent_prompt +""" +from __future__ import annotations + +from .state import MultiAgentState, format_multi_agent_message +from .role_store import ( + RoleConfig, + load_preset_role, + list_roles, + save_custom_role, + build_role_system_prompt, +) +from .prompts import ( + build_multi_agent_master_prompt, + build_multi_agent_sub_agent_prompt, + MULTI_AGENT_MASTER_PROMPT_BODY, + MULTI_AGENT_SUB_AGENT_PROMPT_BODY, +) +from .tools import ( + MULTI_AGENT_MASTER_TOOLS, + MULTI_AGENT_SUB_AGENT_TOOLS, + build_master_tools_for_conversation, + build_sub_agent_tools_for_role, +) + +__all__ = [ + "MultiAgentState", + "format_multi_agent_message", + "RoleConfig", + "load_preset_role", + "list_roles", + "save_custom_role", + "build_role_system_prompt", + "build_multi_agent_master_prompt", + "build_multi_agent_sub_agent_prompt", + "MULTI_AGENT_MASTER_PROMPT_BODY", + "MULTI_AGENT_SUB_AGENT_PROMPT_BODY", + "MULTI_AGENT_MASTER_TOOLS", + "MULTI_AGENT_SUB_AGENT_TOOLS", + "build_master_tools_for_conversation", + "build_sub_agent_tools_for_role", +] \ No newline at end of file diff --git a/modules/multi_agent/prompts.py b/modules/multi_agent/prompts.py new file mode 100644 index 0000000..44256a8 --- /dev/null +++ b/modules/multi_agent/prompts.py @@ -0,0 +1,156 @@ +"""多智能体模式的系统提示词。""" +from __future__ import annotations + +from typing import Optional + + +MULTI_AGENT_MASTER_PROMPT_BODY = """# 多智能体模式 + +你是 **Team Leader**(团队领导者),负责协调多个子智能体分工协作完成用户的复杂任务。 + +## 工作原则 + +- **主动分工**:除非任务极其简单或明确不需要子智能体,否则主动把任务拆解并指派给合适的角色。 +- **明确指令**:用 `send_message_to_sub_agent` 发任务时,写清楚任务目标、范围、产出要求。 +- **及时回答**:当子智能体通过 `ask_master` 提问时,必须尽快通过 `answer_sub_agent_question` 回答。 +- **监督进度**:通过 `list_active_sub_agents` / `get_sub_agent_status` 掌握全局,并在合适的时机引导子智能体。 +- **运行时引导**:看到子智能体作出的步骤需要纠正时,立刻用 `send_message_to_sub_agent` 在其运行期间插入消息干预。 +- **明确问答**:当你需要一个具体的、可被回答的小问题被某个子智能体处理时,用 `ask_sub_agent` 阻塞等待一轮回答。 + +## 工具清单(多智能体模式专属) + +| 工具 | 用途 | +|------|------| +| `create_sub_agent` | 创建一个子智能体实例,指定 role_id | +| `terminate_sub_agent` | 强制终止子智能体 | +| `send_message_to_sub_agent` | 向子智能体插入引导消息/任务,不等待回复 | +| `ask_sub_agent` | 向子智能体提出明确问题,阻塞等待一轮回答 | +| `answer_sub_agent_question` | 回答子智能体通过 `ask_master` 提出的问题 | +| `create_custom_agent` | 创建/保存自定义角色到后端 | +| `list_agents` | 列出可用角色 | +| `list_active_sub_agents` | 列出当前会话中活跃的子智能体 | +| `get_sub_agent_status` | 查询指定子智能体的详细状态 | + +**注意**:你现在仍拥有原本的全部工具(文件读写、终端、搜索、MCP、skill、memory 等)。以上只列出多智能体模式新增的工具——它们**替换**了原有的 `create_sub_agent` / `close_sub_agent` / `get_sub_agent_status`,使用语义有变化。 + +## 你会收到的消息格式 + +子智能体输出(每轮 assistant 文字输出都会通过 user 消息插到你的对话里): + +``` +来自 UI Operator_1 的任务进度输出 +id: out_xxxxxxxx + + + +我现在开始分析现有设计风格... + + +``` + +子智能体向你提问: + +``` +来自 Full-Stack Engineer_1 的提问 +id: ask_fse_001 + + + +我应该使用 JWT 还是 Session Cookie? + + +``` + +**回答提问**必须用 `answer_sub_agent_question` 工具,传入 `question_id`(即消息里的 id)和 answer 文本。回答不会以 user 消息插入,而是直接返回到子智能体的 `ask_master` 工具结果中。 + +## 关于显示名 + +- 主智能体固定显示名:`Team Leader` +- 子智能体显示名:`{角色名}_{agent_id}`,如 `UI Operator_1`、`Full-Stack Engineer_2` +- 一个角色可以有多个实例(同 role_id 多 agent_id) + +## 关于通信协议的三条硬性原则 + +1. **接收方决定插入方式**:子智能体收到消息后,由它自己的状态决定是 inline 穿插还是开启新轮任务。你不要操心插入位置,只负责发起。 +2. **回答走工具结果而非 user 消息**:你回答子智能体提问用的是 `answer_sub_agent_question`,回答内容是工具结果,不需要写出 XML 包裹。 +3. **任务发布/消息/引导都走自然 XML 格式**:调用 `send_message_to_sub_agent` 时只写正文,后端会自动包成 `来自 Team Leader 的消息 / 任务发布` 格式插入子对话。 + +## 关于团队全局可见 + +子智能体之间通过 `ask_other_agent` / `answer_other_agent` 直接通信,会并行在自己的对话内进行。你只需要在 prompt 里要求子智能体「如要向其他子智能体提问,必须同时直接给你输出一条汇报」,这样你能掌握全局。但你**不需要**手动转发它们之间的问答。 +""" + + +MULTI_AGENT_SUB_AGENT_PROMPT_BODY = """# 多智能体身份 + +你是智能体集群团队的一员。你的团队通过分工协作完成复杂任务,主智能体 **Team Leader** 负责督导全局。 + +# 在任务中 + +- 不要频繁输出内容,不重要的内容会污染主智能体上下文 +- 只汇报关键步骤 +- 任务完成后给出详细结论 +- 自然结束输出即本轮任务结束;上下文会被保留,Team Leader 可能会再次发消息让你继续 + +# 沟通工具 + +- **需要 Team Leader 决策时**:调用 `ask_master` 工具,传入 question 文本 + - 工具会阻塞等待 Team Leader 通过 `answer_sub_agent_question` 给出回答 + - 你的 question 会以 XML 「提问」格式被插入主对话 +- **要问其他子智能体时**:调用 `ask_other_agent`,传入 target_agent_id 与 question + - 等待对方调用 `answer_other_agent` 回答 +- **要回答其他子智能体的提问时**:调用 `answer_other_agent`,传入 source_agent_id 与 question_id 和 answer + - 你的回答直接作为对方 `ask_other_agent` 工具的结果返回(不会以 user 消息插入对话) +- **查询当前活跃子智能体**:调用 `list_active_sub_agents` + +# 关于向你团队「汇报」的强制要求 + +**如果你要向其他子智能体提问,必须同时直接输出一条汇报给 Team Leader**(在你的普通文本输出里),说明: +1. 你为什么要问这个问题 +2. 你问了谁 +3. 你期望得到什么 + +不能偷偷沟通,Team Leader 需要看到完整协作流程。 + +# 输出格式 + +你每轮的普通 assistant 文字输出都会被自动捕获并以如下格式插入到主对话: + +``` +来自 {你的显示名} 的任务进度输出 +id: out_xxxxxxxx + +<{你的显示名}> + +{你的输出} + + +``` + +你不需要自己包裹 XML,直接输出正文即可。 +""" + + +def build_multi_agent_master_prompt(workspace_path: str, base: str = "") -> str: + """构造主智能体(Team Leader)的系统提示词。 + + `base` 一般为现有 MainTerminal 的 base 提示词(环境/工具概览等), + 我们在末尾追加多智能体模式专属正文。 + """ + if base and base.strip(): + return f"{base.rstrip()}\n\n{MULTI_AGENT_MASTER_PROMPT_BODY}\n" + return f"{MULTI_AGENT_MASTER_PROMPT_BODY}\n" + + +def build_multi_agent_sub_agent_prompt(role_body: str, display_name: str, workspace_path: str) -> str: + """构造子智能体的系统提示词。 + + `role_body` 为该角色 Markdown 文件 frontmatter 之后的自定义 prompt。 + `display_name` 为该实例的显示名(如 `UI Operator_1`)。 + """ + header = MULTI_AGENT_SUB_AGENT_PROMPT_BODY.rstrip() + return ( + f"{header}\n\n" + f"# 你的显示名\n\n你的显示名是 `{display_name}`。\n\n" + f"# 你的专属设定\n\n{role_body.strip()}\n" + ) \ No newline at end of file diff --git a/modules/multi_agent/role_store.py b/modules/multi_agent/role_store.py new file mode 100644 index 0000000..4e027d4 --- /dev/null +++ b/modules/multi_agent/role_store.py @@ -0,0 +1,208 @@ +"""多智能体角色存储与解析。 + +角色定义文件格式(Markdown + Frontmatter): + + --- + id: ui-operator + name: UI Operator + description: 界面设计与前端实现 + model: "" + thinking_mode: fast + skills: + - frontend-design + --- + + 自定义 prompt body... + +角色目录: +- ~/.astrion/astrion/host/mutiagents/agents/.md + +存储约定见 .astrion/memory/multi_agent_mode_design.md。 +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +try: + from config.paths import IS_HOST_MODE, RUNTIME_ROOT +except ImportError: + # 防止直接 import 时 config 尚未加载,提供回退值 + IS_HOST_MODE = True + RUNTIME_ROOT = str(Path.home() / ".astrion" / "astrion") + +# 复用项目的 MUTIAGENTS 数据目录(保留原拼写 "mutiagents",见项目记忆) +DEFAULT_MUTIAGENTS_DIR = Path(RUNTIME_ROOT) / ("host" if IS_HOST_MODE else "web") / "mutiagents" +AGENTS_DIR_NAME = "agents" + +FRONTMATTER_RE = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n(?P.*)$", re.S) + + +def _agents_dir() -> Path: + """返回角色根目录(按运行模式自动选择 host/web)。""" + base = Path(DEFAULT_MUTIAGENTS_DIR) + out = base / AGENTS_DIR_NAME + out.mkdir(parents=True, exist_ok=True) + return out + + +def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]: + """解析 Markdown frontmatter,返回 (元数据 dict, body)。""" + m = FRONTMATTER_RE.match(text) + if not m: + return {}, text + raw_meta = m.group("body") or "" + body = m.group("rest") or "" + meta: Dict[str, Any] = {} + # 简易解析(k: v 与 k: 列表项,不引入 yaml 依赖) + current_key: Optional[str] = None + for line in raw_meta.splitlines(): + if not line.strip(): + continue + if line.startswith(" - ") or line.startswith("- "): + value = line.lstrip(" ").lstrip("- ").strip() + if current_key and isinstance(meta.get(current_key), list): + meta[current_key].append(value) + continue + if ":" in line: + k, v = line.split(":", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if v: + meta[k] = v + current_key = None + else: + # 可能是列表块 + meta[k] = [] + current_key = k + return meta, body.strip() + + +def load_role_from_file(file_path: Path) -> Optional["RoleConfig"]: + """从 Markdown 文件加载角色定义。""" + if not file_path.exists() or not file_path.is_file(): + return None + text = file_path.read_text(encoding="utf-8") + meta, body = _parse_frontmatter(text) + if not meta.get("id") or not meta.get("name"): + return None + return RoleConfig( + role_id=str(meta["id"]), + name=str(meta["name"]), + description=str(meta.get("description") or ""), + body_prompt=body, + model_key=(str(meta["model"]) if meta.get("model") else None) or None, + thinking_mode=str(meta.get("thinking_mode") or "fast"), + skills=list(meta.get("skills") or []), + source_file=str(file_path), + ) + + +def load_preset_role(role_id: str) -> Optional["RoleConfig"]: + """从预设/自定义角色目录加载一个角色。""" + f = _agents_dir() / f"{role_id}.md" + return load_role_from_file(f) + + +def list_roles() -> List["RoleConfig"]: + """列出全部角色(预设 + 用户自定义)。""" + agents_dir = _agents_dir() + if not agents_dir.exists(): + return [] + roles: List[RoleConfig] = [] + for p in sorted(agents_dir.glob("*.md")): + r = load_role_from_file(p) + if r and r.role_id: + roles.append(r) + return roles + + +def save_custom_role(role: "RoleConfig") -> Path: + """把自定义角色保存到 agents 目录。""" + f = _agents_dir() / f"{role.role_id}.md" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(_serialize_role(role), encoding="utf-8") + return f + + +def _serialize_role(role: "RoleConfig") -> str: + """把 RoleConfig 序列化为 Markdown frontmatter 字符串。""" + lines = ["---"] + lines.append(f"id: {role.role_id}") + lines.append(f"name: {role.name}") + if role.description: + # 单行描述转义 + desc = role.description.replace('"', '\\"') + lines.append(f'description: "{desc}"') + if role.model_key: + lines.append(f"model: {role.model_key}") + lines.append(f"thinking_mode: {role.thinking_mode or 'fast'}") + if role.skills: + lines.append("skills:") + for s in role.skills: + lines.append(f" - {s}") + lines.append("---") + lines.append("") + lines.append(role.body_prompt or "") + return "\n".join(lines) + + +def build_role_system_prompt(role: "RoleConfig") -> str: + """生成子智能体的「专属 prompt」部分(frontmatter 之后的 body)。""" + return role.body_prompt or "" + + +class RoleConfig: + """一个多智能体角色配置。""" + + __slots__ = ( + "role_id", + "name", + "description", + "body_prompt", + "model_key", + "thinking_mode", + "skills", + "source_file", + ) + + def __init__( + self, + *, + role_id: str, + name: str, + description: str = "", + body_prompt: str = "", + model_key: Optional[str] = None, + thinking_mode: str = "fast", + skills: Optional[List[str]] = None, + source_file: str = "", + ): + self.role_id = role_id + self.name = name + self.description = description + self.body_prompt = body_prompt + self.model_key = model_key + self.thinking_mode = thinking_mode or "fast" + self.skills = list(skills or []) + self.source_file = source_file + + def display_name(self, agent_id: int) -> str: + """根据实例 id 生成显示名。""" + return f"{self.name}_{agent_id}" + + def to_dict(self) -> Dict[str, Any]: + return { + "role_id": self.role_id, + "name": self.name, + "description": self.description, + "model_key": self.model_key, + "thinking_mode": self.thinking_mode, + "skills": self.skills, + "source_file": self.source_file, + } + + def __repr__(self) -> str: + return f"" \ No newline at end of file diff --git a/modules/multi_agent/state.py b/modules/multi_agent/state.py new file mode 100644 index 0000000..ddfdece --- /dev/null +++ b/modules/multi_agent/state.py @@ -0,0 +1,339 @@ +"""多智能体会话状态机。 + +一个 MultiAgentState 绑定到一个多智能体对话的 conversation_id,维护: +- 已创建的子智能体实例(agent_id ↔ role_id ↔ display_name ↔ task_id ↔ status) +- 待插入到主对话的待发 user 消息队列(pending_master_messages) +- 主智能体工具调用 answer_sub_agent_question / answer_other_agent 写回答案的 futomap +- 子智能体调用 ask_master / ask_other_agent 时挂起的 futomap + +关键约定(来自 .astrion/memory/multi_agent_mode_design.md): +- 消息格式:`来自 {显示名} 的{类型}\\nid: {消息id}\\n\\n<{显示名}>\\n<{标签}>\\n{内容}\\n\\n` +- 接收方决定插入方式: + - 子智能体 ask 阻塞等待 → main 调 answer_* 返回到工具结果 + - 子智能体 idle 状态 → 主对话的 pending_master_messages 直接插入新轮 user 消息 + - 子智能体 running 中 → inline 插入到当前末尾(在下一轮 model 调用前合并 messages) +- 通信是「工具调用提问」+「回答返回到工具结果」;其他场景(输出/进度/完成/任务发布/消息/回答) + 才以 user 消息格式插入对话。 +""" +from __future__ import annotations + +import asyncio +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from modules.sub_agent.task import SubAgentTask + +# ---------- 消息类型常量 ---------- +TYPE_TASK = "Task" # 主→子 任务发布 +TYPE_OUTPUT = "Output" # 子→主 进度/完成输出(统一) +TYPE_ASK = "Ask" # 子→主 / 子→子 提问 +TYPE_ANSWER = "Answer" # 主→子 / 子→子 回答(不插入对话,仅做工具结果) +TYPE_MESSAGE = "Message" # 任意方向 消息 +# 内部枚举到此 + +QUESTION_PREFIX_ASK_MASTER = "ask_master" +QUESTION_PREFIX_ASK_OTHER = "ask_other" + + +def format_multi_agent_message( + *, + display_name: str, + msg_type: str, + content: str, + msg_id: Optional[str] = None, + target: Optional[str] = None, + extra_attrs: Optional[Dict[str, str]] = None, +) -> str: + """按统一格式构造 user 消息字符串。 + + Args: + display_name: 发出方显示名(如 UI Operator_1 / Team Leader) + msg_type: 消息类型,对应上方 TYPE_* 常量 + content: 消息正文 + msg_id: 消息 id;不传则自动生成 + target: 接收方显示名(用于子→子 提问时标明对谁提问) + extra_attrs: 额外标签属性(如 question_id="ask_xxx") + """ + if not msg_id: + msg_id = f"msg_{uuid.uuid4().hex[:10]}" + + # 第一行:自然语言前缀(含 target 标识) + if target: + prefix = f"来自 {display_name} 向 {target} 的{msg_type_to_text(msg_type)}" + else: + prefix = f"来自 {display_name} 的{msg_type_to_text(msg_type)}" + + # 第二行:id + id_line = f"id: {msg_id}" + + # 属性 attr 字符串 + attrs = "" + if target: + attrs += f' target="{target}"' + if extra_attrs: + for k, v in extra_attrs.items(): + attrs += f' {k}="{v}"' + + # XML 包裹 + tag = msg_type + xml = ( + f"<{display_name}>\n" + f"<{tag}{attrs}>\n" + f"{content}\n" + f"\n" + f"" + ) + + return f"{prefix}\n{id_line}\n\n{xml}" + + +def msg_type_to_text(msg_type: str) -> str: + """把 TYPE_* 转为中文短语,用于 prompt 前缀。""" + mapping = { + TYPE_TASK: "任务发布", + TYPE_OUTPUT: "任务进度输出", + TYPE_ASK: "提问", + TYPE_ANSWER: "回答", + TYPE_MESSAGE: "消息", + } + return mapping.get(msg_type, msg_type) + + +def build_master_dispatch_text(task: str, msg_id: Optional[str] = None) -> str: + """主智能体发布任务时插入到子智能体对话的 user 消息文本。""" + return format_multi_agent_message( + display_name="Team Leader", + msg_type=TYPE_TASK, + content=task, + msg_id=msg_id, + ) + + +def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None) -> str: + """子智能体输出(进度或完成)插入到主对话的 user 消息文本。""" + return format_multi_agent_message( + display_name=display_name, + msg_type=TYPE_OUTPUT, + content=content, + msg_id=msg_id, + ) + + +def build_sub_agent_ask_master_text(display_name: str, question: str, question_id: str) -> str: + """子智能体向主智能体提问时插入到主对话的 user 消息文本。""" + return format_multi_agent_message( + display_name=display_name, + msg_type=TYPE_ASK, + content=question, + msg_id=question_id, + ) + + +def build_sub_agent_ask_other_text( + display_name: str, + target_display: str, + question: str, + question_id: str, +) -> str: + """子智能体向另一个子智能体提问时插入到目标子智能体对话的文本。""" + return format_multi_agent_message( + display_name=display_name, + msg_type=TYPE_ASK, + content=question, + msg_id=question_id, + target=target_display, + ) + + +def build_master_message_to_sub_agent(message: str, msg_id: Optional[str] = None) -> str: + """主智能体 send_message_to_sub_agent 时插入子对话的 user 消息文本。""" + return format_multi_agent_message( + display_name="Team Leader", + msg_type=TYPE_MESSAGE, + content=message, + msg_id=msg_id, + ) + + +def build_master_answer_to_sub_agent( + display_name: str, + target_display: str, + answer: str, + question_id: str, +) -> str: + """主智能体回答插入到子对话(仅当子智能体 not waiting 或 idle 时走 user 消息路径)。""" + return format_multi_agent_message( + display_name=display_name, + msg_type=TYPE_ANSWER, + content=answer, + msg_id=question_id, + target=target_display, + extra_attrs={"question_id": question_id}, + ) + + +# ---------- 运行态状态机 ---------- +@dataclass +class AgentInstance: + """一个多智能体会话中已创建的子智能体实例。""" + + agent_id: int + role_id: str + display_name: str + task_id: str + status: str = "running" # running / idle / terminated / failed / timeout + summary: str = "" + created_at: float = field(default_factory=lambda: datetime.now().timestamp()) + last_output: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "agent_id": self.agent_id, + "role_id": self.role_id, + "display_name": self.display_name, + "task_id": self.task_id, + "status": self.status, + "summary": self.summary, + "created_at": self.created_at, + "last_output": self.last_output, + } + + +class MultiAgentState: + """绑到一个 conversation_id 的多智能体运行态。 + + 线程安全:所有 pubic 方法均假设在 SubAgentManager 的事件循环线程中调用, + 或者由 chat task 主线程通过 manager 的 _run_coro 进入此循环。 + 跨线程访问通过 manager._run_coro 桥接,避免直接调用。 + """ + + def __init__(self, conversation_id: str): + self.conversation_id = conversation_id + # agent_id 映射;同一会话里 agent_id 唯一 + self.agents: Dict[int, AgentInstance] = {} + # task_id -> agent_id(便于在 SubAgentTask 完成时回写) + self.task_id_to_agent_id: Dict[str, int] = {} + # 主智能体待插入消息队列(每条都是字符串,由 chat task 取走) + self.pending_master_messages: List[str] = [] + # ask_master / ask_other_agent 的等待 future + # key = question_id, value = asyncio.Future (结果为 answer str 或 Exception) + self.pending_questions: Dict[str, asyncio.Future] = {} + # 一个 agent 可能同时只阻塞在一个 ask 工具上(最简实现) + # key = agent_id, value = question_id(表示当前 agent 正阻塞等待) + self.agent_blocking_question: Dict[int, str] = {} + # 角色实例计数:role_id -> 已分配的最大 agent_id(数字) + # 用于创建新实例时自动递增编号,但允许调用方显式指定 + self.role_counters: Dict[str, int] = {} + + # ----- 创建/查询 ----- + def next_agent_id_for_role(self, role_id: str) -> int: + """为指定角色分配下一个 agent_id 编号。""" + n = self.role_counters.get(role_id, 0) + 1 + self.role_counters[role_id] = n + return n + + def register_instance(self, instance: AgentInstance) -> None: + if instance.agent_id in self.agents: + raise ValueError(f"agent_id {instance.agent_id} 已存在") + self.agents[instance.agent_id] = instance + self.task_id_to_agent_id[instance.task_id] = instance.agent_id + + def get_instance(self, agent_id: int) -> Optional[AgentInstance]: + return self.agents.get(agent_id) + + def get_instance_by_task_id(self, task_id: str) -> Optional[AgentInstance]: + aid = self.task_id_to_agent_id.get(task_id) + if aid is None: + return None + return self.agents.get(aid) + + def list_active(self) -> List[AgentInstance]: + return [a for a in self.agents.values() if a.status == "running" or a.status == "idle"] + + def list_all(self) -> List[AgentInstance]: + return list(self.agents.values()) + + def mark_status(self, agent_id: int, status: str, last_output: str = "") -> None: + a = self.agents.get(agent_id) + if a: + a.status = status + if last_output: + a.last_output = last_output + + # ----- 主对话注入 ----- + def push_master_message(self, message_text: str) -> None: + """把一条 user 消息追加到主对话待插入队列。""" + self.pending_master_messages.append(message_text) + + def drain_master_messages(self) -> List[str]: + """取出(清空)所有待插入主对话的消息。""" + msgs = self.pending_master_messages + self.pending_master_messages = [] + return msgs + + def has_pending_master_messages(self) -> bool: + return len(self.pending_master_messages) > 0 + + # ----- 阻塞问答 ----- + async def wait_for_answer(self, question_id: str, agent_id: int, timeout: float = 600.0) -> str: + """子智能体 ask_* 工具调用后阻塞等待答案。 + + 返回 answer 字符串;超时/取消抛 asyncio.TimeoutError 或 CancelledError。 + """ + if question_id in self.pending_questions: + raise RuntimeError(f"question_id 已存在: {question_id}") + fut: asyncio.Future = asyncio.get_event_loop().create_future() + self.pending_questions[question_id] = fut + self.agent_blocking_question[agent_id] = question_id + try: + return await asyncio.wait_for(fut, timeout=timeout) + except asyncio.TimeoutError: + raise + finally: + self.pending_questions.pop(question_id, None) + if self.agent_blocking_question.get(agent_id) == question_id: + self.agent_blocking_question.pop(agent_id, None) + + def provide_answer(self, question_id: str, answer: str) -> bool: + """主/其他子智能体 answer_* 工具调用时回写答案。 + + 返回 True 表示找到等待中的 future;False 表示无等待方或已超时。 + """ + fut = self.pending_questions.get(question_id) + if not fut or fut.done(): + return False + try: + fut.set_result(answer) + except asyncio.InvalidStateError: + return False + return True + + def is_agent_blocking(self, agent_id: int) -> bool: + return agent_id in self.agent_blocking_question + + def get_blocking_question_id(self, agent_id: int) -> Optional[str]: + return self.agent_blocking_question.get(agent_id) + + # ----- 持久化(最简版) ----- + def to_snapshot(self) -> Dict[str, Any]: + return { + "conversation_id": self.conversation_id, + "agents": [a.to_dict() for a in self.agents.values()], + "role_counters": self.role_counters, + } + + @classmethod + def from_snapshot(cls, snapshot: Dict[str, Any]) -> "MultiAgentState": + state = cls(conversation_id=snapshot.get("conversation_id", "")) + state.role_counters = dict(snapshot.get("role_counters") or {}) + for a_data in snapshot.get("agents") or []: + a = AgentInstance(**a_data) + state.agents[a.agent_id] = a + if a.task_id: + state.task_id_to_agent_id[a.task_id] = a.agent_id + return state \ No newline at end of file diff --git a/modules/multi_agent/tools.py b/modules/multi_agent/tools.py new file mode 100644 index 0000000..741259a --- /dev/null +++ b/modules/multi_agent/tools.py @@ -0,0 +1,323 @@ +"""多智能体工具定义(OpenAI Function Calling 格式)。 + +主智能体侧(master):替换原有的 4 个旧版子智能体工具,新增 9 个多智能体工具。 +子智能体侧(sub_agent):在现有 8 个基础工具之外新增 4 个通信工具。 + +工具处理函数(handler)不在这里实现,而是在 SubAgentManager/SubAgentTask 中 +注册回调,工具执行入口仍走 SubAgentManager.execute_tool_for_sub_agent。 +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def _inject_intent(properties: Dict[str, Any]) -> Dict[str, Any]: + """为所有 properties 注入 intent 字段(与现有工具规范保持一致)。""" + out: Dict[str, Any] = {} + for k, v in properties.items(): + if k == "intent": + out[k] = v + continue + out[k] = v + # 在末尾追加 intent 字段 + if "intent" not in out: + out["intent"] = { + "type": "string", + "description": "用不超过15个字向用户说明你要做什么,例如:派遣UI Operator设计配色。", + } + return out + + +# ----- 主智能体工具 ----- +def _master_tool_create_sub_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "create_sub_agent", + "description": ( + "创建一个属于多智能体团队的子智能体实例并启动。必须指定 role_id。" + "实例会注入该角色的专属 prompt 与多智能体协作工具(ask_master 等)。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "role_id": { + "type": "string", + "description": "角色标识,例如 'ui-operator'/'full-stack-engineer'/'code-reviewer'/'researcher'。先用 list_agents 查看可用角色。", + }, + "task": { + "type": "string", + "description": "要交给该子智能体执行的任务描述。要求包含:目标、范围、产出、注意事项。", + }, + "agent_id": { + "type": "integer", + "description": "(可选)手动指定实例编号;不传时自动递增。", + }, + "deliverables_dir": { + "type": "string", + "description": "(可选)交付目录相对路径,留空则用 sub_agent_results/agent_{N}。", + }, + "timeout_seconds": {"type": "integer", "description": "超时秒数,默认 600。"}, + "thinking_mode": { + "type": "string", + "enum": ["fast", "thinking"], + "description": "(可选)覆盖角色默认思考模式。不填使用角色配置。", + }, + }), + "required": ["role_id", "task", "thinking_mode"], + }, + }, + } + + +def _master_tool_terminate_sub_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "terminate_sub_agent", + "description": "强制终止指定子智能体实例。终止后无法恢复,已生成的文件保留。", + "parameters": { + "type": "object", + "properties": _inject_intent({ + "agent_id": {"type": "integer", "description": "要终止的子智能体编号。"}, + }), + "required": ["agent_id"], + }, + }, + } + + +def _master_tool_send_message_to_sub_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "send_message_to_sub_agent", + "description": ( + "运行时引导/干预:向指定子智能体插入一条引导消息或新任务,立刻返回不等待回复。" + "用于看到子智能体中间输出后立即纠正方向、追加要求。消息接收方根据当前状态" + "(running/idle)自行决定是 inline 插入还是触发新一轮任务。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "agent_id": {"type": "integer", "description": "目标子智能体编号。"}, + "message": {"type": "string", "description": "要插入的消息或新任务正文。"}, + }), + "required": ["agent_id", "message"], + }, + }, + } + + +def _master_tool_ask_sub_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "ask_sub_agent", + "description": ( + "向指定子智能体提出一个明确问题并阻塞等待一轮回答(不是发起任务,是问问题)。" + "问题会以 `来自 Team Leader 的提问` 格式插入子对话,子智能体下一轮 assistant 输出" + "作为回答返回到此工具结果中。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "agent_id": {"type": "integer", "description": "目标子智能体编号。"}, + "question": {"type": "string", "description": "要询问的问题,应简短明确。"}, + "timeout_seconds": {"type": "integer", "description": "等待回答超时秒数,默认 600。"}, + }), + "required": ["agent_id", "question"], + }, + }, + } + + +def _master_tool_answer_sub_agent_question() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "answer_sub_agent_question", + "description": ( + "回答子智能体通过 ask_master 工具提出的问题。回答内容会直接返回到子智能体" + "ask_master 工具的 tool_call 结果里(不会以 user 消息插入子对话)。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "question_id": {"type": "string", "description": "提问消息里给出的 id,如 ask_xxx。"}, + "answer": {"type": "string", "description": "给子智能体的回答正文。"}, + }), + "required": ["question_id", "answer"], + }, + }, + } + + +def _master_tool_create_custom_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "create_custom_agent", + "description": "创建/保存一个自定义角色到后端(~/.astrion/astrion/host/mutiagents/agents/)。后续可用 create_sub_agent 指定该角色。", + "parameters": { + "type": "object", + "properties": _inject_intent({ + "role_id": {"type": "string", "description": "角色标识(英文小写下划线,如 api-designer)。"}, + "name": {"type": "string", "description": "显示名(如 API Designer)。"}, + "description": {"type": "string", "description": "一句话简述职责。"}, + "body_prompt": {"type": "string", "description": "角色的自定义 prompt body(Markdown)。"}, + "thinking_mode": {"type": "string", "enum": ["fast", "thinking"], "description": "默认思考模式。"}, + }), + "required": ["role_id", "name", "body_prompt"], + }, + }, + } + + +def _master_tool_list_agents() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "list_agents", + "description": "列出所有可用的预置/自定义角色(role_id / name / 描述)。用于在 create_sub_agent 前选角色。", + "parameters": {"type": "object", "properties": _inject_intent({})}, + }, + } + + +def _master_tool_list_active_sub_agents() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "list_active_sub_agents", + "description": "列出当前多智能体会话中所有活跃/已创建的子智能体实例(agent_id/role/display_name/status)。", + "parameters": {"type": "object", "properties": _inject_intent({})}, + }, + } + + +def _master_tool_get_sub_agent_status() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "get_sub_agent_status", + "description": "查询一个或多个子智能体的详细状态。", + "parameters": { + "type": "object", + "properties": _inject_intent({ + "agent_ids": {"type": "array", "items": {"type": "integer"}, "description": "要查询的子智能体编号列表。"}, + }), + "required": ["agent_ids"], + }, + }, + } + + +MULTI_AGENT_MASTER_TOOLS: List[Dict[str, Any]] = [ + _master_tool_create_sub_agent(), + _master_tool_terminate_sub_agent(), + _master_tool_send_message_to_sub_agent(), + _master_tool_ask_sub_agent(), + _master_tool_answer_sub_agent_question(), + _master_tool_create_custom_agent(), + _master_tool_list_agents(), + _master_tool_list_active_sub_agents(), + _master_tool_get_sub_agent_status(), +] + + +# ----- 子智能体工具 ----- +def _sub_tool_ask_master() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "ask_master", + "description": ( + "向 Team Leader(主智能体)提问,工具调用会阻塞等待主智能体通过 answer_sub_agent_question 给出回答。" + "用于需要主智能体决策的场合。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "question": {"type": "string", "description": "向 Team Leader 提问的内容。"}, + "question_id": {"type": "string", "description": "(可选)问题 id;不传自动生成。"}, + }), + "required": ["question"], + }, + }, + } + + +def _sub_tool_ask_other_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "ask_other_agent", + "description": ( + "向另一个子智能体提问,阻塞等待对方调用 answer_other_agent 回答。" + "**注意**:调用此工具的同时,你必须在你的文本输出里向 Team Leader 输出一条汇报," + "说明你为何问、问谁、期望什么——不要偷偷沟通。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "target_agent_id": {"type": "integer", "description": "目标子智能体编号。"}, + "question": {"type": "string", "description": "提问内容。"}, + "question_id": {"type": "string", "description": "(可选)问题 id。"}, + }), + "required": ["target_agent_id", "question"], + }, + }, + } + + +def _sub_tool_answer_other_agent() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "answer_other_agent", + "description": ( + "回答其他子智能体通过 ask_other_agent 提出的问题。" + "answer 内容会直接返回到对方 ask_other_agent 工具结果中。" + ), + "parameters": { + "type": "object", + "properties": _inject_intent({ + "source_agent_id": {"type": "integer", "description": "提问方 agent_id。"}, + "question_id": {"type": "string", "description": "提问消息中的 id。"}, + "answer": {"type": "string", "description": "回答内容。"}, + }), + "required": ["source_agent_id", "question_id", "answer"], + }, + }, + } + + +def _sub_tool_list_active_sub_agents() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "list_active_sub_agents", + "description": "查询当前多智能体会话中所有活跃/已创建的子智能体。", + "parameters": {"type": "object", "properties": _inject_intent({})}, + }, + } + + +MULTI_AGENT_SUB_AGENT_TOOLS: List[Dict[str, Any]] = [ + _sub_tool_ask_master(), + _sub_tool_ask_other_agent(), + _sub_tool_answer_other_agent(), + _sub_tool_list_active_sub_agents(), +] + + +# ----- 构造函数(供现有 tools_definition 调用) ----- +def build_master_tools_for_conversation() -> List[Dict[str, Any]]: + """返回主智能体在多智能体模式下应额外提供的工具列表。""" + return list(MULTI_AGENT_MASTER_TOOLS) + + +def build_sub_agent_tools_for_role() -> List[Dict[str, Any]]: + """返回子智能体在多智能体模式下应额外提供的工具列表。""" + return list(MULTI_AGENT_SUB_AGENT_TOOLS) \ No newline at end of file diff --git a/modules/sub_agent/manager.py b/modules/sub_agent/manager.py index 082d8ed..47c7e44 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -56,6 +56,9 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi self.container_session: Optional["ContainerHandle"] = container_session self.host_execution_mode: str = "sandbox" self.terminal: Optional["WebTerminal"] = None + # 多智能体模式:为每个启用 multi_agent_mode 的会话维护一个 MultiAgentState + # key = conversation_id, value = MultiAgentState + self.multi_agent_states: Dict[str, Any] = {} self.base_dir.mkdir(parents=True, exist_ok=True) self.state_file.parent.mkdir(parents=True, exist_ok=True) @@ -66,6 +69,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi self._event_loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None self._state_lock = threading.Lock() + # agent_id -> SubAgentTask 映射(供多智能体消息注入使用) + self._sub_agent_instances: Dict[int, Any] = {} self._load_state() try: @@ -137,8 +142,16 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi run_in_background: bool = False, model_key: Optional[str] = None, thinking_mode: Optional[str] = None, + multi_agent_mode: bool = False, + role_id: Optional[str] = None, + display_name: Optional[str] = None, ) -> Dict: - """创建子智能体任务并启动协程。""" + """创建子智能体任务并启动协程。 + + 参数 multi_agent_mode: True 时启用多智能体模式。 + 参数 role_id: 多智能体模式下的角色标诶。 + 参数 display_name: 多智能体模式下的显示名(如 UI Operator_1)。 + """ validation_error = self._validate_create_params(agent_id, summary, task, deliverables_dir) if validation_error: return {"success": False, "error": validation_error} @@ -214,6 +227,25 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi self._mark_agent_id_used(conversation_id, agent_id) self._save_state() + # 多智能体模式:为该会话创建或复用 MultiAgentState + multi_agent_state = None + if multi_agent_mode: + multi_agent_state = self.get_or_create_multi_agent_state(conversation_id) + # 把实例注册到 state + from modules.multi_agent.state import AgentInstance + inst = AgentInstance( + agent_id=agent_id, + role_id=role_id or "", + display_name=display_name or f"Agent_{agent_id}", + task_id=task_id, + status="running", + summary=summary, + ) + try: + multi_agent_state.register_instance(inst) + except ValueError: + return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"} + sub_agent = SubAgentTask( manager=self, task_record=task_record, @@ -221,19 +253,30 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi system_prompt=system_prompt, model_key=model_key, thinking_mode=thinking_mode, + multi_agent_mode=multi_agent_mode, + multi_agent_state=multi_agent_state, + display_name=display_name, ) task_coro = sub_agent.run() asyncio_task = self._run_coro(task_coro) sub_agent._task = asyncio_task self._running_tasks[task_id] = asyncio_task + # 缓存 sub_agent 实例供给多智能体模式 Poli注入使用 + self._sub_agent_instances[agent_id] = sub_agent def _on_done(fut): self._running_tasks.pop(task_id, None) + self._sub_agent_instances.pop(agent_id, None) self.reconcile_task_states(conversation_id=conversation_id) + # 多智能体模式:结束时把状态写回 MultiAgentState + if multi_agent_mode and multi_agent_state: + self._on_multi_agent_task_done(task_id, agent_id, multi_agent_state, sub_agent) asyncio_task.add_done_callback(_on_done) message = f"子智能体{agent_id} 已创建,任务ID: {task_id}" + if multi_agent_mode and display_name: + message = f"{display_name} 已创建,任务ID: {task_id}" print(f"{OUTPUT_FORMATS['info']} {message}") return { @@ -244,6 +287,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi "message": message, "deliverables_dir": str(deliverables_path), "run_in_background": run_in_background, + "display_name": display_name, } def wait_for_completion( @@ -482,6 +526,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi return {"success": False, "error": "子智能体管理器未绑定终端,无法执行工具"} try: + # 多智能体模式常见问答工具已在 SubAgentTask._execute_multi_agent_tool 中处理 + # 这里只处理实际通过主进程执行的工具 if tool_name == "search_workspace": return await handle_search_workspace(self.project_path, self.terminal, arguments) if tool_name == "read_mediafile": @@ -497,6 +543,69 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi logger.exception(f"[SubAgent] 工具执行异常: {tool_name}") return {"success": False, "error": f"工具执行异常: {exc}"} + # ------------------------------------------------------------------ + # 多智能体模式:状态管理、外部接口、消息注入 + # ------------------------------------------------------------------ + def get_or_create_multi_agent_state(self, conversation_id: str): + """获取或为该会话创建 MultiAgentState。""" + from modules.multi_agent.state import MultiAgentState + state = self.multi_agent_states.get(conversation_id) + if state: + return state + state = MultiAgentState(conversation_id=conversation_id) + self.multi_agent_states[conversation_id] = state + return state + + def get_multi_agent_state(self, conversation_id: str): + """获取该会话的多智能体状态。""" + return self.multi_agent_states.get(conversation_id) + + def drop_multi_agent_state(self, conversation_id: str) -> None: + """删除会话状态(会话结束时调用)。""" + self.multi_agent_states.pop(conversation_id, None) + + def inject_message_to_sub_agent(self, agent_id: int, message_text: str) -> bool: + """同事件循环中向子智能体上下文插入 user 消息。 + + 适用于 ask_other_agent / send_message_to_sub_agent / answer_sub_agent_question_ + (非阻塞到工具结果的路径)。返回 True 表示成功注入。 + """ + # 查找该 agent_id 对应的 running SubAgentTask + sub_agent = self._find_sub_agent_task_by_agent_id(agent_id) + if not sub_agent: + return False + sub_agent.messages.append({"role": "user", "content": message_text}) + return True + + def _find_sub_agent_task_by_agent_id(self, agent_id: int) -> Optional[Any]: + """通过遍历创建中的 task 查找活 SubAgentTask 实例。 + + 这是个 helper:在主实现中我们需要保留从 agent_id 到 SubAgentTask 的引用。 + 理论上可以在 create_sub_agent 时把 sub_agent 存起来,这里使用 rs safer贪心法: + 遊历 _running_tasks 不为可行,因为 asyncio.Task 不抽不包含 SubAgentTask引用。 + 我们改为 `SubAgentTask` 对象列表供查询。 + """ + # 优先查缓存:create_sub_agent 时的字段 + for inst in self._sub_agent_instances.values(): + if inst.agent_id == agent_id: + return inst + return None + + def _on_multi_agent_task_done(self, task_id: str, agent_id: int, state: Any, sub_agent: Any) -> None: + """SubAgentTask 结束回调会调这个更新 MultiAgentState 实例状态。""" + # 取出当前 status(由 _finalize_task 设置) + final_task = self.tasks.get(task_id) or {} + final_status = final_task.get("status") or "terminated" + # 允许的自然退出:running -> idle(未走 _finalize) vs failed/timeout + # SubAgentTask 在多智能体模式下没有 _finalize_task,我们手动赋值 idle + # 如果状态被结 _finalize 为 failed/timeout,则保持该状态 + if final_status in TERMINAL_STATUSES: + state.mark_status(agent_id, final_status, last_output=str(final_task.get("final_result") or "")) + elif final_status == "terminated": + state.mark_status(agent_id, "terminated") + else: + state.mark_status(agent_id, "idle") + def _get_runtime_path(self, host_path: Path) -> str: """将宿主机路径映射为容器内路径(仅用于提示展示)。""" if not self.container_session or getattr(self.container_session, "mode", None) != "docker": diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index a5d2353..bf20b16 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -21,9 +21,19 @@ from utils.logger import setup_logger if TYPE_CHECKING: from modules.sub_agent.manager import SubAgentManager + from modules.multi_agent.state import MultiAgentState logger = setup_logger(__name__) +# 多智能体模式下额外加载的工具定义 +def _load_multi_agent_sub_agent_tools() -> List[Dict[str, Any]]: + try: + from modules.multi_agent.tools import build_sub_agent_tools_for_role + return build_sub_agent_tools_for_role() + except Exception as exc: + logger.warning(f"[SubAgentTask] 加载多智能体工具失败: {exc}") + return [] + class SubAgentTask: """单个后台子智能体任务。""" @@ -36,6 +46,10 @@ class SubAgentTask: system_prompt: str, model_key: Optional[str], thinking_mode: Optional[str], + *, + multi_agent_mode: bool = False, + multi_agent_state: Optional["MultiAgentState"] = None, + display_name: Optional[str] = None, ): self.manager = manager self.task_record = task_record @@ -73,6 +87,12 @@ class SubAgentTask: self._cancelled = False self._task: Optional[asyncio.Task] = None + # 多智能体模式相关字段 + self.multi_agent_mode = bool(multi_agent_mode) + self.multi_agent_state = multi_agent_state + # display_name 不传时回退为 'Agent_{agent_id}' + self.display_name = display_name or f"Agent_{self.agent_id}" + def emit(self, type_: str, data: Dict[str, Any]) -> None: """输出一行 JSONL 到 progress 文件并缓存。""" line = json.dumps({"type": type_, **data}, ensure_ascii=False) @@ -100,8 +120,13 @@ class SubAgentTask: async def _run_loop(self) -> None: client, model_key = self._build_client() - tools = list(SUB_AGENT_TOOLS) - tools.append(FINISH_TOOL) + if self.multi_agent_mode: + tools = list(SUB_AGENT_TOOLS) + tools.extend(_load_multi_agent_sub_agent_tools()) + # 多智能体模式下不要求 finish_task,自然输出结束即本轮任务结束 + else: + tools = list(SUB_AGENT_TOOLS) + tools.append(FINISH_TOOL) start_time = time.time() max_turns = 50 @@ -123,6 +148,10 @@ class SubAgentTask: if usage: self._apply_usage(usage) + # 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话 + if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip(): + self._forward_output_to_master(assistant_message) + final_message: Dict[str, Any] = {"role": "assistant", "content": assistant_message} if reasoning: final_message["reasoning_content"] = reasoning @@ -131,6 +160,11 @@ class SubAgentTask: self.messages.append(final_message) if not tool_calls: + # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 状态 + if self.multi_agent_mode: + self._mark_idle() + return + # 普通模式:prompt 并要求继续 / finish_task self.messages.append({ "role": "user", "content": "如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。", @@ -165,6 +199,26 @@ class SubAgentTask: await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True) + def _forward_output_to_master(self, output_text: str) -> None: + """把子智能体的 assistant 文本输出转发成主对话的 user 消息。""" + if not self.multi_agent_state: + return + try: + from modules.multi_agent.state import build_sub_agent_output_text + msg = build_sub_agent_output_text(self.display_name, output_text.strip()) + self.multi_agent_state.push_master_message(msg) + # 同时记录到实例状态,供 list_active_sub_agents 使用 + inst = self.multi_agent_state.get_instance(self.agent_id) + if inst: + inst.last_output = output_text[:500] + except Exception as exc: + logger.warning(f"[SubAgentTask] forward output to master failed: {exc}") + + def _mark_idle(self) -> None: + """多智能体模式下,子智能体自然结束']=本轮任务 结束,进入 idle 状态。""" + if self.multi_agent_state: + self.multi_agent_state.mark_status(self.agent_id, "idle") + def _build_client(self) -> tuple: """加载模型配置并初始化 DeepSeekClient。""" config_path = self.manager.models_config_file @@ -262,9 +316,78 @@ class SubAgentTask: return {"_raw": raw} async def _execute_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: - """通过 manager 调用主进程执行工具。""" + """通过 manager 调用主进程执行工具。 + + 多智能体模式下,对于通信工具(ask_master / ask_other_agent / answer_other_agent/ + list_active_sub_agents)在主进程内直接处理,不再转发到 WebTerminal。 + """ + if self.multi_agent_mode and self.multi_agent_state: + result = await self._execute_multi_agent_tool(name, args) + if result is not None: + return result return await self.manager.execute_tool_for_sub_agent(name, args) + async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。""" + state = self.multi_agent_state + if not state: + return None + try: + if name == "ask_master": + question = str(args.get("question") or "").strip() + question_id = str(args.get("question_id") or f"ask_master_{uuid.uuid4().hex[:10]}") + if not question: + return {"success": False, "error": "question 不能为空"} + # 插入到主对话 + from modules.multi_agent.state import build_sub_agent_ask_master_text + msg = build_sub_agent_ask_master_text(self.display_name, question, question_id) + state.push_master_message(msg) + inst = state.get_instance(self.agent_id) + if inst: + inst.last_output = f"[ask_master] {question[:200]}" + # 阻塞等待回答(状态标为正在等待主智能体回答) + state.mark_status(self.agent_id, "running") + answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600)) + state.mark_status(self.agent_id, "running") + return {"success": True, "answer": answer, "question_id": question_id} + + if name == "ask_other_agent": + target_id = int(args.get("target_agent_id") or 0) + question = str(args.get("question") or "").strip() + question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}") + if not target_id or not question: + return {"success": False, "error": "参数缺失"} + # 查找目标实例 + target_inst = state.get_instance(target_id) + if not target_inst: + return {"success": False, "error": f"agent {target_id} 不存在"} + # 构造提问消息并插入到目标子对话;同时要求其在下一轮调用 answer_other_agent + from modules.multi_agent.state import build_sub_agent_ask_other_text + target_display = target_inst.display_name + msg = build_sub_agent_ask_other_text(self.display_name, target_display, question, question_id) + self.manager.inject_message_to_sub_agent(target_id, msg) + # 阻塞等待回答 + answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600)) + return {"success": True, "answer": answer, "question_id": question_id} + + if name == "answer_other_agent": + source_id = int(args.get("source_agent_id") or 0) + question_id = str(args.get("question_id") or "") + answer = str(args.get("answer") or "").strip() + if not question_id or not answer: + return {"success": False, "error": "参数缺失"} + ok = state.provide_answer(question_id, answer) + return {"success": bool(ok), "question_id": question_id} + + if name == "list_active_sub_agents": + return {"success": True, "agents": [a.to_dict() for a in state.list_all()]} + except asyncio.TimeoutError: + return {"success": False, "error": "等待回答超时", "question_id": args.get("question_id")} + except Exception as exc: + logger.exception(f"[SubAgent] 多智能体工具异常: {name}") + return {"success": False, "error": f"多智能体工具异常: {exc}"} + return None + def _update_stats(self, name: str) -> None: if name == "read_file": self.stats["files_read"] += 1 diff --git a/server/app_legacy.py b/server/app_legacy.py index a97ac4e..61af79f 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -33,6 +33,7 @@ from server.usage import usage_bp from server.status import status_bp from server.tasks import tasks_bp from server.api_v1 import api_v1_bp +from server.multi_agent import multi_agent_bp from server.socket_handlers import socketio from server.security import attach_security_hooks from werkzeug.utils import secure_filename @@ -296,6 +297,7 @@ app.register_blueprint(usage_bp) app.register_blueprint(status_bp) app.register_blueprint(tasks_bp) app.register_blueprint(api_v1_bp) +app.register_blueprint(multi_agent_bp) # 安全钩子(CSRF 校验 + 响应头) attach_security_hooks(app) diff --git a/server/multi_agent.py b/server/multi_agent.py new file mode 100644 index 0000000..5c033b8 --- /dev/null +++ b/server/multi_agent.py @@ -0,0 +1,199 @@ +"""多智能体模式 server 路由。 + +- `/multiagent/new` 返回主 SPA 入口(与 `/new` 一样返回 static/index.html) + 前端通过路径识别多智能体模式后,在创建对话时写入 metadata.multi_agent_mode=true +- `/api/multiagent/conversations` POST 创建多智能体对话(写入 metadata) +- `/api/multiagent/roles` GET 列出可用角色 +- `/api/multiagent/roles` POST 创建自定义角色 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List + +from flask import Blueprint, current_app, jsonify, request, session + +from server.auth_helpers import api_login_required, get_current_username +from server.context import get_user_resources + +multi_agent_bp = Blueprint("multi_agent", __name__) + + +@multi_agent_bp.route("/multiagent/new") +@api_login_required +def multi_agent_new_page(): + """多智能体模式入口,返回与 /new 相同的 SPA index.html。""" + return current_app.send_static_file("index.html") + + +@multi_agent_bp.route("/api/multiagent/roles", methods=["GET"]) +@api_login_required +def list_roles_api(): + """列出全部可用角色(预置+自定义)。""" + try: + from modules.multi_agent.role_store import list_roles + roles = list_roles() + return jsonify({ + "success": True, + "roles": [r.to_dict() for r in roles], + }) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@multi_agent_bp.route("/api/multiagent/roles", methods=["POST"]) +@api_login_required +def create_role_api(): + """创建自定义角色。body: { role_id, name, description?, body_prompt, thinking_mode? }""" + try: + from modules.multi_agent.role_store import RoleConfig, save_custom_role, list_roles + data = request.get_json() or {} + role_id = str(data.get("role_id") or "").strip() + name = str(data.get("name") or "").strip() + body_prompt = str(data.get("body_prompt") or "").strip() + description = str(data.get("description") or "").strip() + thinking_mode = str(data.get("thinking_mode") or "fast").strip() + if not role_id or not name or not body_prompt: + return jsonify({"success": False, "error": "role_id/name/body_prompt 必填"}), 400 + if thinking_mode not in {"fast", "thinking"}: + thinking_mode = "fast" + # 不允许覆盖已存在的同名角色 + existing = {r.role_id for r in list_roles()} + if role_id in existing: + return jsonify({"success": False, "error": f"角色 {role_id} 已存在"}), 409 + role = RoleConfig( + role_id=role_id, + name=name, + description=description, + body_prompt=body_prompt, + thinking_mode=thinking_mode, + ) + saved = save_custom_role(role) + return jsonify({"success": True, "role_id": role_id, "file": str(saved)}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@multi_agent_bp.route("/api/multiagent/conversations", methods=["POST"]) +@api_login_required +def create_multi_agent_conversation(): + """创建多智能体模式对话。在 metadata 中写入 multi_agent_mode=true。 + + body: { workspace_id?, thinking_mode?, run_mode?, preserve_mode? } + """ + import time as _time + from server.conversation import _get_active_workspace_task, _resolve_target_terminal_for_workspace + from modules.personalization_manager import load_personalization_config + try: + from server.user_workspace import UserWorkspace # noqa + except Exception: + UserWorkspace = None # type: ignore + + username = get_current_username() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + + data = request.get_json() or {} + target_workspace_id = (data.get("workspace_id") or "").strip() + + terminal, workspace = get_user_resources(username) + if not terminal or not workspace: + return jsonify({"success": False, "error": "工作区未就绪"}), 503 + + if target_workspace_id: + try: + terminal, workspace = _resolve_target_terminal_for_workspace( + username, target_workspace_id, terminal, workspace + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 503 + + preserve_mode = bool(data.get("preserve_mode")) + thinking_mode = data.get("thinking_mode") if preserve_mode and "thinking_mode" in data else None + run_mode = data.get("mode") if preserve_mode and "mode" in data else None + + effective_workspace_id = target_workspace_id or session.get("workspace_id") or "default" + active_task = _get_active_workspace_task(username=username, workspace_id=effective_workspace_id) + + try: + prefs = load_personalization_config(workspace.data_dir) + except Exception: + prefs = {} + + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + if not cm: + return jsonify({"success": False, "error": "对话管理器未初始化"}), 500 + + safe_run_mode = run_mode + if safe_run_mode not in {"fast", "thinking", "deep"}: + candidate = (prefs or {}).get("default_run_mode") + safe_run_mode = candidate if candidate in {"fast", "thinking", "deep"} else "fast" + safe_thinking = bool(thinking_mode) if thinking_mode is not None else safe_run_mode != "fast" + default_permission_mode = (prefs or {}).get("default_permission_mode") + if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): + default_permission_mode = None + + previous_cm_current = getattr(cm, "current_conversation_id", None) + + conversation_id = cm.create_conversation( + project_path=str(workspace.project_path), + thinking_mode=safe_thinking, + run_mode=safe_run_mode, + initial_messages=[], + model_key=(prefs or {}).get("default_model") or getattr(terminal, "model_key", None), + metadata_overrides={ + "permission_mode": default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")(), + "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), + "multi_agent_mode": True, + }, + ) + try: + cm.current_conversation_id = previous_cm_current + except Exception: + pass + + # 触发对话列表更新事件 + try: + from server.app_legacy import socketio + socketio.emit('conversation_list_update', { + 'action': 'created', + 'conversation_id': conversation_id, + }, room=f"user_{username}") + except Exception: + pass + + return jsonify({ + "success": True, + "conversation_id": conversation_id, + "multi_agent_mode": True, + }), 201 + + +@multi_agent_bp.route("/api/multiagent/active_sub_agents", methods=["GET"]) +@api_login_required +def list_active_sub_agents_api(): + """查询当前会话所有子智能体实例(多智能体模式专用)。""" + username = get_current_username() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + conversation_id = (request.args.get("conversation_id") or "").strip() + if not conversation_id: + return jsonify({"success": False, "error": "缺少 conversation_id 参数"}), 400 + terminal, _ = get_user_resources(username) + if not terminal: + return jsonify({"success": False, "error": "工作区未就绪"}), 503 + sub_agent_manager = getattr(terminal, "sub_agent_manager", None) + if not sub_agent_manager: + return jsonify({"success": False, "error": "子智能体管理器未就绪"}), 503 + state = sub_agent_manager.get_multi_agent_state(conversation_id) + if not state: + return jsonify({"success": True, "agents": []}) + return jsonify({ + "success": True, + "agents": [a.to_dict() for a in state.list_all()], + }) + + +__all__ = ["multi_agent_bp"] \ No newline at end of file diff --git a/static/src/app/methods/ui/route.ts b/static/src/app/methods/ui/route.ts index 3dd72bc..e221e50 100644 --- a/static/src/app/methods/ui/route.ts +++ b/static/src/app/methods/ui/route.ts @@ -31,6 +31,88 @@ export const routeMethods = { this.currentConversationTitle = ''; this.titleTypingText = ''; const path = window.location.pathname.replace(/^\/+/, ''); + // 检查多智能体模式入口 + if (path === 'multiagent/new' || path === 'multiagent') { + this.multiAgentMode = true; + this.currentConversationId = null; + this.currentConversationTitle = '多智能体模式'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('多智能体模式', { animate: false }); + this.initialRouteResolved = true; + this.refreshBlankHeroState(); + // 多智能体模式下自动创建一个带 metadata.multi_agent_mode=true 的新对话 + try { + const resp = await fetch('/api/multiagent/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const result = await resp.json(); + if (result && result.success && result.conversation_id) { + this.currentConversationId = result.conversation_id; + // 拉取完整会话信息 + try { + const loadResp = await fetch(`/api/conversations/${result.conversation_id}/load`, { method: 'PUT' }); + const loadResult = await loadResp.json(); + if (loadResult.success) { + if (typeof loadResult.run_mode === 'string') { + this.runMode = loadResult.run_mode; + this.thinkingMode = typeof loadResult.thinking_mode === 'boolean' ? loadResult.thinking_mode : loadResult.run_mode !== 'fast'; + } + if (typeof loadResult.model_key === 'string' && loadResult.model_key) this.modelSet(loadResult.model_key); + this.currentConversationTitle = loadResult.title || '多智能体模式'; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); + history.replaceState({ conversationId: result.conversation_id }, '', `/multiagent/${result.conversation_id.replace(/^conv_/, '')}`); + this.logMessageState('bootstrapRoute:multi-agent-loaded'); + } + } catch (_e) { + // 加载失败也继续,主对话 已创建 + } + } + } catch (error) { + console.warn('[multiagent] 初始化多智能体对话失败:', error); + } + await this.restoreComposerDraftState('bootstrap-route:multiagent'); + return; + } + // 当 URL 是 /multiagent/conv_xxx 时也走多智能体模式 + if (path.startsWith('multiagent/')) { + this.multiAgentMode = true; + const convPart = path.slice('multiagent/'.length); + const convId = convPart.startsWith('conv_') ? convPart : `conv_${convPart}`; + try { + const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' }); + const result = await resp.json(); + if (result.success) { + if (typeof result.run_mode === 'string') { + this.runMode = result.run_mode; + this.thinkingMode = typeof result.thinking_mode === 'boolean' ? result.thinking_mode : result.run_mode !== 'fast'; + } else if (typeof result.thinking_mode === 'boolean') { + this.thinkingMode = result.thinking_mode; + this.runMode = result.thinking_mode ? 'thinking' : 'fast'; + } + if (typeof result.model_key === 'string' && result.model_key) this.modelSet(result.model_key); + this.currentConversationId = convId; + this.currentConversationTitle = result.title || '多智能体模式'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); + history.replaceState({ conversationId: convId }, '', `/multiagent/${this.stripConversationPrefix(convId)}`); + this.initialRouteResolved = true; + await this.restoreComposerDraftState('bootstrap-route:multiagent-existing'); + return; + } + } catch (error) { + console.warn('[multiagent] 加载多智能体对话失败:', error); + } + // 加载失败回退到 multiagent/new 路径 + history.replaceState({}, '', '/multiagent/new'); + window.location.reload(); + return; + } + // 非多智能体模式:清除标志 + this.multiAgentMode = false; if (!path || this.isExplicitNewConversationRoute()) { this.currentConversationId = null; this.currentConversationTitle = '新对话'; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 1f93721..0fbdb12 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -6,6 +6,8 @@ export function dataState() { // 路由相关 initialRouteResolved: false, dropToolEvents: false, + // 多智能体模式开关 + multiAgentMode: false, // 轮询模式标志(禁用 WebSocket 事件处理) usePollingMode: true, diff --git a/static/src/auth/LoginApp.vue b/static/src/auth/LoginApp.vue index 4ffbee8..c9c7633 100644 --- a/static/src/auth/LoginApp.vue +++ b/static/src/auth/LoginApp.vue @@ -38,6 +38,14 @@ 宿主机模式(免登录) + +
{{ error }}
@@ -96,6 +104,11 @@ const login = async () => { } }; +const enterMultiAgent = () => { + // 直接跳转,后端 @login_required 会确保已登录后重定向回来 + window.location.href = '/multiagent/new'; +}; + const hostLogin = async () => { hostSubmitting.value = true; error.value = ''; From 8e5d4f05d943f413e54f7be6b93fbd05fd4ebda2 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 12:20:18 +0800 Subject: [PATCH 03/19] =?UTF-8?q?fix(multi-agent):=20=E5=A4=9A=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E6=A8=A1=E5=BC=8F=E4=B8=8B=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E5=8F=AA=E5=8A=A0=E8=BD=BD=E5=AF=B9=E5=BA=94=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 get_conversation_list / web_terminal / conversation路由 增加 multi_agent_mode 查询参数: - multi_agent_mode=1 仅返回 multi_agent_mode 对话 - multi_agent_mode=0 仅返回常规对话 - 不传则不过滤 前端 loadConversationsList / loadWorkspaceConversations 根据 this.multiAgentMode 自动带参 --- core/web_terminal.py | 4 +-- server/conversation.py | 10 ++++++- static/src/app/methods/conversation/load.ts | 7 +++-- utils/context_manager/conversation_mixin.py | 4 +-- .../conversation_manager/list_search_mixin.py | 26 ++++++++++++++++--- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/core/web_terminal.py b/core/web_terminal.py index 7f7cbbd..964574d 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -393,10 +393,10 @@ class WebTerminal(MainTerminal): "message": f"加载对话异常: {e}" } - def get_conversations_list(self, limit: int = 20, offset: int = 0, non_empty: bool = False) -> Dict: + def get_conversations_list(self, limit: int = 20, offset: int = 0, non_empty: bool = False, multi_agent_mode: Optional[bool] = None) -> Dict: """获取对话列表(Web版本)""" try: - result = self.context_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty) + result = self.context_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode) return { "success": True, "data": result diff --git a/server/conversation.py b/server/conversation.py index 4d97d5d..3c4db1c 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -525,6 +525,14 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: limit = request.args.get('limit', 20, type=int) offset = request.args.get('offset', 0, type=int) non_empty = request.args.get('non_empty', '0') in ('1', 'true', 'True') + # multi_agent_mode: '1' 仅多智能体模式对话;'0' 仅常规对话;未传 None 不过滤 + ma_param = request.args.get('multi_agent_mode', None) + if ma_param in ('1', 'true', 'True'): + multi_agent_filter: Optional[bool] = True + elif ma_param in ('0', 'false', 'False'): + multi_agent_filter = False + else: + multi_agent_filter = None target_workspace_id = request.args.get('workspace_id', '', type=str).strip() # 限制参数范围 @@ -541,7 +549,7 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: except RuntimeError as exc: return jsonify({"success": False, "error": str(exc)}), 503 - result = terminal.get_conversations_list(limit=limit, offset=offset, non_empty=non_empty) + result = terminal.get_conversations_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_filter) cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) write_host_workspace_debug( "sidebar-debug-api", diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index 50c172f..d0cbbd3 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -14,7 +14,9 @@ export const loadMethods = { const requestSeq = ++this.conversationListRequestSeq; this.conversationsLoading = true; try { - const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}`); + // 多智能体模式下只加载多智能体对话记录;常规模式排除多智能体对话 + const maParam = this.multiAgentMode ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}${maParam}`); const data = await response.json(); if (data.success) { @@ -284,7 +286,8 @@ export const loadMethods = { if (group.loading || group.loadingMore) return; group.loading = true; try { - const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}`); + const maParam = this.multiAgentMode ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}${maParam}`); const data = await response.json(); if (data.success) { const items = (data.data?.conversations || []).map((conv: any) => ({ diff --git a/utils/context_manager/conversation_mixin.py b/utils/context_manager/conversation_mixin.py index 91e8cf2..d9b95f7 100644 --- a/utils/context_manager/conversation_mixin.py +++ b/utils/context_manager/conversation_mixin.py @@ -325,9 +325,9 @@ class ConversationMixin: except Exception as e: print(f"⌘ 自动保存异常: {e}") - def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False) -> Dict: + def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False, multi_agent_mode: Optional[bool] = None) -> Dict: """获取对话列表""" - return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty) + return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode) def delete_conversation_by_id(self, conversation_id: str) -> bool: """删除指定对话""" diff --git a/utils/conversation_manager/list_search_mixin.py b/utils/conversation_manager/list_search_mixin.py index f5c3fe1..9edea48 100644 --- a/utils/conversation_manager/list_search_mixin.py +++ b/utils/conversation_manager/list_search_mixin.py @@ -47,7 +47,7 @@ class ConversationMetadata: class ListSearchMixin: """ConversationManager list search mixin 能力 mixin。""" - def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False) -> Dict: + def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False, multi_agent_mode: Optional[bool] = None) -> Dict: """ 获取对话列表 @@ -55,14 +55,26 @@ class ListSearchMixin: limit: 限制数量 offset: 偏移量 non_empty: 仅返回有内容(total_messages > 0)的对话,分页基于过滤后的结果 + multi_agent_mode: 若为 True 仅返回多智能体模式对话;若为 False 仅返回常规对话;None 不过滤 Returns: Dict: 包含对话列表和统计信息 """ t0 = time.perf_counter() if perf_log: - perf_log("get_conversation_list enter", extra={"limit": limit, "offset": offset, "non_empty": non_empty}) + perf_log("get_conversation_list enter", extra={"limit": limit, "offset": offset, "non_empty": non_empty, "multi_agent_mode": multi_agent_mode}) try: + def _filter_by_multi_agent(items): + """按 multi_agent_mode 元数据过滤。""" + if multi_agent_mode is None: + return items + result = [] + for conv_id, meta in items: + conv_ma = bool(meta.get("multi_agent_mode", False)) + if conv_ma == multi_agent_mode: + result.append((conv_id, meta)) + return result + if non_empty: # 过滤模式:全量加载后剔除空对话,再在过滤结果上分页, # 保证 total / has_more 与"有内容对话"的真实数量一致。 @@ -76,6 +88,8 @@ class ListSearchMixin: item for item in sorted_conversations if (item[1].get("total_messages", 0) or 0) > 0 ] + if multi_agent_mode is not None: + sorted_conversations = _filter_by_multi_agent(sorted_conversations) total = len(sorted_conversations) conversations = sorted_conversations[offset:offset + limit] else: @@ -91,8 +105,11 @@ class ListSearchMixin: reverse=True ) + if multi_agent_mode is not None: + sorted_conversations = _filter_by_multi_agent(sorted_conversations) + # 分页 - total = max(len(sorted_conversations), total_files) + total = max(len(sorted_conversations), total_files if multi_agent_mode is None else len(sorted_conversations)) conversations = sorted_conversations[offset:offset+limit] # 格式化结果 @@ -108,7 +125,8 @@ class ListSearchMixin: "thinking_mode": metadata.get("thinking_mode", False), "total_messages": metadata.get("total_messages", 0), "total_tools": metadata.get("total_tools", 0), - "status": metadata.get("status", "active") + "status": metadata.get("status", "active"), + "multi_agent_mode": bool(metadata.get("multi_agent_mode", False)) }) elapsed_ms = (time.perf_counter() - t0) * 1000 From f6521185278e3f308f6791ce72e8275506bc9292 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 12:41:09 +0800 Subject: [PATCH 04/19] =?UTF-8?q?fix(multi-agent):=20=E8=B7=A8=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=BE=AA=E7=8E=AF=E9=97=AE=E7=AD=94=E3=80=81=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E5=AE=89=E5=85=A8=E5=8F=96=E6=B6=88=E3=80=81=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=BB=93=E6=9D=9F=E6=B1=87=E6=8A=A5=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E3=80=81=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E5=8F=AF=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/main_terminal_parts/tools_execution.py | 12 ++- modules/multi_agent/state.py | 61 +++++++++--- modules/multi_agent/tools.py | 6 +- modules/sub_agent/creation.py | 12 ++- modules/sub_agent/manager.py | 31 ++++-- modules/sub_agent/state.py | 11 +++ modules/sub_agent/task.py | 99 ++++++++++++++++--- modules/sub_agent/toolkit.py | 6 +- server/chat_flow_task_main.py | 89 +++++++++++++++-- server/chat_flow_task_support.py | 104 ++++++++++++++++++++ server/chat_flow_tool_loop.py | 12 ++- server/multi_agent.py | 32 +++++- static/src/app/methods/conversation/load.ts | 6 +- static/src/app/methods/ui/route.ts | 6 ++ static/src/app/watchers.ts | 4 + static/src/auth/LoginApp.vue | 27 +++-- static/src/stores/conversation.ts | 7 +- utils/conversation_manager/crud_mixin.py | 3 +- utils/conversation_manager/index_mixin.py | 1 + 19 files changed, 458 insertions(+), 71 deletions(-) diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index e6838bd..9dbe060 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -2,6 +2,7 @@ import asyncio import json import os import time +import uuid from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Set @@ -1755,16 +1756,13 @@ class MainTerminalToolsExecutionMixin: # 构造 task_message(作为 Team Leader 的任务发布) from modules.multi_agent.state import build_master_dispatch_text task_message = build_master_dispatch_text(arguments.get("task", "")) - # 加载并覆盖作业:multi_agent_mode/sub_agent_manager.create_sub_agent 的作业路径 summary_text = (arguments.get("summary") or f"{role.name}作业")[:80] - deliverables_dir = arguments.get("deliverables_dir", f"sub_agent_results/agent_{agent_id}") thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast" # 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用) result = self.sub_agent_manager.create_sub_agent( agent_id=int(agent_id), summary=summary_text, task=arguments.get("task", ""), - deliverables_dir=deliverables_dir, run_in_background=bool(arguments.get("run_in_background", True)), timeout_seconds=arguments.get("timeout_seconds"), conversation_id=conv_id, @@ -1773,6 +1771,8 @@ class MainTerminalToolsExecutionMixin: multi_agent_mode=True, role_id=role_id, display_name=display_name, + system_prompt=system_prompt, + task_message=task_message, ) # 在多智能体模式下,主进程 create_sub_agent 总是后台启动, # 主智能体不需要阻塞等待,而是通过子智能体输出转发拿进度。 @@ -1887,11 +1887,13 @@ class MainTerminalToolsExecutionMixin: if not state: result = {"success": False, "error": "多智能体状态未就绪"} else: - # 构造提问并插入子对话(子智能体下一轮 assistant 输出作为回答插入主对话) + question_id = f"ask_sub_agent_{int(time.time() * 1000)}_{uuid.uuid4().hex[:6]}" + # 构造提问并插入子对话(子智能体下一轮 assistant 输出作为回答返回到工具结果) text = format_multi_agent_message( display_name="Team Leader", msg_type=TYPE_ASK, content=question, + msg_id=question_id, target=state.get_instance(agent_id).display_name if state.get_instance(agent_id) else f"Agent_{agent_id}", ) ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) @@ -1900,7 +1902,7 @@ class MainTerminalToolsExecutionMixin: else: # 阻塞等待子智能体下一轮输出作为回答 answer = await state.wait_for_answer( - question_id=f"ask_sub_agent_{int(time.time())}", + question_id=question_id, agent_id=agent_id, timeout=timeout, ) diff --git a/modules/multi_agent/state.py b/modules/multi_agent/state.py index ddfdece..04f9586 100644 --- a/modules/multi_agent/state.py +++ b/modules/multi_agent/state.py @@ -20,6 +20,7 @@ from __future__ import annotations import asyncio import json import uuid +from asyncio import AbstractEventLoop from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -48,6 +49,7 @@ def format_multi_agent_message( msg_id: Optional[str] = None, target: Optional[str] = None, extra_attrs: Optional[Dict[str, str]] = None, + msg_type_text: Optional[str] = None, ) -> str: """按统一格式构造 user 消息字符串。 @@ -58,15 +60,17 @@ def format_multi_agent_message( msg_id: 消息 id;不传则自动生成 target: 接收方显示名(用于子→子 提问时标明对谁提问) extra_attrs: 额外标签属性(如 question_id="ask_xxx") + msg_type_text: 覆盖默认的中文消息类型文案(如"任务结束汇报") """ if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:10]}" + type_label = msg_type_text or msg_type_to_text(msg_type) # 第一行:自然语言前缀(含 target 标识) if target: - prefix = f"来自 {display_name} 向 {target} 的{msg_type_to_text(msg_type)}" + prefix = f"来自 {display_name} 向 {target} 的{type_label}" else: - prefix = f"来自 {display_name} 的{msg_type_to_text(msg_type)}" + prefix = f"来自 {display_name} 的{type_label}" # 第二行:id id_line = f"id: {msg_id}" @@ -114,13 +118,14 @@ def build_master_dispatch_text(task: str, msg_id: Optional[str] = None) -> str: ) -def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None) -> str: +def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None, *, is_final: bool = False) -> str: """子智能体输出(进度或完成)插入到主对话的 user 消息文本。""" return format_multi_agent_message( display_name=display_name, msg_type=TYPE_OUTPUT, content=content, msg_id=msg_id, + msg_type_text="任务结束汇报" if is_final else "任务进度输出", ) @@ -223,6 +228,10 @@ class MultiAgentState: # ask_master / ask_other_agent 的等待 future # key = question_id, value = asyncio.Future (结果为 answer str 或 Exception) self.pending_questions: Dict[str, asyncio.Future] = {} + # question_id -> 创建 future 时所在的事件循环,用于跨循环安全 set_result + self.pending_question_loops: Dict[str, AbstractEventLoop] = {} + # 回答早于 wait_for_answer 注册时先暂存 + self.pending_answers: Dict[str, str] = {} # 一个 agent 可能同时只阻塞在一个 ask 工具上(最简实现) # key = agent_id, value = question_id(表示当前 agent 正阻塞等待) self.agent_blocking_question: Dict[int, str] = {} @@ -285,25 +294,26 @@ class MultiAgentState: 返回 answer 字符串;超时/取消抛 asyncio.TimeoutError 或 CancelledError。 """ + # 如果回答已经提前到达,直接返回 + if question_id in self.pending_answers: + return self.pending_answers.pop(question_id) if question_id in self.pending_questions: raise RuntimeError(f"question_id 已存在: {question_id}") - fut: asyncio.Future = asyncio.get_event_loop().create_future() + loop = asyncio.get_running_loop() + fut: asyncio.Future = loop.create_future() self.pending_questions[question_id] = fut + self.pending_question_loops[question_id] = loop self.agent_blocking_question[agent_id] = question_id try: return await asyncio.wait_for(fut, timeout=timeout) - except asyncio.TimeoutError: - raise finally: self.pending_questions.pop(question_id, None) + self.pending_question_loops.pop(question_id, None) if self.agent_blocking_question.get(agent_id) == question_id: self.agent_blocking_question.pop(agent_id, None) - def provide_answer(self, question_id: str, answer: str) -> bool: - """主/其他子智能体 answer_* 工具调用时回写答案。 - - 返回 True 表示找到等待中的 future;False 表示无等待方或已超时。 - """ + async def _do_provide_answer(self, question_id: str, answer: str) -> bool: + """在同 future 所属事件循环内设置结果。""" fut = self.pending_questions.get(question_id) if not fut or fut.done(): return False @@ -313,6 +323,35 @@ class MultiAgentState: return False return True + def provide_answer(self, question_id: str, answer: str) -> bool: + """主/其他子智能体 answer_* 工具调用时回写答案。 + + 返回 True 表示找到等待中的 future;False 表示无等待方或已超时。 + 支持跨事件循环调用(例如主对话循环回答子智能体循环里的提问)。 + """ + # 如果 wait_for_answer 还没注册,先把答案暂存 + if question_id not in self.pending_questions: + self.pending_answers[question_id] = answer + return True + fut = self.pending_questions.get(question_id) + if not fut or fut.done(): + self.pending_answers[question_id] = answer + return False + loop = self.pending_question_loops.get(question_id) + if loop is None: + try: + loop = fut.get_loop() + except Exception: + pass + if loop is not None: + try: + asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), loop) + return True + except Exception: + pass + # 同循环回退 + return asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), asyncio.get_event_loop()).result(timeout=5) + def is_agent_blocking(self, agent_id: int) -> bool: return agent_id in self.agent_blocking_question diff --git a/modules/multi_agent/tools.py b/modules/multi_agent/tools.py index 741259a..bd055e2 100644 --- a/modules/multi_agent/tools.py +++ b/modules/multi_agent/tools.py @@ -53,10 +53,6 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]: "type": "integer", "description": "(可选)手动指定实例编号;不传时自动递增。", }, - "deliverables_dir": { - "type": "string", - "description": "(可选)交付目录相对路径,留空则用 sub_agent_results/agent_{N}。", - }, "timeout_seconds": {"type": "integer", "description": "超时秒数,默认 600。"}, "thinking_mode": { "type": "string", @@ -64,7 +60,7 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]: "description": "(可选)覆盖角色默认思考模式。不填使用角色配置。", }, }), - "required": ["role_id", "task", "thinking_mode"], + "required": ["role_id", "task"], }, }, } diff --git a/modules/sub_agent/creation.py b/modules/sub_agent/creation.py index f2e0bfb..5abad99 100644 --- a/modules/sub_agent/creation.py +++ b/modules/sub_agent/creation.py @@ -48,7 +48,7 @@ class SubAgentCreationMixin: if agent_id not in used: used.append(agent_id) - def _validate_create_params(self, agent_id: Optional[int], summary: str, task: str, target_dir: str) -> Optional[str]: + def _validate_create_params(self, agent_id: Optional[int], summary: str, task: str, target_dir: Optional[str], *, multi_agent_mode: bool = False) -> Optional[str]: if agent_id is None: return "子智能体代号不能为空" try: @@ -61,7 +61,8 @@ class SubAgentCreationMixin: return "任务摘要不能为空" if not task or not task.strip(): return "任务详情不能为空" - if target_dir is None: + # 多智能体模式不需要交付目录 + if not multi_agent_mode and target_dir is None: return "指定文件夹不能为空" return None @@ -69,8 +70,11 @@ class SubAgentCreationMixin: suffix = uuid.uuid4().hex[:6] return f"sub_{agent_id}_{int(time.time())}_{suffix}" - def _resolve_deliverables_dir(self, relative_dir: str) -> Path: - relative_dir = relative_dir.strip() if relative_dir else "" + def _resolve_deliverables_dir(self, relative_dir: Optional[str], *, multi_agent_mode: bool = False) -> Path: + relative_dir = (relative_dir or "").strip() + # 多智能体模式:没有交付目录概念,直接使用项目根目录 + if multi_agent_mode and not relative_dir: + return self.project_path.resolve() if not relative_dir: raise ValueError("交付目录不能为空,必须指定") deliverables_path = (self.project_path / relative_dir).resolve() diff --git a/modules/sub_agent/manager.py b/modules/sub_agent/manager.py index 47c7e44..b3139c3 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -136,7 +136,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi agent_id: int, summary: str, task: str, - deliverables_dir: str, + deliverables_dir: Optional[str] = None, timeout_seconds: Optional[int] = None, conversation_id: Optional[str] = None, run_in_background: bool = False, @@ -145,6 +145,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi multi_agent_mode: bool = False, role_id: Optional[str] = None, display_name: Optional[str] = None, + system_prompt: Optional[str] = None, + task_message: Optional[str] = None, ) -> Dict: """创建子智能体任务并启动协程。 @@ -152,7 +154,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi 参数 role_id: 多智能体模式下的角色标诶。 参数 display_name: 多智能体模式下的显示名(如 UI Operator_1)。 """ - validation_error = self._validate_create_params(agent_id, summary, task, deliverables_dir) + validation_error = self._validate_create_params(agent_id, summary, task, deliverables_dir, multi_agent_mode=multi_agent_mode) if validation_error: return {"success": False, "error": validation_error} @@ -181,7 +183,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi task_root.mkdir(parents=True, exist_ok=True) try: - deliverables_path = self._resolve_deliverables_dir(deliverables_dir) + deliverables_path = self._resolve_deliverables_dir(deliverables_dir, multi_agent_mode=multi_agent_mode) except ValueError as exc: return {"success": False, "error": str(exc)} @@ -194,11 +196,17 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi prompt_workspace = self._get_runtime_path(self.project_path) deliverables_display = self._get_runtime_path(deliverables_path) - user_message = build_user_message(agent_id, summary, task, deliverables_display, timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT) + if task_message: + user_message = task_message + else: + user_message = build_user_message(agent_id, summary, task, deliverables_display, timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT) task_file.write_text(user_message, encoding="utf-8") - system_prompt = build_system_prompt(prompt_workspace) - system_prompt_file.write_text(system_prompt, encoding="utf-8") + if system_prompt: + final_system_prompt = system_prompt + else: + final_system_prompt = build_system_prompt(prompt_workspace) + system_prompt_file.write_text(final_system_prompt, encoding="utf-8") timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT @@ -250,7 +258,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi manager=self, task_record=task_record, task_message=user_message, - system_prompt=system_prompt, + system_prompt=final_system_prompt, model_key=model_key, thinking_mode=thinking_mode, multi_agent_mode=multi_agent_mode, @@ -360,7 +368,12 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi task_id = task["task_id"] running_task = self._running_tasks.pop(task_id, None) if running_task and not running_task.done(): - running_task.cancel() + # 子智能体运行在独立事件循环线程中,取消操作必须投递到该循环 + try: + loop = running_task.get_loop() + loop.call_soon_threadsafe(running_task.cancel) + except Exception: + running_task.cancel() deadline = time.time() + 5 while not running_task.done() and time.time() < deadline: time.sleep(0.05) @@ -574,7 +587,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi sub_agent = self._find_sub_agent_task_by_agent_id(agent_id) if not sub_agent: return False - sub_agent.messages.append({"role": "user", "content": message_text}) + sub_agent.inject_message(message_text) return True def _find_sub_agent_task_by_agent_id(self, agent_id: int) -> Optional[Any]: diff --git a/modules/sub_agent/state.py b/modules/sub_agent/state.py index 07ee4de..181668c 100644 --- a/modules/sub_agent/state.py +++ b/modules/sub_agent/state.py @@ -257,6 +257,11 @@ class SubAgentStateMixin: running_task.result(timeout=0) except Exception: pass + # 多智能体模式:任务自然进入 idle 时不写输出文件,不应标记为失败 + if task.get("multi_agent_mode") and not Path(task.get("output_file", "")).exists(): + task["status"] = "idle" + task["updated_at"] = time.time() + return {"status": "idle", "task_id": task_id} return self._check_task_status(task) return {"status": "running", "task_id": task_id} @@ -264,6 +269,12 @@ class SubAgentStateMixin: if output_file.exists(): return self._check_task_status(task) + # 多智能体模式:没有输出文件表示 idle,不强制清理 + if task.get("multi_agent_mode"): + task["status"] = "idle" + task["updated_at"] = time.time() + return {"status": "idle", "task_id": task_id} + if self._should_force_cleanup_stale_task(task): return self._mark_task_terminated( task, diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index bf20b16..e90b7e7 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -4,11 +4,15 @@ import asyncio import base64 import json import mimetypes +import re +import threading import time import uuid from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING + +_QUESTION_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE) from modules.sub_agent.toolkit import ( SUB_AGENT_TOOLS, @@ -90,8 +94,14 @@ class SubAgentTask: # 多智能体模式相关字段 self.multi_agent_mode = bool(multi_agent_mode) self.multi_agent_state = multi_agent_state - # display_name 不传时回退为 'Agent_{agent_id}' + # display_name 不传时回退为 'Agent_{self.agent_id}' self.display_name = display_name or f"Agent_{self.agent_id}" + # 多智能体运行期控制 + # 使用 threading.Event 避免跨事件循环唤醒问题 + self._continue_event = threading.Event() + self._idle = False + self._pending_answer_question_id: Optional[str] = None + self._answered_question_ids: Set[str] = set() def emit(self, type_: str, data: Dict[str, Any]) -> None: """输出一行 JSONL 到 progress 文件并缓存。""" @@ -123,34 +133,57 @@ class SubAgentTask: if self.multi_agent_mode: tools = list(SUB_AGENT_TOOLS) tools.extend(_load_multi_agent_sub_agent_tools()) - # 多智能体模式下不要求 finish_task,自然输出结束即本轮任务结束 + # 多智能体模式下不要求 finish_task,自然输出结束即进入 idle,可继续接收消息 else: tools = list(SUB_AGENT_TOOLS) tools.append(FINISH_TOOL) start_time = time.time() max_turns = 50 + turn = 0 - for turn in range(1, max_turns + 1): - if self._cancelled: - break + while not self._cancelled: elapsed = time.time() - start_time if elapsed > self.timeout_seconds: await self._write_timeout(elapsed) return + # 多智能体模式下,idle 时等待新消息或外部回答;超时后继续循环检查 + if self.multi_agent_mode and self._idle: + try: + await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, self._continue_event.wait), + timeout=1.0, + ) + except asyncio.TimeoutError: + pass + self._continue_event.clear() + if self._cancelled: + break + self._idle = False + continue + + turn += 1 + if turn > max_turns: + await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True) + return + self.stats["api_calls"] += 1 self.stats["turn_count"] = turn self.stats["runtime_seconds"] = int(elapsed) self.emit("stats", {**self.stats, "turn_count": turn}) + # 多智能体模式:在模型调用前识别是否有待回答的提问 + if self.multi_agent_mode: + self._pending_answer_question_id = self._peek_pending_question_id() + assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools) if usage: self._apply_usage(usage) # 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话 if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip(): - self._forward_output_to_master(assistant_message) + self._forward_output_to_master(assistant_message, is_final=not tool_calls) final_message: Dict[str, Any] = {"role": "assistant", "content": assistant_message} if reasoning: @@ -160,10 +193,11 @@ class SubAgentTask: self.messages.append(final_message) if not tool_calls: - # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 状态 + # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 等待 if self.multi_agent_mode: self._mark_idle() - return + self._idle = True + continue # 普通模式:prompt 并要求继续 / finish_task self.messages.append({ "role": "user", @@ -197,15 +231,21 @@ class SubAgentTask: "content": content, }) - await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True) + # 循环结束(取消或 idle 被外部终止)后的清理 + if self.multi_agent_mode and self._cancelled: + if self.multi_agent_state: + self.multi_agent_state.mark_status(self.agent_id, "terminated") - def _forward_output_to_master(self, output_text: str) -> None: + def _forward_output_to_master(self, output_text: str, *, is_final: bool = False) -> None: """把子智能体的 assistant 文本输出转发成主对话的 user 消息。""" if not self.multi_agent_state: return + # 如果这是对 pending 提问的回答,不走主对话转发,而是返回到 ask 工具结果 + if self._provide_answer(output_text): + return try: from modules.multi_agent.state import build_sub_agent_output_text - msg = build_sub_agent_output_text(self.display_name, output_text.strip()) + msg = build_sub_agent_output_text(self.display_name, output_text.strip(), is_final=is_final) self.multi_agent_state.push_master_message(msg) # 同时记录到实例状态,供 list_active_sub_agents 使用 inst = self.multi_agent_state.get_instance(self.agent_id) @@ -215,10 +255,43 @@ class SubAgentTask: logger.warning(f"[SubAgentTask] forward output to master failed: {exc}") def _mark_idle(self) -> None: - """多智能体模式下,子智能体自然结束']=本轮任务 结束,进入 idle 状态。""" + """多智能体模式下,子智能体自然结束即本轮任务结束,进入 idle 状态。""" if self.multi_agent_state: self.multi_agent_state.mark_status(self.agent_id, "idle") + def inject_message(self, message_text: str) -> None: + """外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。""" + self.messages.append({"role": "user", "content": message_text}) + self._continue_event.set() + + def _peek_pending_question_id(self) -> Optional[str]: + """检查最后一条 user 消息是否是向本智能体提问,返回 question_id。""" + if not self.multi_agent_mode or not self.messages: + return None + for msg in reversed(self.messages): + if msg.get("role") == "user": + content = msg.get("content") or "" + if "的提问" in content: + m = _QUESTION_ID_RE.search(content) + if m: + qid = m.group(1) + if qid not in self._answered_question_ids: + return qid + break + return None + + def _provide_answer(self, output_text: str) -> bool: + """如果当前输出是对 pending 提问的回答,把回答写回 future 并阻止转发到主对话。""" + if not self._pending_answer_question_id or not self.multi_agent_state: + return False + self.multi_agent_state.provide_answer( + self._pending_answer_question_id, + output_text.strip(), + ) + self._answered_question_ids.add(self._pending_answer_question_id) + self._pending_answer_question_id = None + return True + def _build_client(self) -> tuple: """加载模型配置并初始化 DeepSeekClient。""" config_path = self.manager.models_config_file diff --git a/modules/sub_agent/toolkit.py b/modules/sub_agent/toolkit.py index 79a87a5..55b6422 100644 --- a/modules/sub_agent/toolkit.py +++ b/modules/sub_agent/toolkit.py @@ -6,6 +6,8 @@ import json from datetime import datetime from typing import Any, Dict, List, Optional +from config.model_profiles import _parse_env_ref + # 子智能体可用工具定义(与前端进度展示兼容) SUB_AGENT_TOOLS: List[Dict[str, Any]] = [ { @@ -267,8 +269,8 @@ def _format_tool_result(name: str, raw: Any) -> str: def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, Any]]: """把 sub_agent_models.json 中的模型条目转成 DeepSeekClient.apply_profile 所需格式。""" name = str(model_raw.get("name") or model_raw.get("model_name") or model_raw.get("model") or "").strip() - url = str(model_raw.get("url") or model_raw.get("base_url") or "").strip() - api_key = str(model_raw.get("apikey") or model_raw.get("api_key") or "").strip() + url = str(_parse_env_ref(model_raw.get("url") or model_raw.get("base_url") or "") or "").strip() + api_key = str(_parse_env_ref(model_raw.get("apikey") or model_raw.get("api_key") or "") or "").strip() if not name or not url or not api_key: return None diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index cbefa60..6dd0395 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -130,7 +130,7 @@ from .chat_flow_runtime import ( detect_malformed_tool_call, ) -from .chat_flow_task_support import process_sub_agent_updates, process_background_command_updates +from .chat_flow_task_support import process_sub_agent_updates, process_background_command_updates, process_multi_agent_master_messages from .chat_flow_tool_loop import execute_tool_calls from .chat_flow_stream_loop import run_streaming_attempts from .deep_compression import run_deep_compression @@ -778,6 +778,25 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) - "sort_key": update.get("updated_at") or time.time(), }) + # 3) 多智能体模式:把子智能体转发到主对话的 pending 消息也作为通知池项消费 + if getattr(web_terminal, "multi_agent_mode", False): + sub_manager = getattr(web_terminal, "sub_agent_manager", None) + if sub_manager: + state = sub_manager.get_multi_agent_state(conversation_id) + if state: + ma_messages = state.drain_master_messages() + for msg_text in ma_messages: + notices.append({ + "kind": "multi_agent", + "message": msg_text, + "payload": { + "sub_agent_notice": True, + "message_source": "sub_agent", + "multi_agent_output": True, + }, + "sort_key": time.time(), + }) + notices.sort(key=lambda item: item.get("sort_key") or 0) return notices @@ -787,18 +806,37 @@ def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool: sub_manager = getattr(web_terminal, "sub_agent_manager", None) if sub_manager: announced = getattr(web_terminal, "_announced_sub_agent_tasks", set()) + has_running_non_ma = False + has_unnotified_non_ma = False + has_running_ma = False for task in sub_manager.tasks.values(): if not isinstance(task, dict): continue - if not task.get("run_in_background"): - continue if task.get("conversation_id") != conversation_id: continue status = task.get("status") + multi_agent_flag = task.get("multi_agent_mode") or False if status not in TERMINAL_STATUSES.union({"terminated"}): - return True # 仍在运行 - if status != "terminated" and (task.get("task_id") not in announced) and not task.get("notified"): - return True # 已完成但未通知 + if multi_agent_flag: + has_running_ma = True + elif task.get("run_in_background"): + has_running_non_ma = True + continue + if not multi_agent_flag and task.get("run_in_background") and (task.get("task_id") not in announced) and not task.get("notified"): + has_unnotified_non_ma = True + if has_running_non_ma or has_unnotified_non_ma: + return True + # 多智能体模式:有未消费的主对话消息 或 有运行中(非 idle)实例时继续轮询 + if getattr(web_terminal, "multi_agent_mode", False) and has_running_ma: + state = sub_manager.get_multi_agent_state(conversation_id) + if state: + if state.has_pending_master_messages(): + return True + # 只要还有非 idle 实例就继续;全部 idle 且无 pending 则结束轮询 + if any(a.status not in {"idle", "terminated"} for a in state.list_all()): + return True + return False + return True bg_manager = getattr(web_terminal, "background_command_manager", None) if bg_manager: try: @@ -854,6 +892,11 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation await asyncio.sleep(1) continue + # 多智能体模式:主对话任务仍在运行时,由主循环自己消费 pending 消息 + if getattr(web_terminal, "_multi_agent_main_task_active", False): + await asyncio.sleep(1) + continue + notices = _collect_pending_completion_notices( web_terminal=web_terminal, conversation_id=conversation_id, @@ -928,6 +971,9 @@ async def handle_task_with_sender( web_terminal = terminal conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None) + # 多智能体模式:标记主对话任务正在运行,供后台通知池判断是否可以安全消费 + if getattr(web_terminal, "multi_agent_mode", False): + web_terminal._multi_agent_main_task_active = True videos = videos or [] raw_sender = sender @@ -1638,6 +1684,22 @@ async def handle_task_with_sender( debug_log(f"[Goal] 目标停止:{goal_result.get('reason')}") except Exception as exc: debug_log(f"[Goal] turn 结束处理失败: {exc}") + + # 多智能体模式:没有工具调用时,先消费子智能体待转发到主对话的消息; + # 如果有新消息注入,继续迭代让 Team Leader 响应,而不是直接结束任务。 + if getattr(web_terminal, "multi_agent_mode", False): + injected_count = await process_multi_agent_master_messages( + messages=messages, + inline=False, + web_terminal=web_terminal, + sender=sender, + debug_log=debug_log, + ) + if injected_count: + debug_log(f"[MultiAgent] no-tool-call turn 注入 {injected_count} 条子智能体消息,继续迭代") + is_first_iteration = False + continue + break # 目标模式:本轮段确实产生了工具调用 @@ -1699,6 +1761,7 @@ async def handle_task_with_sender( last_tool_call_time=last_tool_call_time, process_sub_agent_updates=process_sub_agent_updates, process_background_command_updates=process_background_command_updates, + process_multi_agent_master_messages=process_multi_agent_master_messages, maybe_mark_failure_from_message=maybe_mark_failure_from_message, mark_force_thinking=mark_force_thinking, get_stop_flag=get_stop_flag, @@ -1749,8 +1812,22 @@ async def handle_task_with_sender( # 标记不再是第一次迭代 is_first_iteration = False + # 多智能体模式:在进入下一轮模型调用前,消费子智能体最新输出到主对话 + if getattr(web_terminal, "multi_agent_mode", False): + await process_multi_agent_master_messages( + messages=messages, + inline=False, + web_terminal=web_terminal, + sender=sender, + debug_log=debug_log, + ) + # 最终统计 + # 多智能体模式:主对话任务结束,解除活跃标记 + if getattr(web_terminal, "multi_agent_mode", False): + web_terminal._multi_agent_main_task_active = False + debug_log(f"\n{'='*40}") debug_log(f"任务完成统计:") debug_log(f" 总迭代次数: {total_iterations}") diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 5b9c31d..48ff7d8 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -314,6 +314,110 @@ async def process_background_command_updates(*, messages: List[Dict], inline: bo +def inject_multi_agent_master_message( + *, + web_terminal, + messages, + text: str, + sender, + conversation_id: Optional[str] = None, + inline: bool = True, + after_tool_call_id: Optional[str] = None, +) -> Optional[str]: + """把多智能体子智能体输出/消息以原生格式注入主对话,不添加 [系统通知|xxx] 前缀。""" + raw = "" if text is None else str(text).strip() + if not raw: + return None + + metadata = { + "runtime_injected": True, + "source": "sub_agent", + "message_source": "sub_agent", + "inline": inline, + "is_auto_generated": True, + "auto_message_type": "multi_agent_output", + "visibility": "compact", + "starts_work": False, + } + + try: + ctx_manager = getattr(web_terminal, "context_manager", None) + if ctx_manager is not None: + ctx_manager.add_conversation("user", raw, metadata=metadata) + except Exception: + pass + + if messages is not None: + insert_index = len(messages) + if after_tool_call_id: + for idx, msg in enumerate(messages): + if msg.get("role") == "tool" and msg.get("tool_call_id") == after_tool_call_id: + end = idx + 1 + while end < len(messages) and messages[end].get("role") == "tool": + end += 1 + insert_index = end + break + messages.insert(insert_index, {"role": "user", "content": raw}) + + if callable(sender): + payload = { + "message": raw, + "content": raw, + "conversation_id": conversation_id, + "inline": inline, + "source": "sub_agent", + "message_source": "sub_agent", + "visibility": "compact", + "starts_work": False, + "metadata": metadata, + "runtime_injected": True, + } + try: + sender("user_message", payload) + except Exception: + pass + + return raw + + +async def process_multi_agent_master_messages( + *, + web_terminal, + messages, + sender, + debug_log, + inline: bool = False, + after_tool_call_id: Optional[str] = None, +) -> int: + """从 MultiAgentState 取出待插入主对话的消息并注入。返回注入条数。""" + if not getattr(web_terminal, "multi_agent_mode", False): + return 0 + manager = getattr(web_terminal, "sub_agent_manager", None) + if not manager: + return 0 + conversation_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None) + if not conversation_id: + return 0 + state = manager.get_multi_agent_state(conversation_id) + if not state: + return 0 + pending = state.drain_master_messages() + if not pending: + return 0 + debug_log(f"[MultiAgent] draining {len(pending)} pending master messages") + for msg in pending: + inject_multi_agent_master_message( + web_terminal=web_terminal, + messages=messages, + text=msg, + sender=sender, + conversation_id=conversation_id, + inline=inline, + after_tool_call_id=after_tool_call_id, + ) + return len(pending) + + async def wait_retry_delay(*, delay_seconds: int, client_sid: str, username: str, sender, get_stop_flag, clear_stop_flag) -> bool: """等待重试间隔,同时检查是否收到停止请求。""" if delay_seconds <= 0: diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 3124376..0e3d5a4 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -24,7 +24,7 @@ from modules.personalization_manager import load_personalization_config, resolve from modules.auto_approval_service import run_auto_approval from modules.user_question_manager import format_user_question_answer from .deep_compression import run_deep_compression -from .chat_flow_task_support import inject_runtime_user_message +from .chat_flow_task_support import inject_runtime_user_message, process_multi_agent_master_messages def _format_numbered_lines(lines: List[str], start_line_no: int) -> List[Dict[str, Any]]: @@ -261,7 +261,7 @@ async def _wait_for_user_questions(*, question_ids: List[str], username: str, ti return answered -async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None): +async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, process_multi_agent_master_messages=process_multi_agent_master_messages, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None): previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False) web_terminal._tool_loop_active = True allowed_tool_names = set() @@ -1323,6 +1323,14 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie debug_log=debug_log, maybe_mark_failure_from_message=maybe_mark_failure_from_message, ) + await process_multi_agent_master_messages( + messages=messages, + inline=True, + after_tool_call_id=last_completed_tool_call_id, + web_terminal=web_terminal, + sender=sender, + debug_log=debug_log, + ) # 运行期模式通知:必须等待同一轮全部 tool_call 都完成后再注入, # 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。 diff --git a/server/multi_agent.py b/server/multi_agent.py index 5c033b8..b7ad90a 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -13,19 +13,47 @@ from typing import Any, Dict, List from flask import Blueprint, current_app, jsonify, request, session -from server.auth_helpers import api_login_required, get_current_username +from server.auth_helpers import api_login_required, login_required, get_current_username from server.context import get_user_resources multi_agent_bp = Blueprint("multi_agent", __name__) @multi_agent_bp.route("/multiagent/new") -@api_login_required +@login_required def multi_agent_new_page(): """多智能体模式入口,返回与 /new 相同的 SPA index.html。""" return current_app.send_static_file("index.html") +@multi_agent_bp.route("/multiagent/") +@login_required +def multi_agent_conversation_page(conversation_id: str): + """多智能体模式 指定会话 URL,返回 SPA index.html 让前端路由处理。""" + return current_app.send_static_file("index.html") + + +@multi_agent_bp.route("/api/multiagent/rebuild-index", methods=["POST"]) +@api_login_required +def rebuild_conversation_index_api(): + """强制从磁盘重建对话索引,补全 multi_agent_mode 等新字段。""" + try: + username = get_current_username() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + terminal, _ = get_user_resources(username) + if not terminal: + return jsonify({"success": False, "error": "工作区未就绪"}), 503 + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + if not cm: + return jsonify({"success": False, "error": "对话管理器未初始化"}), 503 + rebuilt = cm._rebuild_index_from_files() + cm._save_index(rebuilt) + return jsonify({"success": True, "index_size": len(rebuilt)}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @multi_agent_bp.route("/api/multiagent/roles", methods=["GET"]) @api_login_required def list_roles_api(): diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index d0cbbd3..e65fcff 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -180,6 +180,9 @@ export const loadMethods = { if (typeof result.model_key === 'string' && result.model_key) { this.modelSet(result.model_key); } + if (typeof result.multi_agent_mode === 'boolean') { + this.multiAgentMode = result.multi_agent_mode; + } // 2. 更新当前对话信息 this.skipConversationHistoryReload = true; @@ -193,10 +196,11 @@ export const loadMethods = { if (!preserveListPosition) { this.promoteConversationToTop(conversationId); } + const urlPrefix = this.multiAgentMode ? '/multiagent/' : '/'; history.pushState( { conversationId }, '', - `/${this.stripConversationPrefix(conversationId)}` + `${urlPrefix}${this.stripConversationPrefix(conversationId)}` ); this.skipConversationLoadedEvent = true; diff --git a/static/src/app/methods/ui/route.ts b/static/src/app/methods/ui/route.ts index e221e50..ebfbb6f 100644 --- a/static/src/app/methods/ui/route.ts +++ b/static/src/app/methods/ui/route.ts @@ -41,6 +41,12 @@ export const routeMethods = { this.startTitleTyping('多智能体模式', { animate: false }); this.initialRouteResolved = true; this.refreshBlankHeroState(); + // 进入多智能体模式时触发后端重建索引以补全 multi_agent_mode 字段 + try { + await fetch('/api/multiagent/rebuild-index', { method: 'POST' }); + } catch (_e) { + // 重建失败不阻断主流程 + } // 多智能体模式下自动创建一个带 metadata.multi_agent_mode=true 的新对话 try { const resp = await fetch('/api/multiagent/conversations', { diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index 4929f6f..3fa75c4 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -1,7 +1,11 @@ // @ts-nocheck import { debugLog, traceLog } from './methods/common'; +import { useConversationStore } from '../stores/conversation'; export const watchers = { + multiAgentMode(newValue) { + useConversationStore().$patch({ multiAgentMode: !!newValue }); + }, inputMessage() { this.autoResizeInput(); if (typeof this.scheduleComposerDraftPersist === 'function') { diff --git a/static/src/auth/LoginApp.vue b/static/src/auth/LoginApp.vue index c9c7633..a785223 100644 --- a/static/src/auth/LoginApp.vue +++ b/static/src/auth/LoginApp.vue @@ -69,7 +69,7 @@ const submitting = ref(false); const hostSubmitting = ref(false); const hostModeEnabled = ref(false); -const login = async () => { +const doLogin = async (redirectUrl = '/') => { if (!email.value || !password.value) { error.value = '请输入邮箱和密码'; return; @@ -92,7 +92,7 @@ const login = async () => { const data = await resp.json(); if (data.success) { - window.location.href = '/'; + window.location.href = redirectUrl; return; } @@ -104,12 +104,9 @@ const login = async () => { } }; -const enterMultiAgent = () => { - // 直接跳转,后端 @login_required 会确保已登录后重定向回来 - window.location.href = '/multiagent/new'; -}; +const login = () => doLogin('/'); -const hostLogin = async () => { +const doHostLogin = async (redirectUrl = '/') => { hostSubmitting.value = true; error.value = ''; @@ -122,7 +119,7 @@ const hostLogin = async () => { const data = await resp.json(); if (data.success) { - window.location.href = '/'; + window.location.href = redirectUrl; return; } @@ -134,6 +131,20 @@ const hostLogin = async () => { } }; +const hostLogin = () => doHostLogin('/'); + +const enterMultiAgent = () => { + if (hostModeEnabled.value) { + doHostLogin('/multiagent/new'); + return; + } + if (!email.value || !password.value) { + error.value = '请输入邮箱和密码,或选择宿主机模式'; + return; + } + doLogin('/multiagent/new'); +}; + onMounted(async () => { applyTheme(loadTheme()); diff --git a/static/src/stores/conversation.ts b/static/src/stores/conversation.ts index 134b158..e4aab2f 100644 --- a/static/src/stores/conversation.ts +++ b/static/src/stores/conversation.ts @@ -45,6 +45,7 @@ interface ConversationState { runningWorkspaceTasks: any[]; acknowledgedCompletedTaskIds: string[]; workspaceGroups: WorkspaceConversationGroup[]; + multiAgentMode: boolean; } export const useConversationStore = defineStore('conversation', { @@ -70,7 +71,8 @@ export const useConversationStore = defineStore('conversation', { conversationsLimit: 20, runningWorkspaceTasks: [], acknowledgedCompletedTaskIds: [], - workspaceGroups: [] + workspaceGroups: [], + multiAgentMode: false }), actions: { resetConversations() { @@ -192,8 +194,9 @@ export const useConversationStore = defineStore('conversation', { const fetchOffset = refresh ? 0 : group.offset; group.loading = true; try { + const maParam = this.multiAgentMode ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; const response = await fetch( - `/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}` + `/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}${maParam}` ); const data = await response.json(); if (data.success) { diff --git a/utils/conversation_manager/crud_mixin.py b/utils/conversation_manager/crud_mixin.py index f2f2afb..a984444 100644 --- a/utils/conversation_manager/crud_mixin.py +++ b/utils/conversation_manager/crud_mixin.py @@ -233,7 +233,8 @@ class CrudMixin: "has_videos": conversation_data["metadata"].get("has_videos", False), "total_messages": metadata.total_messages, "total_tools": metadata.total_tools, - "status": metadata.status + "status": metadata.status, + "multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False)) } self._save_index(index) diff --git a/utils/conversation_manager/index_mixin.py b/utils/conversation_manager/index_mixin.py index b448e48..b16df1c 100644 --- a/utils/conversation_manager/index_mixin.py +++ b/utils/conversation_manager/index_mixin.py @@ -163,6 +163,7 @@ class IndexMixin: "total_messages": metadata.get("total_messages", 0), "total_tools": metadata.get("total_tools", 0), "status": metadata.get("status", "active"), + "multi_agent_mode": bool(metadata.get("multi_agent_mode", False)), } elapsed_ms = (time.perf_counter() - t0) * 1000 if perf_log: From 7f2ad9144d2ef71ad4aaf5a8652a7d08e7c6391b Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 18:39:21 +0800 Subject: [PATCH 05/19] =?UTF-8?q?fix(multi-agent):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AD=90=E6=99=BA=E8=83=BD=E4=BD=93=E7=8A=B6=E6=80=81=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=E3=80=81=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E4=B8=8E=20idle=20=E7=AD=89=E5=BE=85=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/__init__.py | 7 +- core/main_terminal_parts/tools_execution.py | 57 +++++- core/web_terminal.py | 29 ++- modules/multi_agent/debug_logger.py | 30 +++ modules/multi_agent/state.py | 29 ++- modules/multi_agent/tools.py | 5 +- modules/sub_agent/manager.py | 76 +++++++- modules/sub_agent/state.py | 72 ++++++- modules/sub_agent/task.py | 179 ++++++++++++++++-- server/chat_flow_task_main.py | 30 ++- server/chat_flow_task_support.py | 15 ++ server/conversation.py | 8 +- static/src/app/methods/conversation/action.ts | 12 +- static/src/app/methods/message/send.ts | 15 +- .../overlay/SubAgentActivityDialog.vue | 175 +++++++++++++---- static/src/composables/useLegacySocket.ts | 22 ++- 16 files changed, 659 insertions(+), 102 deletions(-) create mode 100644 modules/multi_agent/debug_logger.py diff --git a/config/__init__.py b/config/__init__.py index bfdf7ef..98f68e7 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -16,11 +16,13 @@ def _load_dotenv(): import sys pre_existing_keys = set(os.environ.keys()) - # 1) 仓库根 .env(开发便利,不覆盖已有环境变量) + # 1) 仓库根 .env(开发便利,不覆盖已有的环境变量,但 ASTRION_DATA_ROOT + # 作为项目数据根目录必须优先以 .env 为准,避免外部 shell 误指到 clone) if getattr(sys, 'frozen', False): env_path = Path.home() / '.astrion' / 'astrion' / '.env' else: env_path = Path(__file__).resolve().parents[1] / '.env' + env_from_file: dict = {} if env_path.exists(): try: for raw_line in env_path.read_text(encoding="utf-8").splitlines(): @@ -34,11 +36,14 @@ def _load_dotenv(): value = value.strip().strip('"').strip("'") if not key: continue + env_from_file[key] = value if key in pre_existing_keys: continue os.environ[key] = value except Exception: pass + if "ASTRION_DATA_ROOT" in env_from_file: + os.environ["ASTRION_DATA_ROOT"] = str(Path(env_from_file["ASTRION_DATA_ROOT"]).expanduser()) # 2) settings.json(数据根下的统一配置) data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion")) diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 9dbe060..d9d70aa 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -101,6 +101,7 @@ from utils.api_client import DeepSeekClient from utils.context_manager import ContextManager from utils.tool_result_formatter import format_tool_result_for_context from utils.logger import setup_logger +from modules.multi_agent.debug_logger import ma_debug from config.model_profiles import ( get_model_profile, get_model_prompt_replacements, @@ -1128,6 +1129,27 @@ class MainTerminalToolsExecutionMixin: task_ids.append(task.get("task_id")) if missing: result = {"success": False, "error": f"未找到对应子智能体: {missing}"} + elif getattr(self, "multi_agent_mode", False): + # 多智能体模式:子智能体通过自然输出结束,不调用 finish_task。 + # sleep 不再阻塞等待,而是直接返回各实例最近一次输出内容。 + conv_id = self.context_manager.current_conversation_id + ma_state = manager.get_multi_agent_state(conv_id) + outputs = [] + for aid in normalized_ids: + inst = ma_state.get_instance(aid) if ma_state else None + outputs.append({ + "agent_id": aid, + "display_name": inst.display_name if inst else f"Agent_{aid}", + "status": inst.status if inst else "unknown", + "last_output": inst.last_output if inst else "", + }) + result = { + "success": True, + "mode": "wait_sub_agent_ids", + "agent_ids": normalized_ids, + "outputs": outputs, + "message": f"已获取 {len(normalized_ids)} 个子智能体最近一次输出" + } else: wait_results = [] waited_task_ids = [] @@ -1763,7 +1785,7 @@ class MainTerminalToolsExecutionMixin: agent_id=int(agent_id), summary=summary_text, task=arguments.get("task", ""), - run_in_background=bool(arguments.get("run_in_background", True)), + run_in_background=False, timeout_seconds=arguments.get("timeout_seconds"), conversation_id=conv_id, model_key=role.model_key, @@ -1774,8 +1796,8 @@ class MainTerminalToolsExecutionMixin: system_prompt=system_prompt, task_message=task_message, ) - # 在多智能体模式下,主进程 create_sub_agent 总是后台启动, - # 主智能体不需要阻塞等待,而是通过子智能体输出转发拿进度。 + # 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。 + # run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。 except Exception as exc: logger.exception("[multi_agent] create_sub_agent failed") result = {"success": False, "error": str(exc)} @@ -1865,12 +1887,27 @@ class MainTerminalToolsExecutionMixin: else: # 构造消息文本并插入子对话 text = build_master_message_to_sub_agent(message) + ma_debug( + "tool_send_message_to_sub_agent", + agent_id=agent_id, + raw_message=str(message)[:500], + wrapped_message_preview=text[:500], + conversation_id=conv_id, + ) ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) if not ok: result = {"success": False, "error": f"子智能体 {agent_id} 不存在或已结束"} else: result = {"success": True, "agent_id": agent_id} + ma_debug( + "tool_send_message_to_sub_agent_result", + agent_id=agent_id, + conversation_id=conv_id, + ok=ok, + result=result, + ) except Exception as exc: + logger.exception("[multi_agent] send_message_to_sub_agent failed") result = {"success": False, "error": str(exc)} elif tool_name == "ask_sub_agent": @@ -1896,6 +1933,14 @@ class MainTerminalToolsExecutionMixin: msg_id=question_id, target=state.get_instance(agent_id).display_name if state.get_instance(agent_id) else f"Agent_{agent_id}", ) + ma_debug( + "tool_ask_sub_agent", + agent_id=agent_id, + question=str(question)[:500], + question_id=question_id, + wrapped_message_preview=text[:500], + conversation_id=conv_id, + ) ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) if not ok: result = {"success": False, "error": f"子智能体 {agent_id} 不存在"} @@ -1921,6 +1966,12 @@ class MainTerminalToolsExecutionMixin: answer = arguments.get("answer", "") conv_id = self.context_manager.current_conversation_id state = self.sub_agent_manager.get_multi_agent_state(conv_id) + ma_debug( + "tool_answer_sub_agent_question", + question_id=question_id, + answer_preview=str(answer)[:500], + conversation_id=conv_id, + ) if not state: result = {"success": False, "error": "多智能体状态未就绪"} else: diff --git a/core/web_terminal.py b/core/web_terminal.py index 964574d..24ab6ad 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -3,7 +3,7 @@ import json import time from datetime import datetime -from typing import Dict, List, Optional, Callable, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Callable, TYPE_CHECKING import os from core.main_terminal import MainTerminal from utils.logger import setup_logger @@ -105,13 +105,19 @@ class WebTerminal(MainTerminal): # 新增:对话管理相关方法(Web版本) # =========================================== - def create_new_conversation(self, thinking_mode: bool = None, run_mode: Optional[str] = None) -> Dict: + def create_new_conversation( + self, + thinking_mode: bool = None, + run_mode: Optional[str] = None, + metadata_overrides: Optional[Dict[str, Any]] = None, + ) -> Dict: """ 创建新对话(Web版本) Args: thinking_mode: 思考模式,None则使用当前设置 run_mode: 显式的运行模式(fast/thinking/deep) + metadata_overrides: 额外写入对话 metadata 的字段 Returns: Dict: 包含新对话信息 @@ -171,17 +177,21 @@ class WebTerminal(MainTerminal): try: # 先创建新对话。start_new_conversation 会先 save_current_conversation(), # 此时 terminal.model_key 仍是旧对话的模型,避免把旧对话覆盖成默认模型。 + metadata_overrides_merged = { + "permission_mode": self.get_permission_mode(), + "execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox", + "pending_permission_mode": None, + "pending_execution_mode": None, + # frozen_*_prompt 不在创建时预设,由第一次 build_messages 根据当时的实际模式懒加载并冻结 + } + if isinstance(metadata_overrides, dict): + metadata_overrides_merged.update(metadata_overrides) + conversation_id = self.context_manager.start_new_conversation( project_path=self.project_path, thinking_mode=thinking_mode, run_mode=self.run_mode, - metadata_overrides={ - "permission_mode": self.get_permission_mode(), - "execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox", - "pending_permission_mode": None, - "pending_execution_mode": None, - # frozen_*_prompt 不在创建时预设,由第一次 build_messages 根据当时的实际模式懒加载并冻结 - }, + metadata_overrides=metadata_overrides_merged, ) # 新对话创建完成后再应用默认模型(此时旧对话已安全保存)。 @@ -377,6 +387,7 @@ class WebTerminal(MainTerminal): "run_mode": self.run_mode, "thinking_mode": self.thinking_mode, "model_key": getattr(self, "model_key", None), + "multi_agent_mode": bool(getattr(self, "multi_agent_mode", False)), "message": f"对话已加载: {conversation_id}" } else: diff --git a/modules/multi_agent/debug_logger.py b/modules/multi_agent/debug_logger.py new file mode 100644 index 0000000..7ede7e5 --- /dev/null +++ b/modules/multi_agent/debug_logger.py @@ -0,0 +1,30 @@ +"""多智能体模式专用调试日志。 + +把所有子智能体与主智能体之间的消息往来、注入、转发、工具调用记录下来, +便于排查循环/重复输出等问题。日志写入 {LOGS_DIR}/multi_agent_loop.log。 +""" +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +from config.paths import LOGS_DIR + + +_LOG_PATH = Path(LOGS_DIR) / "multi_agent_loop.log" + + +def ma_debug(event: str, **kwargs: Any) -> None: + """追加一条结构化调试日志。""" + try: + _LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = {"t": time.time(), "event": event} + for k, v in kwargs.items(): + payload[k] = v + line = json.dumps(payload, ensure_ascii=False, default=str) + with open(_LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass diff --git a/modules/multi_agent/state.py b/modules/multi_agent/state.py index 04f9586..4c51f4a 100644 --- a/modules/multi_agent/state.py +++ b/modules/multi_agent/state.py @@ -25,6 +25,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, TYPE_CHECKING +from modules.multi_agent.debug_logger import ma_debug if TYPE_CHECKING: from modules.sub_agent.task import SubAgentTask @@ -298,7 +299,17 @@ class MultiAgentState: if question_id in self.pending_answers: return self.pending_answers.pop(question_id) if question_id in self.pending_questions: - raise RuntimeError(f"question_id 已存在: {question_id}") + old_fut = self.pending_questions[question_id] + try: + old_loop = old_fut.get_loop() + if old_loop.is_closed(): + self.pending_questions.pop(question_id, None) + self.pending_question_loops.pop(question_id, None) + else: + raise RuntimeError(f"question_id 已存在: {question_id}") + except Exception: + self.pending_questions.pop(question_id, None) + self.pending_question_loops.pop(question_id, None) loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() self.pending_questions[question_id] = fut @@ -329,6 +340,12 @@ class MultiAgentState: 返回 True 表示找到等待中的 future;False 表示无等待方或已超时。 支持跨事件循环调用(例如主对话循环回答子智能体循环里的提问)。 """ + ma_debug( + "state_provide_answer", + question_id=question_id, + has_pending=question_id in self.pending_questions, + answer_preview=str(answer)[:300], + ) # 如果 wait_for_answer 还没注册,先把答案暂存 if question_id not in self.pending_questions: self.pending_answers[question_id] = answer @@ -349,8 +366,14 @@ class MultiAgentState: return True except Exception: pass - # 同循环回退 - return asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), asyncio.get_event_loop()).result(timeout=5) + # 同循环回退(future 所属循环可能已关闭,失败时把答案暂存,避免阻塞方永远等不到) + try: + return asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), asyncio.get_event_loop()).result(timeout=5) + except Exception: + self.pending_questions.pop(question_id, None) + self.pending_question_loops.pop(question_id, None) + self.pending_answers[question_id] = answer + return True def is_agent_blocking(self, agent_id: int) -> bool: return agent_id in self.agent_blocking_question diff --git a/modules/multi_agent/tools.py b/modules/multi_agent/tools.py index bd055e2..b002e61 100644 --- a/modules/multi_agent/tools.py +++ b/modules/multi_agent/tools.py @@ -53,7 +53,10 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]: "type": "integer", "description": "(可选)手动指定实例编号;不传时自动递增。", }, - "timeout_seconds": {"type": "integer", "description": "超时秒数,默认 600。"}, + "timeout_seconds": { + "type": "integer", + "description": "(可选)超时秒数。不填表示该子智能体不会被时间终结,适用于可能多轮长期任务的子智能体;一般情况下推荐创建永久子智能体。", + }, "thinking_mode": { "type": "string", "enum": ["fast", "thinking"], diff --git a/modules/sub_agent/manager.py b/modules/sub_agent/manager.py index b3139c3..1bcaa3f 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -30,6 +30,7 @@ from modules.sub_agent.tools import handle_search_workspace, handle_read_mediafi from modules.sub_agent.state import SubAgentStateMixin from modules.sub_agent.stats import SubAgentStatsMixin from modules.sub_agent.creation import SubAgentCreationMixin +from modules.multi_agent.debug_logger import ma_debug if TYPE_CHECKING: from core.web_terminal import WebTerminal @@ -199,7 +200,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi if task_message: user_message = task_message else: - user_message = build_user_message(agent_id, summary, task, deliverables_display, timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT) + display_timeout = timeout_seconds if timeout_seconds is not None else 0 + user_message = build_user_message(agent_id, summary, task, deliverables_display, display_timeout or SUB_AGENT_DEFAULT_TIMEOUT) task_file.write_text(user_message, encoding="utf-8") if system_prompt: @@ -208,8 +210,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi final_system_prompt = build_system_prompt(prompt_workspace) system_prompt_file.write_text(final_system_prompt, encoding="utf-8") - timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT - + # timeout_seconds 为 None 表示永久子智能体(不会被时间终结) task_record = { "task_id": task_id, "agent_id": agent_id, @@ -223,6 +224,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi "updated_at": time.time(), "conversation_id": conversation_id, "run_in_background": run_in_background, + "multi_agent_mode": bool(multi_agent_mode), "task_root": str(task_root), "output_file": str(output_file), "stats_file": str(stats_file), @@ -286,6 +288,15 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi if multi_agent_mode and display_name: message = f"{display_name} 已创建,任务ID: {task_id}" print(f"{OUTPUT_FORMATS['info']} {message}") + ma_debug( + "manager_create_sub_agent", + task_id=task_id, + agent_id=agent_id, + display_name=display_name, + multi_agent_mode=multi_agent_mode, + run_in_background=task_record.get("run_in_background"), + timeout_seconds=timeout_seconds, + ) return { "success": True, @@ -577,6 +588,26 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi """删除会话状态(会话结束时调用)。""" self.multi_agent_states.pop(conversation_id, None) + def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: + """修正运行态任务状态。 + + 在父类实现前先根据内存中的 MultiAgentState 给旧任务补上 multi_agent_mode + 标记,避免任务记录缺字段导致被当成普通子智能体误判为 failed。 + """ + if conversation_id and conversation_id in self.multi_agent_states: + state = self.multi_agent_states[conversation_id] + agent_ids = {a.agent_id for a in state.list_all()} + for task in self.tasks.values(): + if ( + isinstance(task, dict) + and task.get("conversation_id") == conversation_id + and task.get("agent_id") in agent_ids + and task.get("multi_agent_mode") is None + ): + task["multi_agent_mode"] = True + task["updated_at"] = time.time() + return super().reconcile_task_states(conversation_id=conversation_id) + def inject_message_to_sub_agent(self, agent_id: int, message_text: str) -> bool: """同事件循环中向子智能体上下文插入 user 消息。 @@ -585,6 +616,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi """ # 查找该 agent_id 对应的 running SubAgentTask sub_agent = self._find_sub_agent_task_by_agent_id(agent_id) + ma_debug( + "manager_inject_message_to_sub_agent", + agent_id=agent_id, + message_preview=str(message_text)[:500], + found=bool(sub_agent), + task_id=sub_agent.task_id if sub_agent else None, + ) if not sub_agent: return False sub_agent.inject_message(message_text) @@ -606,18 +644,40 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi def _on_multi_agent_task_done(self, task_id: str, agent_id: int, state: Any, sub_agent: Any) -> None: """SubAgentTask 结束回调会调这个更新 MultiAgentState 实例状态。""" - # 取出当前 status(由 _finalize_task 设置) final_task = self.tasks.get(task_id) or {} - final_status = final_task.get("status") or "terminated" - # 允许的自然退出:running -> idle(未走 _finalize) vs failed/timeout - # SubAgentTask 在多智能体模式下没有 _finalize_task,我们手动赋值 idle - # 如果状态被结 _finalize 为 failed/timeout,则保持该状态 + final_status_before = final_task.get("status") + ma_debug( + "manager_on_multi_agent_task_done", + task_id=task_id, + agent_id=agent_id, + sub_agent_idle=getattr(sub_agent, "_idle", False), + sub_agent_cancelled=getattr(sub_agent, "_cancelled", False), + task_status_before=final_status_before, + ) + # 多智能体模式下,子智能体自然进入 idle 后 Task 可能被外部事件循环取消, + # 或者 reconcile 把 idle 误判为 failed。优先以 SubAgentTask 自身状态为准: + # - 被手动取消 -> terminated + # - 自然进入 idle -> idle(可继续接收消息) + # - 真正异常/超时/finish_task 失败 -> failed/timeout + if getattr(sub_agent, "_cancelled", False): + state.mark_status(agent_id, "terminated") + ma_debug("manager_ma_state_set", agent_id=agent_id, status="terminated", reason="sub_agent_cancelled") + return + if getattr(sub_agent, "_idle", False): + state.mark_status(agent_id, "idle") + ma_debug("manager_ma_state_set", agent_id=agent_id, status="idle", reason="sub_agent_idle") + return + + # 兜底:取出当前 task status(由 _finalize_task 设置) if final_status in TERMINAL_STATUSES: state.mark_status(agent_id, final_status, last_output=str(final_task.get("final_result") or "")) + ma_debug("manager_ma_state_set", agent_id=agent_id, status=final_status, reason="task_terminal_status") elif final_status == "terminated": state.mark_status(agent_id, "terminated") + ma_debug("manager_ma_state_set", agent_id=agent_id, status="terminated", reason="task_terminated_status") else: state.mark_status(agent_id, "idle") + ma_debug("manager_ma_state_set", agent_id=agent_id, status="idle", reason="fallback_idle") def _get_runtime_path(self, host_path: Path) -> str: """将宿主机路径映射为容器内路径(仅用于提示展示)。""" diff --git a/modules/sub_agent/state.py b/modules/sub_agent/state.py index 181668c..0b4467a 100644 --- a/modules/sub_agent/state.py +++ b/modules/sub_agent/state.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional from utils.logger import setup_logger +from modules.multi_agent.debug_logger import ma_debug logger = setup_logger(__name__) TERMINAL_STATUSES = {"completed", "failed", "timeout"} @@ -94,6 +95,7 @@ class SubAgentStateMixin: try: output = json.loads(output_file.read_text(encoding="utf-8")) except Exception as exc: + logger.warning(f"[check_task_status] output 文件解析失败: {exc}") task["status"] = "failed" task["updated_at"] = time.time() return {"success": False, "status": "failed", "task_id": task_id, "message": f"输出文件解析失败: {exc}"} @@ -103,6 +105,24 @@ class SubAgentStateMixin: stats = output.get("stats", {}) elapsed_seconds = self._compute_elapsed_seconds(task) + ma_debug( + "check_task_status", + task_id=task_id, + agent_id=task.get("agent_id"), + multi_agent_mode=task.get("multi_agent_mode"), + output_status=output.get("status"), + output_success=success, + ) + + # 多智能体模式:output 中 status 为 running/idle 表示子智能体仍在运行或 + # 本轮结束但上下文保留、可继续接收消息,都不是失败/完成。 + # 此时不生成 final_result,只保持原状态,避免 reconcile 把运行中误判为失败。 + if task.get("multi_agent_mode") and output.get("status") in {"running", "idle"}: + task["status"] = output["status"] + task["updated_at"] = time.time() + ma_debug("check_task_status_keep_alive", task_id=task_id, status=output["status"]) + return {"status": output["status"], "task_id": task_id} + if output.get("timeout"): status = "timeout" elif output.get("max_turns_exceeded"): @@ -113,6 +133,7 @@ class SubAgentStateMixin: else: status = "failed" + ma_debug("check_task_status_result", task_id=task_id, agent_id=task.get("agent_id"), status=status) task["status"] = status task["updated_at"] = time.time() if status == "completed" and elapsed_seconds is not None: @@ -237,6 +258,13 @@ class SubAgentStateMixin: if not task: return status = "completed" if success else "failed" + ma_debug( + "mark_task_done", + task_id=task_id, + agent_id=task.get("agent_id"), + success=success, + new_status=status, + ) task["status"] = status task["updated_at"] = time.time() task["runtime_seconds"] = runtime_seconds @@ -246,10 +274,19 @@ class SubAgentStateMixin: def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]: """刷新单个任务运行态。""" status = task.get("status") - if status in TERMINAL_STATUSES.union({"terminated"}): - return {"status": status, "task_id": task.get("task_id")} - task_id = task.get("task_id") + agent_id = task.get("agent_id") + multi_agent_flag = task.get("multi_agent_mode") + ma_debug( + "refresh_task_runtime_state_start", + task_id=task_id, + agent_id=agent_id, + before_status=status, + multi_agent_mode=multi_agent_flag, + ) + if status in TERMINAL_STATUSES.union({"terminated"}): + return {"status": status, "task_id": task_id} + running_task = self._running_tasks.get(task_id) if task_id else None if running_task: if running_task.done(): @@ -261,18 +298,25 @@ class SubAgentStateMixin: if task.get("multi_agent_mode") and not Path(task.get("output_file", "")).exists(): task["status"] = "idle" task["updated_at"] = time.time() + ma_debug("refresh_task_runtime_state_idle_no_output", task_id=task_id) return {"status": "idle", "task_id": task_id} - return self._check_task_status(task) + result = self._check_task_status(task) + ma_debug("refresh_task_runtime_state_done_result", task_id=task_id, result=result) + return result + ma_debug("refresh_task_runtime_state_still_running", task_id=task_id) return {"status": "running", "task_id": task_id} output_file = Path(task.get("output_file", "")) if output_file.exists(): - return self._check_task_status(task) + result = self._check_task_status(task) + ma_debug("refresh_task_runtime_state_output_result", task_id=task_id, result=result) + return result # 多智能体模式:没有输出文件表示 idle,不强制清理 if task.get("multi_agent_mode"): task["status"] = "idle" task["updated_at"] = time.time() + ma_debug("refresh_task_runtime_state_idle_no_output_file", task_id=task_id) return {"status": "idle", "task_id": task_id} if self._should_force_cleanup_stale_task(task): @@ -293,6 +337,7 @@ class SubAgentStateMixin: def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: """修正运行态任务状态,返回修正条目数。""" changed = 0 + ma_debug("reconcile_task_states_start", conversation_id=conversation_id, task_count=len(self.tasks)) for task in self.tasks.values(): if not isinstance(task, dict): continue @@ -301,10 +346,19 @@ class SubAgentStateMixin: before_status = task.get("status") before_notified = task.get("notified") self._refresh_task_runtime_state(task) - if task.get("status") != before_status or task.get("notified") != before_notified: + after_status = task.get("status") + if after_status != before_status or task.get("notified") != before_notified: + ma_debug( + "reconcile_task_states_changed", + task_id=task.get("task_id"), + agent_id=task.get("agent_id"), + before_status=before_status, + after_status=after_status, + ) changed += 1 if changed: self._save_state() + ma_debug("reconcile_task_states_end", conversation_id=conversation_id, changed=changed) return changed def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool: @@ -314,8 +368,12 @@ class SubAgentStateMixin: created_at = 0 if created_at <= 0: return False + # timeout_seconds 为 None 表示永久子智能体,不强制清理 + raw_timeout = task.get("timeout_seconds") + if raw_timeout is None: + return False from config import SUB_AGENT_DEFAULT_TIMEOUT - timeout_seconds = int(task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT or 0) + timeout_seconds = int(raw_timeout or SUB_AGENT_DEFAULT_TIMEOUT or 0) timeout_seconds = max(timeout_seconds, 1) grace_seconds = 120 elapsed = time.time() - created_at diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index e90b7e7..779a71e 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: from modules.sub_agent.manager import SubAgentManager from modules.multi_agent.state import MultiAgentState +from modules.multi_agent.debug_logger import ma_debug + logger = setup_logger(__name__) # 多智能体模式下额外加载的工具定义 @@ -63,7 +65,8 @@ class SubAgentTask: self.thinking_mode = thinking_mode or "fast" self.task_id = task_record["task_id"] self.agent_id = task_record["agent_id"] - self.timeout_seconds = int(task_record.get("timeout_seconds") or 180) + raw_timeout = task_record.get("timeout_seconds") + self.timeout_seconds = int(raw_timeout) if raw_timeout is not None else None self.deliverables_dir = Path(task_record["deliverables_dir"]) self.output_file = Path(task_record["output_file"]) @@ -97,8 +100,9 @@ class SubAgentTask: # display_name 不传时回退为 'Agent_{self.agent_id}' self.display_name = display_name or f"Agent_{self.agent_id}" # 多智能体运行期控制 - # 使用 threading.Event 避免跨事件循环唤醒问题 - self._continue_event = threading.Event() + # 使用 asyncio.Event 在子智能体自己的事件循环内等待; + # inject_message 可能跨线程调用,通过 loop.call_soon_threadsafe 唤醒。 + self._continue_event: Optional[asyncio.Event] = None self._idle = False self._pending_answer_question_id: Optional[str] = None self._answered_question_ids: Set[str] = set() @@ -116,16 +120,20 @@ class SubAgentTask: async def run(self) -> None: """主 LLM 循环。""" + # 在子智能体自己的事件循环内初始化 asyncio.Event + self._continue_event = asyncio.Event() try: await self._run_loop() except asyncio.CancelledError: self._cancelled = True logger.debug(f"[SubAgent] task={self.task_id} 被取消") + ma_debug("sub_agent_run_cancelled", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name) # shield 避免取消信号中断最终状态落盘 await asyncio.shield(self._write_failure("子智能体被手动终止")) raise except Exception as exc: logger.exception(f"[SubAgent] task={self.task_id} 执行异常") + ma_debug("sub_agent_run_exception", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, error=str(exc)) await self._write_failure(f"执行异常: {exc}") async def _run_loop(self) -> None: @@ -144,22 +152,33 @@ class SubAgentTask: while not self._cancelled: elapsed = time.time() - start_time - if elapsed > self.timeout_seconds: + if self.timeout_seconds is not None and elapsed > self.timeout_seconds: await self._write_timeout(elapsed) return - # 多智能体模式下,idle 时等待新消息或外部回答;超时后继续循环检查 + # 多智能体模式下,idle 时等待新消息或外部回答;只有真正被注入消息时才继续运行 if self.multi_agent_mode and self._idle: + event_set = False try: - await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor(None, self._continue_event.wait), - timeout=1.0, - ) + if self._continue_event is None: + self._continue_event = asyncio.Event() + await asyncio.wait_for(self._continue_event.wait(), timeout=1.0) + event_set = True except asyncio.TimeoutError: pass - self._continue_event.clear() if self._cancelled: break + if not event_set: + # 只是周期性检查取消状态,没有新消息,保持 idle 继续等待 + continue + self._continue_event.clear() + ma_debug( + "sub_agent_idle_wake", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + pending_messages=[m.get("role") for m in self.messages[-3:]], + ) self._idle = False continue @@ -177,6 +196,18 @@ class SubAgentTask: if self.multi_agent_mode: self._pending_answer_question_id = self._peek_pending_question_id() + # 调试:记录进入本轮模型调用前的上下文摘要 + ma_debug( + "sub_agent_model_call_start", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + turn=turn, + message_count=len(self.messages), + last_user_message=self.messages[-1].get("content", "")[:300] if self.messages and self.messages[-1].get("role") == "user" else "", + pending_answer_question_id=self._pending_answer_question_id, + ) + assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools) if usage: self._apply_usage(usage) @@ -191,12 +222,14 @@ class SubAgentTask: if tool_calls: final_message["tool_calls"] = tool_calls self.messages.append(final_message) + self._persist_conversation(partial_summary=assistant_message[:200]) if not tool_calls: # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 等待 if self.multi_agent_mode: self._mark_idle() self._idle = True + self._persist_conversation(partial_summary=assistant_message[:200]) continue # 普通模式:prompt 并要求继续 / finish_task self.messages.append({ @@ -230,6 +263,7 @@ class SubAgentTask: "tool_call_id": tool_call.get("id", progress_id), "content": content, }) + self._persist_conversation(partial_summary=assistant_message[:200]) # 循环结束(取消或 idle 被外部终止)后的清理 if self.multi_agent_mode and self._cancelled: @@ -237,7 +271,7 @@ class SubAgentTask: self.multi_agent_state.mark_status(self.agent_id, "terminated") def _forward_output_to_master(self, output_text: str, *, is_final: bool = False) -> None: - """把子智能体的 assistant 文本输出转发成主对话的 user 消息。""" + """把子智能体的 assistant 文本输出转发成主对话的 user 消息,并写入进度文件供前端查看。""" if not self.multi_agent_state: return # 如果这是对 pending 提问的回答,不走主对话转发,而是返回到 ask 工具结果 @@ -251,17 +285,58 @@ class SubAgentTask: inst = self.multi_agent_state.get_instance(self.agent_id) if inst: inst.last_output = output_text[:500] + # 写入进度文件,前端子智能体进度弹窗可直接展示 + self.emit("progress", { + "subtype": "output", + "content": output_text, + "is_final": is_final, + "ts": int(time.time() * 1000), + }) + ma_debug( + "sub_agent_output_forwarded", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + is_final=is_final, + content_preview=output_text[:300], + ) except Exception as exc: logger.warning(f"[SubAgentTask] forward output to master failed: {exc}") def _mark_idle(self) -> None: """多智能体模式下,子智能体自然结束即本轮任务结束,进入 idle 状态。""" + ma_debug( + "sub_agent_mark_idle", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + ) if self.multi_agent_state: self.multi_agent_state.mark_status(self.agent_id, "idle") def inject_message(self, message_text: str) -> None: """外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。""" self.messages.append({"role": "user", "content": message_text}) + ma_debug( + "sub_agent_message_injected", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + message_preview=str(message_text)[:500], + was_idle=self._idle, + ) + # inject_message 可能从其他线程(主对话线程)调用,需要线程安全唤醒。 + # 优先使用子智能体 Task 所属事件循环投递 set(),避免跨线程直接操作 Future。 + if self._continue_event is None: + self._continue_event = asyncio.Event() + if self._task is not None: + try: + task_loop = self._task.get_loop() + if task_loop.is_running(): + task_loop.call_soon_threadsafe(self._continue_event.set) + return + except Exception: + pass self._continue_event.set() def _peek_pending_question_id(self) -> Optional[str]: @@ -409,6 +484,14 @@ class SubAgentTask: if name == "ask_master": question = str(args.get("question") or "").strip() question_id = str(args.get("question_id") or f"ask_master_{uuid.uuid4().hex[:10]}") + ma_debug( + "sub_agent_tool_ask_master", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + question_id=question_id, + question=question[:500], + ) if not question: return {"success": False, "error": "question 不能为空"} # 插入到主对话 @@ -428,6 +511,15 @@ class SubAgentTask: target_id = int(args.get("target_agent_id") or 0) question = str(args.get("question") or "").strip() question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}") + ma_debug( + "sub_agent_tool_ask_other_agent", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + target_agent_id=target_id, + question_id=question_id, + question=question[:500], + ) if not target_id or not question: return {"success": False, "error": "参数缺失"} # 查找目标实例 @@ -516,10 +608,75 @@ class SubAgentTask: async def _write_failure(self, message: str, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None: elapsed = time.time() - (self.stats["runtime_start"] / 1000) + ma_debug( + "sub_agent_write_failure", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + message=message, + max_turns_exceeded=max_turns_exceeded, + timeout=timeout, + idle=self._idle, + cancelled=self._cancelled, + ) self._finalize_task(False, message, elapsed, max_turns_exceeded=max_turns_exceeded, timeout=timeout) + def _persist_conversation(self, *, partial_summary: str = "") -> None: + """每轮结束后立即落盘子智能体对话,避免跑完了才存一次导致中间状态丢失。""" + try: + runtime_seconds = int((time.time() * 1000 - self.stats["runtime_start"]) / 1000) + status = "running" + if self._cancelled: + status = "terminated" + elif self.multi_agent_mode and self._idle: + status = "idle" + ma_debug( + "sub_agent_persist_conversation", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + status=status, + idle=self._idle, + cancelled=self._cancelled, + multi_agent_mode=self.multi_agent_mode, + ) + conversation_data = { + "agent_id": self.agent_id, + "task_id": self.task_id, + "created_at": datetime.fromtimestamp(self.stats["runtime_start"] / 1000).isoformat(), + "updated_at": datetime.now().isoformat(), + "status": status, + "success": None, + "summary": partial_summary, + "messages": self.messages, + "stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}, + } + self.conversation_file.parent.mkdir(parents=True, exist_ok=True) + self.conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8") + + output_data = { + "success": None, + "status": status, + "summary": partial_summary, + "stats": conversation_data["stats"], + } + self.output_file.parent.mkdir(parents=True, exist_ok=True) + self.output_file.write_text(json.dumps(output_data, ensure_ascii=False), encoding="utf-8") + except Exception as exc: + logger.warning(f"[SubAgentTask] 增量保存失败: {exc}") + def _finalize_task(self, success: bool, summary: str, elapsed: float, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None: runtime_seconds = int(elapsed) + ma_debug( + "sub_agent_finalize_task", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + success=success, + summary=summary, + idle=self._idle, + cancelled=self._cancelled, + ) output_data = { "success": success, "summary": summary, diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 6dd0395..c8b105d 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -826,13 +826,13 @@ def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool: has_unnotified_non_ma = True if has_running_non_ma or has_unnotified_non_ma: return True - # 多智能体模式:有未消费的主对话消息 或 有运行中(非 idle)实例时继续轮询 - if getattr(web_terminal, "multi_agent_mode", False) and has_running_ma: + # 多智能体模式:只要有未消费的主对话消息,或还有未结束(非 idle/terminated)的实例,就继续轮询。 + # 即使全部实例都已 idle,只要 pending 消息尚未消费,也要继续,避免主任务结束后通知丢失。 + if getattr(web_terminal, "multi_agent_mode", False): state = sub_manager.get_multi_agent_state(conversation_id) if state: if state.has_pending_master_messages(): return True - # 只要还有非 idle 实例就继续;全部 idle 且无 pending 则结束轮询 if any(a.status not in {"idle", "terminated"} for a in state.list_all()): return True return False @@ -1839,6 +1839,7 @@ async def handle_task_with_sender( # 检查是否有后台运行的子智能体或待通知的完成任务 manager = getattr(web_terminal, "sub_agent_manager", None) has_running_sub_agents = False + has_running_multi_agent = False bg_manager = getattr(web_terminal, "background_command_manager", None) has_running_background_commands = False if manager: @@ -1848,16 +1849,33 @@ async def handle_task_with_sender( debug_log(f"[SubAgent] reconcile_task_states failed: {exc}") if not hasattr(web_terminal, "_announced_sub_agent_tasks"): web_terminal._announced_sub_agent_tasks = set() + + # 多智能体模式:检测是否还有运行中/idle 的多智能体实例。 + # 它们不是传统后台任务,不阻塞前端输入区,但需要通知池在主任务结束后 + # 继续消费 pending 消息并触发 Team Leader 响应。 + if getattr(web_terminal, "multi_agent_mode", False): + for task in manager.tasks.values(): + if ( + isinstance(task, dict) + and task.get("conversation_id") == conversation_id + and task.get("multi_agent_mode") + and task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) + ): + has_running_multi_agent = True + break + running_tasks = [ task for task in manager.tasks.values() if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) and task.get("run_in_background") + and not task.get("multi_agent_mode") and task.get("conversation_id") == conversation_id ] pending_notice_tasks = [ task for task in manager.tasks.values() if task.get("status") in TERMINAL_STATUSES.union({"terminated"}) and task.get("run_in_background") + and not task.get("multi_agent_mode") and task.get("conversation_id") == conversation_id and task.get("task_id") not in web_terminal._announced_sub_agent_tasks ] @@ -1884,11 +1902,11 @@ async def handle_task_with_sender( # 与子智能体完全复用同一 waiting 事件(前端已有稳定处理链路) sender('sub_agent_waiting', _build_shared_waiting_payload(waiting_items)) - # 统一「通知池」轮询器:子智能体 + 后台 run_command 合并为单一轮询链路, - # 每轮一次性取出两路所有待通知项,避免逐条触发「工作 → 停止 → 再工作」循环。 + # 统一「通知池」轮询器:子智能体 + 后台 run_command + 多智能体 pending 消息 + # 合并为单一轮询链路,每轮一次性取出所有待通知项,避免逐条触发「工作 → 停止 → 再工作」循环。 # 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除 # 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。 - if has_running_sub_agents or has_running_background_commands: + if has_running_sub_agents or has_running_background_commands or has_running_multi_agent: def run_completion_poll(): import asyncio loop = asyncio.new_event_loop() diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 48ff7d8..b66ffca 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Any, Callable, Dict, List, Optional from modules.sub_agent import TERMINAL_STATUSES +from modules.multi_agent.debug_logger import ma_debug _VALID_SOURCES = { @@ -329,6 +330,14 @@ def inject_multi_agent_master_message( if not raw: return None + ma_debug( + "inject_multi_agent_master_message", + conversation_id=conversation_id, + text_preview=raw[:300], + inline=inline, + after_tool_call_id=after_tool_call_id, + ) + metadata = { "runtime_injected": True, "source": "sub_agent", @@ -405,6 +414,12 @@ async def process_multi_agent_master_messages( if not pending: return 0 debug_log(f"[MultiAgent] draining {len(pending)} pending master messages") + ma_debug( + "process_multi_agent_master_messages", + conversation_id=conversation_id, + count=len(pending), + previews=[str(m)[:200] for m in pending], + ) for msg in pending: inject_multi_agent_master_message( web_terminal=web_terminal, diff --git a/server/conversation.py b/server/conversation.py index 3c4db1c..7cdd84e 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -598,6 +598,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None run_mode = data.get('mode') if preserve_mode and 'mode' in data else None target_workspace_id = (data.get('workspace_id') or '').strip() + multi_agent_mode = bool(data.get('multi_agent_mode')) if target_workspace_id: try: @@ -641,6 +642,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam metadata_overrides={ "permission_mode": default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")(), "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), + "multi_agent_mode": multi_agent_mode, }, ) try: @@ -654,7 +656,11 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam "safe_navigation": True, } else: - result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode) + result = terminal.create_new_conversation( + thinking_mode=thinking_mode, + run_mode=run_mode, + metadata_overrides={"multi_agent_mode": multi_agent_mode} if multi_agent_mode else None, + ) if result["success"]: # 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。 diff --git a/static/src/app/methods/conversation/action.ts b/static/src/app/methods/conversation/action.ts index 5b58af8..386521f 100644 --- a/static/src/app/methods/conversation/action.ts +++ b/static/src/app/methods/conversation/action.ts @@ -122,15 +122,17 @@ export const actionMethods = { this.versioningInitializingBackupToastId = backupToastId; } - const response = await fetch('/api/conversations', { + const isMultiAgent = Boolean(this.multiAgentMode); + const createUrl = isMultiAgent ? '/api/multiagent/conversations' : '/api/conversations'; + const createBody = isMultiAgent + ? JSON.stringify({ preserve_mode: true, thinking_mode: this.thinkingMode, mode: this.runMode }) + : JSON.stringify({ thinking_mode: this.thinkingMode, mode: this.runMode }); + const response = await fetch(createUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - thinking_mode: this.thinkingMode, - mode: this.runMode - }) + body: createBody }); const result = await response.json(); diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index c065805..113b97d 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -209,15 +209,17 @@ export const sendMethods = { this.versioningInitializingBackupToastId = backupToastId; } - const createResp = await fetch('/api/conversations', { + const isMultiAgent = Boolean(this.multiAgentMode); + const createUrl = isMultiAgent ? '/api/multiagent/conversations' : '/api/conversations'; + const createBody = isMultiAgent + ? JSON.stringify({ preserve_mode: true, thinking_mode: this.thinkingMode, mode: this.runMode }) + : JSON.stringify({ thinking_mode: this.thinkingMode, mode: this.runMode }); + const createResp = await fetch(createUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - thinking_mode: this.thinkingMode, - mode: this.runMode - }) + body: createBody }); const createResult = await createResp.json().catch(() => ({})); if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) { @@ -258,7 +260,8 @@ export const sendMethods = { } const pathFragment = this.stripConversationPrefix(targetConversationId); - history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); + const urlPrefix = this.multiAgentMode ? '/multiagent/' : '/'; + history.replaceState({ conversationId: targetConversationId }, '', `${urlPrefix}${pathFragment}`); } catch (error) { this.uiPushToast({ title: '发送失败', diff --git a/static/src/components/overlay/SubAgentActivityDialog.vue b/static/src/components/overlay/SubAgentActivityDialog.vue index 7c3dfcb..dc36665 100644 --- a/static/src/components/overlay/SubAgentActivityDialog.vue +++ b/static/src/components/overlay/SubAgentActivityDialog.vue @@ -29,13 +29,29 @@
{{ activityError }}
-
+
{{ activityLoading ? '正在读取子智能体活动...' : '暂无活动记录' }}
-
- {{ item.text }} - {{ item.stateLabel }} +
+ +
@@ -50,17 +66,22 @@ import { storeToRefs } from 'pinia'; import { useSubAgentStore } from '@/stores/subAgent'; type ActivityEntry = { + type?: string; id?: string; tool?: string; status?: string; args?: Record; ts?: number; + subtype?: string; + content?: string; + is_final?: boolean; }; const subAgentStore = useSubAgentStore(); const { activeAgent, activityEntries, activityLoading, activityError, stoppingTaskIds } = storeToRefs(subAgentStore); const stopError = ref(''); +const expandedOutputs = ref>(new Set()); const close = () => { subAgentStore.closeSubAgent(); @@ -141,48 +162,138 @@ const isTerminalStatus = (status?: string) => { return ['completed', 'failed', 'timeout', 'terminated', 'cancelled'].includes(normalized); }; -const displayItems = computed(() => { +const toggleOutput = (key: string) => { + const next = new Set(expandedOutputs.value); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + expandedOutputs.value = next; +}; + +const handleItemClick = (item: any) => { + if (item.kind === 'output') { + toggleOutput(item.key); + } +}; + +const timelineItems = computed(() => { const entries = activityEntries.value || []; - const groups: { key: string; entry: ActivityEntry }[] = []; + const rawItems: { kind: 'tool'; key: string; entry: ActivityEntry } | { kind: 'output'; key: string; content: string; isFinal: boolean }[] = []; + let currentToolGroup: { kind: 'tool'; key: string; entry: ActivityEntry } | null = null; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (!entry || entry.type !== 'progress') continue; + const flushToolGroup = () => { + if (currentToolGroup) { + rawItems.push(currentToolGroup); + currentToolGroup = null; + } + }; - const baseKey = entry.id || `${entry.tool || 'tool'}-${entry.ts || i}`; - const lastGroup = groups[groups.length - 1]; - - // 同一个 id 的相邻进度更新(running -> completed)合并为一次工具调用。 - // 如果上一个同 id 的组已经进入终态,则后续同 id 的记录视为新的工具调用。 - if ( - lastGroup && - (lastGroup.entry.id === entry.id || lastGroup.key === baseKey) && - !isTerminalStatus(lastGroup.entry.status) - ) { - lastGroup.entry = { ...lastGroup.entry, ...entry }; - continue; + entries.forEach((entry: ActivityEntry, index: number) => { + if (entry?.type === 'progress' && entry?.subtype === 'output' && typeof entry.content === 'string') { + flushToolGroup(); + rawItems.push({ + kind: 'output', + key: `output-${entry.ts || index}`, + content: entry.content, + isFinal: !!entry.is_final, + }); + return; } - // 生成唯一 key:id 重复时加上序号 + if (!entry || entry.type !== 'progress' || !entry.tool) return; + + const baseKey = entry.id || `${entry.tool}-${entry.ts || index}`; + if ( + currentToolGroup && + (currentToolGroup.entry.id === entry.id || currentToolGroup.key === baseKey) && + !isTerminalStatus(currentToolGroup.entry.status) + ) { + currentToolGroup.entry = { ...currentToolGroup.entry, ...entry }; + return; + } + + flushToolGroup(); let key = baseKey; let suffix = 0; - while (groups.some((g) => g.key === key)) { + while (rawItems.some((item) => item.kind === 'tool' && item.key === key)) { suffix++; key = `${baseKey}--${suffix}`; } - groups.push({ key, entry: { ...entry } }); - } + currentToolGroup = { kind: 'tool', key, entry: { ...entry } }; + }); - return groups.map((group) => { - const item = group.entry; - const state = normalizeStatus(item.status); - const stateLabel = state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中'; + flushToolGroup(); + + return rawItems.map((item) => { + if (item.kind === 'output') return item; + const state = normalizeStatus(item.entry.status); return { - key: group.key, + kind: 'tool' as const, + key: item.key, state, - stateLabel, - text: buildText(item) + stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中', + text: buildText(item.entry) }; }); }); + + diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 435796c..a001294 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -1590,7 +1590,8 @@ export async function initializeLegacySocket(ctx: any) { const hasRunningSubAgents = !!data?.has_running_sub_agents; const hasRunningBackgroundCommands = !!data?.has_running_background_commands; const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands; - if (!hasRunningBackground) { + // 多智能体模式下,主任务停止即可恢复输入区,不因子智能体后台运行保持停止按钮 + if (ctx.multiAgentMode || !hasRunningBackground) { ctx.taskInProgress = false; } else { ctx.waitingForSubAgent = hasRunningSubAgents; @@ -1616,8 +1617,8 @@ export async function initializeLegacySocket(ctx: any) { }); socketLog('任务完成', data); - // 如果有运行中的子智能体,不重置任务状态 - if (!data.has_running_sub_agents) { + // 多智能体模式下,主任务完成即视为可发送状态,后台子智能体是否 idle/running 不影响输入区 + if (ctx.multiAgentMode || !data.has_running_sub_agents) { console.log('[DEBUG] 没有运行中的子智能体,重置任务状态'); if (ctx.waitingForSubAgent) { ctx.waitingForSubAgent = false; @@ -1652,13 +1653,16 @@ export async function initializeLegacySocket(ctx: any) { }); socketLog('等待子智能体完成:', data); - // 设置标志:有子智能体在运行,阻止状态重置 - ctx.waitingForSubAgent = true; + // 多智能体模式下,子智能体 idle/running 是常态,不阻塞主对话输入区 + if (!ctx.multiAgentMode) { + // 设置标志:有子智能体在运行,阻止状态重置 + ctx.waitingForSubAgent = true; - // 保持任务进行中状态 - ctx.taskInProgress = true; - ctx.streamingMessage = false; - ctx.stopRequested = false; + // 保持任务进行中状态 + ctx.taskInProgress = true; + ctx.streamingMessage = false; + ctx.stopRequested = false; + } console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', { taskInProgress: ctx.taskInProgress, From 692748d5673f10e5deea2110dda66da2ee37d33f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 12 Jul 2026 23:09:56 +0800 Subject: [PATCH 06/19] =?UTF-8?q?fix(multi-agent):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B8=B2=E6=9F=93=E3=80=81=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E3=80=81=E5=AD=98=E5=82=A8=E4=B8=8E=E6=BB=9A=E5=8A=A8=E6=8A=96?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 子智能体消息渲染为左侧对话气泡 - 多智能体通知细分为进度/完成/提问三类 - 修复多智能体会话存储到 mutiagents 目录并迁移数据 - 修复 token-statistics API 支持多智能体会话 - 修复 ma_debug 未定义报错 - 移除 .message-block content-visibility 修复滚动抖动 --- core/main_terminal.py | 3 +- core/main_terminal_parts/tools_policy.py | 3 +- core/web_terminal.py | 14 +- modules/multi_agent/state.py | 50 ++++++ scripts/migrate_multi_agent_conversations.py | 143 ++++++++++++++++++ server/app_legacy.py | 4 +- server/chat_flow_helpers.py | 2 +- server/chat_flow_task_main.py | 55 +++++++ server/chat_flow_task_support.py | 35 ++++- server/context.py | 2 +- server/conversation.py | 26 ++-- server/multi_agent.py | 23 ++- server/socket_handlers.py | 2 +- server/status/base.py | 2 +- static/src/components/chat/ChatArea.vue | 140 ++++++++++++++++- .../styles/components/chat/_chat-area.scss | 34 ++++- utils/context_manager/base.py | 15 +- utils/context_manager/conversation_mixin.py | 121 +++++++++++++-- utils/context_manager/token_mixin.py | 8 +- 19 files changed, 618 insertions(+), 64 deletions(-) create mode 100644 scripts/migrate_multi_agent_conversations.py diff --git a/core/main_terminal.py b/core/main_terminal.py index bdffadd..af09569 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -374,7 +374,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi if not cm or not conv_id: return try: - cm.conversation_manager.update_conversation_metadata(conv_id, updates) + target_manager = cm._get_conversation_manager_for_id(conv_id) if hasattr(cm, "_get_conversation_manager_for_id") else cm.conversation_manager + target_manager.update_conversation_metadata(conv_id, updates) if isinstance(getattr(cm, "conversation_metadata", None), dict): cm.conversation_metadata.update(updates) except Exception: diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index 51bd82c..19ecb37 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -218,8 +218,7 @@ class MainTerminalToolsPolicyMixin: conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) if conv_id and getattr(self, "context_manager", None): try: - self.context_manager.conversation_manager.update_conversation_metadata( - conv_id, + self.context_manager._get_conversation_manager_for_id(conv_id).update_conversation_metadata(conv_id, {"permission_mode": normalized}, ) if self.context_manager.current_conversation_id == conv_id: diff --git a/core/web_terminal.py b/core/web_terminal.py index 24ab6ad..43f40a7 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -206,7 +206,8 @@ class WebTerminal(MainTerminal): # 把默认模型同步到新对话的 metadata try: - self.context_manager.conversation_manager.save_conversation( + target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id) + target_manager.save_conversation( conversation_id=conversation_id, messages=self.context_manager.conversation_history, project_path=str(self.project_path), @@ -275,7 +276,8 @@ class WebTerminal(MainTerminal): perf_log("_ensure_conversation_versioning_enabled manager created", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id}) meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=tracking_mode) perf_log("_ensure_conversation_versioning_enabled set_enabled done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id}) - conv_data = self.context_manager.conversation_manager.load_conversation(normalized_id) or {} + target_manager = self.context_manager._get_conversation_manager_for_id(normalized_id) + conv_data = target_manager.load_conversation(normalized_id) or {} snapshot_payload = { "conversation_id": normalized_id, "title": conv_data.get("title"), @@ -293,7 +295,7 @@ class WebTerminal(MainTerminal): init_row = init_result.get("row") or {} if init_row.get("tree_hash"): meta["last_tree_hash"] = init_row.get("tree_hash") - self.context_manager.conversation_manager.update_conversation_metadata( + target_manager.update_conversation_metadata( normalized_id, { "versioning": { @@ -324,7 +326,8 @@ class WebTerminal(MainTerminal): if success: # 根据对话元数据同步思考模式 try: - conv_data = self.context_manager.conversation_manager.load_conversation(conversation_id) or {} + target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id) + conv_data = target_manager.load_conversation(conversation_id) or {} meta = conv_data.get("metadata", {}) or {} mode = bool(meta.get("thinking_mode", self.thinking_mode)) self.thinking_mode = mode @@ -371,7 +374,8 @@ class WebTerminal(MainTerminal): self.current_session_id += 1 # 获取对话信息 - conversation_data = self.context_manager.conversation_manager.load_conversation(conversation_id) + target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id) + conversation_data = target_manager.load_conversation(conversation_id) if not conversation_data: return { "success": False, diff --git a/modules/multi_agent/state.py b/modules/multi_agent/state.py index 4c51f4a..67a1b96 100644 --- a/modules/multi_agent/state.py +++ b/modules/multi_agent/state.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio import json +import re import uuid from asyncio import AbstractEventLoop from dataclasses import dataclass, field @@ -51,6 +52,7 @@ def format_multi_agent_message( target: Optional[str] = None, extra_attrs: Optional[Dict[str, str]] = None, msg_type_text: Optional[str] = None, + subtype: Optional[str] = None, ) -> str: """按统一格式构造 user 消息字符串。 @@ -62,6 +64,7 @@ def format_multi_agent_message( target: 接收方显示名(用于子→子 提问时标明对谁提问) extra_attrs: 额外标签属性(如 question_id="ask_xxx") msg_type_text: 覆盖默认的中文消息类型文案(如"任务结束汇报") + subtype: 渲染/分类使用的子类型(如 progress_output / completion_report / ask_master) """ if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:10]}" @@ -80,6 +83,8 @@ def format_multi_agent_message( attrs = "" if target: attrs += f' target="{target}"' + if subtype: + attrs += f' subtype="{subtype}"' if extra_attrs: for k, v in extra_attrs.items(): attrs += f' {k}="{v}"' @@ -119,6 +124,13 @@ def build_master_dispatch_text(task: str, msg_id: Optional[str] = None) -> str: ) +# 子类型常量(用于前端渲染与后端分类) +SUBTYPE_PROGRESS_OUTPUT = "progress_output" +SUBTYPE_COMPLETION_REPORT = "completion_report" +SUBTYPE_ASK_MASTER = "ask_master" +SUBTYPE_ASK_OTHER = "ask_other" + + def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None, *, is_final: bool = False) -> str: """子智能体输出(进度或完成)插入到主对话的 user 消息文本。""" return format_multi_agent_message( @@ -127,6 +139,7 @@ def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optiona content=content, msg_id=msg_id, msg_type_text="任务结束汇报" if is_final else "任务进度输出", + subtype=SUBTYPE_COMPLETION_REPORT if is_final else SUBTYPE_PROGRESS_OUTPUT, ) @@ -137,6 +150,7 @@ def build_sub_agent_ask_master_text(display_name: str, question: str, question_i msg_type=TYPE_ASK, content=question, msg_id=question_id, + subtype=SUBTYPE_ASK_MASTER, ) @@ -153,9 +167,45 @@ def build_sub_agent_ask_other_text( content=question, msg_id=question_id, target=target_display, + subtype=SUBTYPE_ASK_OTHER, ) +_MULTI_AGENT_MESSAGE_RE = re.compile( + r"^来自\s+(?P.+?)\s+的(?P.+?)\n" + r"id:\s*(?P\S+)\n\n" + r"<(?P=display_name)>\n" + r"<(?P\w+)(?P[^>]*)>\n" + r"(?P.*?)\n" + r"\n" + r"$", + re.DOTALL, +) + + +def parse_multi_agent_message(text: str) -> Optional[Dict[str, str]]: + """解析标准多智能体消息格式。 + + 返回字段:display_name, type_text, msg_id, tag, subtype, content。 + 若不是标准格式则返回 None。 + """ + if not text: + return None + m = _MULTI_AGENT_MESSAGE_RE.search(text) + if not m: + return None + attrs = m.group("attrs") or "" + subtype_match = re.search(r'subtype="([^"]+)"', attrs) + return { + "display_name": m.group("display_name").strip(), + "type_text": m.group("type_text").strip(), + "msg_id": m.group("msg_id").strip(), + "tag": m.group("tag").strip(), + "subtype": subtype_match.group(1) if subtype_match else "", + "content": m.group("content"), + } + + def build_master_message_to_sub_agent(message: str, msg_id: Optional[str] = None) -> str: """主智能体 send_message_to_sub_agent 时插入子对话的 user 消息文本。""" return format_multi_agent_message( diff --git a/scripts/migrate_multi_agent_conversations.py b/scripts/migrate_multi_agent_conversations.py new file mode 100644 index 0000000..7b4832b --- /dev/null +++ b/scripts/migrate_multi_agent_conversations.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""把误存到常规对话目录的多智能体会话迁移到 mutiagents/conversations/。 + +用法: + python3 scripts/migrate_multi_agent_conversations.py [--dry-run] +""" +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import time +from datetime import datetime +from pathlib import Path + +# 加载项目配置以确定数据目录 +project_root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(project_root)) +from config import DATA_DIR, IS_HOST_MODE +from modules.multi_agent.role_store import DEFAULT_MUTIAGENTS_DIR + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def save_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(f".tmp.{int(time.time() * 1000)}") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + shutil.move(str(tmp), str(path)) + + +def migrate(dry_run: bool = False) -> dict: + regular_dir = Path(DATA_DIR) / "conversations" / "workspace" + ma_dir = Path(DEFAULT_MUTIAGENTS_DIR) / "conversations" / "workspace" + + regular_index_path = regular_dir / "index.json" + ma_index_path = ma_dir / "index.json" + + regular_index = load_json(regular_index_path) if regular_index_path.exists() else {} + ma_index = load_json(ma_index_path) if ma_index_path.exists() else {} + + moved = [] + skipped = [] + errors = [] + + for f in sorted(regular_dir.glob("conv_*.json")): + if f.name == "index.json": + continue + try: + data = load_json(f) + except Exception as exc: + errors.append({"file": str(f), "error": f"读取失败: {exc}"}) + continue + + meta = data.get("metadata", {}) or {} + if not meta.get("multi_agent_mode"): + skipped.append(f.name) + continue + + conv_id = data.get("id") or f.stem + target_file = ma_dir / f.name + + # 目标已存在时跳过,避免覆盖 + if target_file.exists(): + errors.append({"file": str(f), "error": f"目标已存在: {target_file}"}) + continue + + if dry_run: + moved.append({"id": conv_id, "file": f.name, "target": str(target_file)}) + continue + + try: + # 移动文件 + target_file.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(f), str(target_file)) + + # 更新索引 + if conv_id in regular_index: + ma_index[conv_id] = regular_index.pop(conv_id) + else: + # 从文件重建索引条目 + ma_index[conv_id] = { + "title": data.get("title") or "未命名对话", + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "project_path": meta.get("project_path"), + "project_relative_path": meta.get("project_relative_path"), + "thinking_mode": meta.get("thinking_mode", False), + "run_mode": meta.get("run_mode") or ("thinking" if meta.get("thinking_mode") else "fast"), + "model_key": meta.get("model_key"), + "has_images": meta.get("has_images", False), + "has_videos": meta.get("has_videos", False), + "total_messages": meta.get("total_messages", 0), + "total_tools": meta.get("total_tools", 0), + "status": meta.get("status", "active"), + "multi_agent_mode": True, + } + + moved.append({"id": conv_id, "file": f.name, "target": str(target_file)}) + except Exception as exc: + errors.append({"file": str(f), "error": f"迁移失败: {exc}"}) + + if not dry_run: + # 保存索引 + save_json(regular_index_path, regular_index) + save_json(ma_index_path, ma_index) + + return { + "dry_run": dry_run, + "regular_dir": str(regular_dir), + "ma_dir": str(ma_dir), + "moved": moved, + "skipped_count": len(skipped), + "errors": errors, + } + + +def main(): + parser = argparse.ArgumentParser(description="迁移多智能体会话到 mutiagents 目录") + parser.add_argument("--dry-run", action="store_true", help="只预览,不实际移动") + args = parser.parse_args() + + result = migrate(dry_run=args.dry_run) + + print(f"常规目录: {result['regular_dir']}") + print(f"目标目录: {result['ma_dir']}") + print(f"模式: {'预览' if args.dry_run else '实际迁移'}") + print(f"将迁移: {len(result['moved'])} 个对话") + print(f"跳过(非多智能体): {result['skipped_count']} 个") + print(f"错误: {len(result['errors'])}") + + for item in result["moved"]: + print(f" → {item['id']} -> {item.get('target', item['file'])}") + + for err in result["errors"]: + print(f" ⚠ {err['file']}: {err['error']}") + + +if __name__ == "__main__": + main() diff --git a/server/app_legacy.py b/server/app_legacy.py index 61af79f..8e897ff 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -525,7 +525,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati safe_title = title[:80] ok = False try: - ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title) + ok = web_terminal.context_manager._get_conversation_manager_for_id(conversation_id).update_conversation_title(conversation_id, safe_title) except Exception as exc: debug_log(f"[TitleGen] 保存标题失败: {exc}") _title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id) @@ -1082,7 +1082,7 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[ raise RuntimeError(load_result.get("message", "对话加载失败")) # 切换到对话记录的运行模式 try: - conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} + conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {} meta = conv_data.get("metadata", {}) or {} run_mode_meta = meta.get("run_mode") if run_mode_meta: diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index c5309bf..b843bcb 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -132,7 +132,7 @@ def generate_conversation_title_background( safe_title = title[:80] ok = False try: - ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title) + ok = web_terminal.context_manager._get_conversation_manager_for_id(conversation_id).update_conversation_title(conversation_id, safe_title) except Exception as exc: debug_logger(f"[TitleGen] 保存标题失败: {exc}") _title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index c8b105d..22df386 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -45,6 +45,7 @@ from modules.upload_security import UploadSecurityError from modules.user_manager import UserWorkspace from modules.usage_tracker import QUOTA_DEFAULTS from modules.sub_agent import TERMINAL_STATUSES +from modules.multi_agent.debug_logger import ma_debug from modules.versioning_manager import ConversationVersioningManager, VersioningError from modules.shallow_versioning import ShallowVersioningManager from core.web_terminal import WebTerminal @@ -569,6 +570,12 @@ async def _dispatch_completion_user_notice( for item in preceding_notices if str(item.get("message") or "").strip() ] + ma_debug( + "dispatch_completion_create_chat_task", + conversation_id=conversation_id, + user_message_preview=user_message[:300], + preceding_count=len(preceding_notices), + ) rec = task_manager.create_chat_task( username, workspace_id, @@ -580,6 +587,11 @@ async def _dispatch_completion_user_notice( run_mode=session_data.get("run_mode"), session_data=session_data, ) + ma_debug( + "dispatch_completion_task_created", + conversation_id=conversation_id, + task_id=getattr(rec, "task_id", None), + ) payload = { 'message': user_message, 'conversation_id': conversation_id, @@ -701,6 +713,7 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) - and t.get("status") in TERMINAL_STATUSES # completed/failed/timeout(不含 terminated) and not t.get("notified") and t.get("task_id") not in web_terminal._announced_sub_agent_tasks + and not t.get("multi_agent_mode") # 多智能体任务走独立注入路径,不在这里发传统通知 ] candidates.sort(key=lambda t: t.get("updated_at") or t.get("created_at") or 0) for task_info in candidates: @@ -785,6 +798,12 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) - state = sub_manager.get_multi_agent_state(conversation_id) if state: ma_messages = state.drain_master_messages() + ma_debug( + "collect_notices_drain_ma_messages", + conversation_id=conversation_id, + count=len(ma_messages), + previews=[str(m)[:200] for m in ma_messages], + ) for msg_text in ma_messages: notices.append({ "kind": "multi_agent", @@ -878,6 +897,7 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation pass loop_count = 0 + ma_debug("poll_completion_notifications_start", conversation_id=conversation_id) while (time.time() - start_wait) < max_wait_time: loop_count += 1 # 检查停止标志 @@ -885,22 +905,27 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation if client_stop_info: stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info if stop_requested: + ma_debug("poll_completion_notifications_stop_requested", conversation_id=conversation_id) break # 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入 if getattr(web_terminal, "_tool_loop_active", False): + ma_debug("poll_completion_notifications_wait_tool_loop", conversation_id=conversation_id) await asyncio.sleep(1) continue # 多智能体模式:主对话任务仍在运行时,由主循环自己消费 pending 消息 if getattr(web_terminal, "_multi_agent_main_task_active", False): + ma_debug("poll_completion_notifications_wait_main_task", conversation_id=conversation_id) await asyncio.sleep(1) continue + ma_debug("poll_completion_notifications_collect", conversation_id=conversation_id, loop_count=loop_count) notices = _collect_pending_completion_notices( web_terminal=web_terminal, conversation_id=conversation_id, ) + ma_debug("poll_completion_notifications_collected", conversation_id=conversation_id, notice_count=len(notices)) if notices: # 取出后剩余是否还有未完成/未通知的后台工作(决定前端是否保持等待态) @@ -974,6 +999,18 @@ async def handle_task_with_sender( # 多智能体模式:标记主对话任务正在运行,供后台通知池判断是否可以安全消费 if getattr(web_terminal, "multi_agent_mode", False): web_terminal._multi_agent_main_task_active = True + manager = getattr(web_terminal, "sub_agent_manager", None) + pending_count = 0 + if manager: + state = manager.get_multi_agent_state(conversation_id) + if state: + pending_count = len(state.pending_master_messages) + ma_debug( + "handle_task_with_sender_start", + conversation_id=conversation_id, + message_preview=str(message)[:300] if message else None, + pending_master_messages_count=pending_count, + ) videos = videos or [] raw_sender = sender @@ -1695,6 +1732,11 @@ async def handle_task_with_sender( sender=sender, debug_log=debug_log, ) + ma_debug( + "no_tool_call_turn_process_ma_messages", + conversation_id=conversation_id, + injected_count=injected_count, + ) if injected_count: debug_log(f"[MultiAgent] no-tool-call turn 注入 {injected_count} 条子智能体消息,继续迭代") is_first_iteration = False @@ -1907,6 +1949,13 @@ async def handle_task_with_sender( # 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除 # 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。 if has_running_sub_agents or has_running_background_commands or has_running_multi_agent: + ma_debug( + "completion_poll_start", + conversation_id=conversation_id, + has_running_sub_agents=has_running_sub_agents, + has_running_background_commands=has_running_background_commands, + has_running_multi_agent=has_running_multi_agent, + ) def run_completion_poll(): import asyncio loop = asyncio.new_event_loop() @@ -1923,6 +1972,12 @@ async def handle_task_with_sender( loop.close() socketio.start_background_task(run_completion_poll) + else: + ma_debug( + "completion_poll_skip", + conversation_id=conversation_id, + reason="no_running_background_or_multi_agent", + ) has_running_completion_jobs = has_running_sub_agents or has_running_background_commands diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index b66ffca..a13ecdc 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -179,6 +179,8 @@ async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = Fals continue if task.get("notified"): continue + if task.get("multi_agent_mode"): + continue task_conv_id = task.get("conversation_id") current_conv_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None) if task_conv_id and current_conv_id and task_conv_id != current_conv_id: @@ -315,6 +317,17 @@ async def process_background_command_updates(*, messages: List[Dict], inline: bo +def _auto_message_type_for_multi_agent_subtype(subtype: Optional[str]) -> str: + """把多智能体消息 subtype 映射为统一的 auto_message_type。""" + if subtype == "progress_output": + return "multi_agent_progress_output" + if subtype == "completion_report": + return "multi_agent_completion_report" + if subtype == "ask_master": + return "multi_agent_ask_master" + return "multi_agent_output" + + def inject_multi_agent_master_message( *, web_terminal, @@ -338,14 +351,26 @@ def inject_multi_agent_master_message( after_tool_call_id=after_tool_call_id, ) + # 解析标准格式以获取 subtype;解析失败时回退到通用类型 + try: + from modules.multi_agent.state import parse_multi_agent_message + parsed = parse_multi_agent_message(raw) + except Exception: + parsed = None + subtype = parsed.get("subtype") if parsed else None + auto_message_type = _auto_message_type_for_multi_agent_subtype(subtype) + metadata = { "runtime_injected": True, "source": "sub_agent", "message_source": "sub_agent", "inline": inline, "is_auto_generated": True, - "auto_message_type": "multi_agent_output", - "visibility": "compact", + "auto_message_type": auto_message_type, + "multi_agent_message": True, + "multi_agent_display_name": parsed.get("display_name") if parsed else None, + "multi_agent_subtype": subtype, + "visibility": "chat", "starts_work": False, } @@ -376,8 +401,12 @@ def inject_multi_agent_master_message( "inline": inline, "source": "sub_agent", "message_source": "sub_agent", - "visibility": "compact", + "visibility": "chat", "starts_work": False, + "auto_message_type": auto_message_type, + "multi_agent_message": True, + "multi_agent_display_name": parsed.get("display_name") if parsed else None, + "multi_agent_subtype": subtype, "metadata": metadata, "runtime_injected": True, } diff --git a/server/context.py b/server/context.py index aa3c67f..61f698f 100644 --- a/server/context.py +++ b/server/context.py @@ -639,7 +639,7 @@ def ensure_conversation_loaded( ).get("project_path"), ) try: - conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} + conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {} meta = conv_data.get("metadata", {}) or {} run_mode_meta = meta.get("run_mode") if run_mode_meta: diff --git a/server/conversation.py b/server/conversation.py index 7cdd84e..859e4f1 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -275,7 +275,7 @@ def _atomic_write_input_draft(path: Path, payload: Dict[str, Any]) -> None: def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]: normalized = _normalize_conv_id(conversation_id) - data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {} + data = terminal.context_manager._get_conversation_manager_for_id(normalized).load_conversation(normalized) or {} meta = data.get("metadata") or {} versioning = meta.get("versioning") or {} if not isinstance(versioning, dict): @@ -316,7 +316,7 @@ def _ensure_conversation_versioning_enabled( manager = _get_conv_versioning_manager(workspace, normalized_id) normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode or default_mode) meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=normalized_tracking_mode) - conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} + conv_data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {} snapshot_payload = { "conversation_id": normalized_id, "title": conv_data.get("title"), @@ -372,7 +372,7 @@ def _update_conversation_versioning_meta( payload["versioning"]["last_commit"] = last_commit if last_input_seq is not None: payload["versioning"]["last_input_seq"] = int(last_input_seq) - return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload) + return terminal.context_manager._get_conversation_manager_for_id(normalized).update_conversation_metadata(normalized, payload) @conversation_bp.route('/api/input-draft', methods=['GET']) @@ -710,7 +710,7 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern """获取特定对话信息""" try: # 通过ConversationManager直接获取对话数据 - conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) + conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) if conversation_data: # 提取关键信息,不返回完整消息内容(避免数据量过大) @@ -950,7 +950,7 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace: """获取对话的消息历史(可选功能,用于调试或详细查看)""" try: # 获取完整对话数据 - conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) + conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) if conversation_data: messages = conversation_data.get("messages", []) @@ -1072,7 +1072,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works manager = _get_conv_versioning_manager(workspace, normalized_id) meta = manager.set_enabled(enabled=enabled, mode=mode, tracking_mode=tracking_mode) if enabled: - conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} + conv_data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {} snapshot_payload = { "conversation_id": normalized_id, "title": conv_data.get("title"), @@ -1615,7 +1615,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use def get_conversation_compression_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}" - data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} + data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {} meta = data.get("metadata", {}) or {} return jsonify({ "success": True, @@ -1639,9 +1639,9 @@ def get_conversation_compression_status(conversation_id, terminal: WebTerminal, def cancel_conversation_compression(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}" - ok = terminal.context_manager.conversation_manager.update_conversation_metadata( - normalized_id, - { + ok = terminal.context_manager._get_conversation_manager_for_id( + normalized_id).update_conversation_metadata( + normalized_id, { "compression_in_progress": False, "compression_mode": None, "compression_stage": None, @@ -2007,7 +2007,7 @@ def review_conversation_preview(conversation_id, terminal: WebTerminal, workspac "message": "无法引用当前对话" }), 400 - conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) + conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) if not conversation_data: return jsonify({ "success": False, @@ -2050,7 +2050,7 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW "message": "无法引用当前对话" }), 400 - conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) + conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) if not conversation_data: return jsonify({ "success": False, @@ -2132,7 +2132,7 @@ def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, us # 如果是真实的对话ID,查找对话数据 try: - conversation_data = terminal.context_manager.conversation_manager.load_conversation(current_id) + conversation_data = terminal.context_manager._get_conversation_manager_for_id(current_id).load_conversation(current_id) if conversation_data: metadata = conversation_data.get("metadata", {}) or {} return jsonify({ diff --git a/server/multi_agent.py b/server/multi_agent.py index b7ad90a..ee62547 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -44,11 +44,14 @@ def rebuild_conversation_index_api(): terminal, _ = get_user_resources(username) if not terminal: return jsonify({"success": False, "error": "工作区未就绪"}), 503 - cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) - if not cm: - return jsonify({"success": False, "error": "对话管理器未初始化"}), 503 - rebuilt = cm._rebuild_index_from_files() - cm._save_index(rebuilt) + ctx_manager = getattr(terminal, "context_manager", None) + if not ctx_manager: + return jsonify({"success": False, "error": "上下文管理器未初始化"}), 503 + ma_manager = getattr(ctx_manager, "multi_agent_conversation_manager", None) + if not ma_manager: + return jsonify({"success": False, "error": "多智能体对话管理器未初始化"}), 503 + rebuilt = ma_manager._rebuild_index_from_files() + ma_manager._save_index(rebuilt) return jsonify({"success": True, "index_size": len(rebuilt)}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 @@ -150,7 +153,10 @@ def create_multi_agent_conversation(): except Exception: prefs = {} - cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + ctx_manager = getattr(terminal, "context_manager", None) + if not ctx_manager: + return jsonify({"success": False, "error": "上下文管理器未初始化"}), 500 + cm = getattr(ctx_manager, "multi_agent_conversation_manager", None) or getattr(ctx_manager, "conversation_manager", None) if not cm: return jsonify({"success": False, "error": "对话管理器未初始化"}), 500 @@ -163,7 +169,7 @@ def create_multi_agent_conversation(): if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): default_permission_mode = None - previous_cm_current = getattr(cm, "current_conversation_id", None) + previous_cm_current = getattr(ctx_manager, "current_conversation_id", None) conversation_id = cm.create_conversation( project_path=str(workspace.project_path), @@ -177,8 +183,9 @@ def create_multi_agent_conversation(): "multi_agent_mode": True, }, ) + # 恢复 context_manager 的当前对话(不要影响普通对话的 current_conversation_id) try: - cm.current_conversation_id = previous_cm_current + ctx_manager.current_conversation_id = previous_cm_current except Exception: pass diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 69cad18..9811190 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -288,7 +288,7 @@ def handle_message(data): emit('error', {'message': str(exc)}) return try: - conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} + conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {} except Exception: conv_data = {} title = conv_data.get('title', '新对话') diff --git a/server/status/base.py b/server/status/base.py index d8aa243..c0c0a3f 100644 --- a/server/status/base.py +++ b/server/status/base.py @@ -122,7 +122,7 @@ def get_status(terminal, workspace, username): current_conv = terminal.context_manager.current_conversation_id status.setdefault('conversation', {})['current_id'] = current_conv if current_conv and not current_conv.startswith('temp_'): - current_conv_data = terminal.context_manager.conversation_manager.load_conversation(current_conv) + current_conv_data = terminal.context_manager._get_conversation_manager_for_id(current_conv).load_conversation(current_conv) if current_conv_data: status['conversation']['title'] = current_conv_data.get('title', '未知对话') status['conversation']['created_at'] = current_conv_data.get('created_at') diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index b21e8aa..3cc1a72 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -13,17 +13,42 @@ 'message-block--compact-user': getMessageVisibility(msg) === 'compact', 'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg), 'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index), + 'message-block--multi-agent': isMultiAgentMessage(msg), 'message-block--last': index === (filteredMessages || []).length - 1 }" > -
+