refactor(multi-agent): 子智能体统一显示名寻址并增强可靠性

- 全局 agent_id 转为内部实现细节:由 next_free_agent_id 自动分配对话级
  最小空闲正整数,不再接受模型指定、不出现在工具参数/结果文案中
- 角色内编号改为 peek + commit 两步:创建成功才消耗编号,失败不跳号
- 所有寻址类工具改用显示名:send_message/stop/terminate 传 display_name,
  get_sub_agent_status 传 display_names,sleep 的 wait_sub_agent_output,
  子侧 ask_other_agent 传 target_display_name,answer_other_agent 去掉
  source_agent_id
- _run_loop 对 _call_model 增加重试:最多 5 次、间隔 10s、仅零接收时重试,
  输出中断直接失败;多智能体模式下 5 次全失败转 idle 并向 Team Leader 报错
- 新增工具「正在调用」进度事件(calling),与 running/completed 共用
  tool_call id,前端支持同 id 原地更新
This commit is contained in:
JOJO 2026-08-27 09:21:30 +08:00
parent 4cf9c38137
commit 16aaea6b1e
17 changed files with 559 additions and 208 deletions

View File

@ -403,7 +403,8 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
### 11.1 角色与实例
- 主智能体显示名固定为 `Team Leader`,不需要专门的预置角色文件。
- 子智能体 = `role_id`(如 `ui-operator` / `full-stack-engineer` / `code-reviewer` / `researcher`+ `agent_id`(同一 role_id 下从 1 递增)。显示名格式 `{Role Name}_{agent_id}`,例如 `UI Operator_1`,后缀永远带数字。
- 子智能体 = `role_id`(如 `ui-operator` / `full-stack-engineer` / `code-reviewer` / `researcher`+ 角色内编号(同一 role_id 下从 1 递增)。显示名格式 `{Role Name}_{角色内编号}`,例如 `UI Operator_1`,后缀永远带数字。
- **编号暴露原则2026-08-26 起)**:角色内编号显示名是**唯一**对模型和用户暴露的身份;全局 `agent_id` 是纯内部实现细节(任务字典 key / task_id 生成),由系统自动分配对话级最小空闲正整数,不接受模型指定、不出现在工具参数/结果文案/前端展示中。所有寻址类工具send_message/stop/terminate/get_sub_agent_status/sleep 的 wait_sub_agent_output、子侧 ask_other_agent一律用显示名。
- 主→子 / 子→主 / 子→子三种通信通过工具完成,工具签名见 `modules/multi_agent/tools.py`
- **`send_message_to_sub_agent``ask_sub_agent` 语义不同,必须保留两者**:前者插入引导消息不阻塞,后者阻塞等待一轮回答。
- 子智能体间通信要求同时向主智能体输出汇报,不允许「偷偷沟通」。
@ -411,6 +412,8 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
### 11.2 子智能体执行机制
- 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(避开 Flask-SocketIO threading 冲突)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.APIClient`
- **模型请求重试2026-08-26 起)**`_run_loop` 对 `_call_model` 包重试循环,与主智能体 `run_streaming_attempts` 同构——最多 5 次尝试(`_SUB_AGENT_MAX_API_RETRIES=4`)、间隔 10s`_SUB_AGENT_RETRY_DELAY_SECONDS`,用 `asyncio.sleep` 分段等待并响应软停止/取消)。重试条件:**仅当零接收**(未收到任何文本/思考/工具调用)才重试;已开始收到内容后断流(`SubAgentModelCallError.received_any=True`)直接失败。失败终态分模式:多智能体模式下 5 次全失败 → 转为 idle + `_forward_output_to_master` 报错(等 Team Leader 重新下达指令),输出期间断开 → 直接 failed 并同步向主智能体报错;传统模式一律 `_write_failure`
- **工具「正在调用」进度事件2026-08-26 起)**`_call_model` 在工具名+id 首个流式 chunk 到达时即 emit `status="calling"` 进度事件(与后续 running/completed 共用同一 tool_call id前端按 id 原地更新条目;`RunnerDetailPanel.vue` / `SubAgentActivityDialog.vue` 的 normalizeStatus 识别 `calling`(显示 spinner + 「调用中」),并支持同一 id 历史条目跨组原地更新(多工具 calling 事件交错场景)。
- 子智能体在多智能体模式下:
- `create_sub_agent` 强制 `run_in_background=False`,不触发 `sub_agent_waiting` 事件,不阻塞前端输入区。
- 子智能体自然的 assistant 输出结束(无 tool_calls即本轮任务结束进入 `idle`,上下文保留,不算 failed。
@ -508,7 +511,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 子智能体对话存在 `~/.astrion/astrion/host/host/data/sub_agents/`。重启后走 `manager.restore_sub_agent` 恢复实例引用。
- **`MultiAgentState` 是进程级全局单例**2026-07 重构):存放在 `modules/multi_agent/state.py``GLOBAL_MULTI_AGENT_STATES`key=`conversation_id`),所有 `SubAgentManager` 实例共享;`manager.multi_agent_states` 只是该全局 dict 的引用。此前它是 manager 实例属性,对话级 terminal 缓存重建会产生多个 manager各自的 `_load_state` 都从磁盘快照 `from_snapshot` 出一份独立副本,导致 terminate 只标记其中一份、前端轮询落到其他副本显示陈旧 idle。`get_or_create` / `drop` / `_load_state` restore 均通过 `GLOBAL_MULTI_AGENT_STATES_LOCK`RLock互斥。
- **进程重启后的状态校准**2026-07 新增):`_load_state` 恢复 ma 快照后,会用任务记录(持久真相)校准实例终态——任务记录是 terminated/终态而快照里还是 idle 的,一律校准为终态;同处还有存量 `_None` 后缀显示名的自愈迁移(按「对话×角色×创建时间」重编号)。
- **显示名编号语义**:显示名后缀(如 `Full-Stack Engineer_1`)是**角色内编号**`next_agent_id_for_role`,每次创建必递增),与内部 `agent_id`LLM 可手动指定,如 10001是两套独立命名空间创建路径 `tools_execution.py`不得混用。
- **显示名编号语义**:显示名后缀(如 `Full-Stack Engineer_1`)是**角色内编号**,创建路径 `tools_execution.py` 中通过 `peek_agent_id_for_role` + `commit_agent_id_for_role` 两步走——先 peek 构造显示名,创建成功后才提交计数器,**失败不消耗编号**(避免跳号);全局 `agent_id``manager.next_free_agent_id()` 自动分配(对话级最小空闲正整数),两者是两套独立命名空间,不得混用。
---

View File

@ -99,7 +99,7 @@ class ToolsDefinitionCoreToolsMixin:
"type": "function",
"function": {
"name": "sleep",
"description": "等待工具。三种模式三选一1) seconds短暂延迟2) wait_runcommand_id等待指定后台 run_command 结束并直接返回结果3) wait_sub_agent_output_ids:等待指定子智能体下一次输出并直接返回该消息(多智能体模式专用)。若同时提供多个等待参数会报错。",
"description": "等待工具。三种模式三选一1) seconds短暂延迟2) wait_runcommand_id等待指定后台 run_command 结束并直接返回结果3) wait_sub_agent_output:等待指定子智能体下一次输出并直接返回该消息(多智能体模式专用)。若同时提供多个等待参数会报错。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -111,10 +111,9 @@ class ToolsDefinitionCoreToolsMixin:
"type": "string",
"description": "等待指定后台 run_command 的 command_id 结束后返回。"
},
"wait_sub_agent_output_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "等待指定子智能体下一次输出并直接返回该消息。多智能体模式专用,且一次只能包含一个编号。"
"wait_sub_agent_output": {
"type": "string",
"description": "等待指定子智能体下一次输出并直接返回该消息。多智能体模式专用,传子智能体显示名(如 UI Operator_1"
},
"reason": {
"type": "string",

View File

@ -170,7 +170,7 @@ class ToolsDefinitionTerminalToolsMixin:
"type": "function",
"function": {
"name": "run_command",
"description": "执行一次性终端命令适合查看文件信息file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序。必须提供 timeout。前台模式run_in_background=false默认上限120秒超时会打断后台模式run_in_background=true上限3600秒会先等待5秒返回已有输出并继续在后台运行完成后由系统通知。",
"description": "执行一次性终端命令适合查看文件信息file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序。必须提供 timeout。前台模式run_in_background=false默认上限120秒超时会打断后台模式run_in_background=true上限3600秒会先等待5秒返回已有输出并继续在后台运行完成后由系统通知。后台模式会返回所有指令输出结果,禁止用于启动后台服务。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -181,7 +181,7 @@ class ToolsDefinitionTerminalToolsMixin:
},
"run_in_background": {
"type": "boolean",
"description": "是否后台运行。true 时先等待5秒返回已有输出并在后台持续执行直至结束。"
"description": "是否后台运行。true 时先等待5秒返回已有输出并在后台持续执行直至结束。禁止用于启动后台服务如常驻服务进程、watch 类命令)。"
}
}),
"required": ["command", "timeout"]

View File

@ -1382,7 +1382,7 @@ class MainTerminalToolsExecutionMixin:
elif tool_name == "sleep":
seconds = arguments.get("seconds")
wait_sub_agent_ids = arguments.get("wait_sub_agent_ids")
wait_sub_agent_output_ids = arguments.get("wait_sub_agent_output_ids")
wait_sub_agent_output = arguments.get("wait_sub_agent_output")
wait_runcommand_id = arguments.get("wait_runcommand_id")
reason = arguments.get("reason", "等待操作完成")
@ -1391,33 +1391,25 @@ class MainTerminalToolsExecutionMixin:
provided += 1
if wait_sub_agent_ids:
provided += 1
if wait_sub_agent_output_ids:
if wait_sub_agent_output:
provided += 1
if wait_runcommand_id:
provided += 1
if provided == 0:
result = {
"success": False,
"error": "sleep 至少需要提供一个参数seconds / wait_sub_agent_ids / wait_sub_agent_output_ids / wait_runcommand_id"
"error": "sleep 至少需要提供一个参数seconds / wait_sub_agent_ids / wait_sub_agent_output / wait_runcommand_id"
}
elif provided > 1:
result = {
"success": False,
"error": "sleep 的等待参数互斥seconds / wait_sub_agent_ids / wait_sub_agent_output_ids / wait_runcommand_id 只能提供一个"
"error": "sleep 的等待参数互斥seconds / wait_sub_agent_ids / wait_sub_agent_output / wait_runcommand_id 只能提供一个"
}
elif wait_sub_agent_output_ids:
elif wait_sub_agent_output:
if not getattr(self, "multi_agent_mode", False):
result = {"success": False, "error": "wait_sub_agent_output_ids 仅在多智能体模式下可用"}
elif not isinstance(wait_sub_agent_output_ids, list) or len(wait_sub_agent_output_ids) != 1:
result = {"success": False, "error": "wait_sub_agent_output_ids 必须包含且仅包含一个子智能体编号"}
else:
try:
agent_id = int(wait_sub_agent_output_ids[0])
if agent_id <= 0:
raise ValueError()
except Exception:
result = {"success": False, "error": "wait_sub_agent_output_ids 必须是正整数"}
result = {"success": False, "error": "wait_sub_agent_output 仅在多智能体模式下可用"}
else:
display_name = str(wait_sub_agent_output or "").strip()
manager = getattr(self, "sub_agent_manager", None)
if not manager:
result = {"success": False, "error": "子智能体管理器不可用"}
@ -1425,33 +1417,39 @@ class MainTerminalToolsExecutionMixin:
state = manager.get_multi_agent_state(getattr(self.context_manager, "current_conversation_id", None))
if not state:
result = {"success": False, "error": "当前对话没有多智能体状态"}
else:
# 显示名寻址:模型只传显示名,内部解析为全局 agent_id
inst = state.get_instance_by_display_name(display_name)
if not inst:
available = "".join(state.list_display_names()) or "(无)"
result = {"success": False, "error": f"未找到子智能体「{display_name}」。当前已有: {available}"}
else:
try:
loop = asyncio.get_running_loop()
fut = state.register_output_wait(agent_id, loop)
fut = state.register_output_wait(inst.agent_id, loop)
msg = await asyncio.wait_for(fut, timeout=300)
result = {
"success": True,
"mode": "wait_sub_agent_output",
"agent_id": agent_id,
"display_name": inst.display_name,
"message": msg,
}
except asyncio.TimeoutError:
result = {"success": False, "error": f"等待子智能体 {agent_id} 输出超时5分钟。可用 send_message_to_sub_agent 重新激活。"}
result = {"success": False, "error": f"等待 {inst.display_name} 输出超时5分钟。可用 send_message_to_sub_agent 重新激活。"}
except asyncio.CancelledError:
result = {"success": False, "error": f"等待子智能体 {agent_id} 被取消。该子智能体现在不可用,可用 send_message_to_sub_agent 重新激活。"}
result = {"success": False, "error": f"等待 {inst.display_name} 被取消。该子智能体现在不可用,可用 send_message_to_sub_agent 重新激活。"}
except RuntimeError as exc:
error_msg = str(exc)
if "send_message_to_sub_agent" not in error_msg:
error_msg += " 可用 send_message_to_sub_agent 重新激活。"
result = {"success": False, "error": error_msg}
except Exception as exc:
result = {"success": False, "error": f"等待子智能体 {agent_id} 输出失败: {exc}。可用 send_message_to_sub_agent 重新激活。"}
result = {"success": False, "error": f"等待 {inst.display_name} 输出失败: {exc}。可用 send_message_to_sub_agent 重新激活。"}
elif wait_sub_agent_ids:
if getattr(self, "multi_agent_mode", False):
result = {
"success": False,
"error": "多智能体模式下 sleep 工具不支持 wait_sub_agent_ids请使用 wait_sub_agent_output_ids"
"error": "多智能体模式下 sleep 工具不支持 wait_sub_agent_ids请使用 wait_sub_agent_output"
}
elif not isinstance(wait_sub_agent_ids, list) or not wait_sub_agent_ids:
result = {"success": False, "error": "wait_sub_agent_ids 必须是非空数组"}
@ -2152,14 +2150,15 @@ class MainTerminalToolsExecutionMixin:
else:
conv_id = self.context_manager.current_conversation_id
multi_agent_state = self.sub_agent_manager.get_or_create_multi_agent_state(conv_id)
# 角色内编号:每次创建都递增,作为显示名后缀
# (如 Full-Stack Engineer_1。它与内部 agent_id 是两套
# 独立命名空间——agent_id 可被 LLM 手动指定(如 10001
# 显示名后缀永远用角色内编号。
role_seq = multi_agent_state.next_agent_id_for_role(role_id)
agent_id = arguments.get("agent_id")
if not agent_id:
agent_id = role_seq
# 角色内编号:显示名后缀(如 UI Operator_1是唯一对模型/用户
# 暴露的编号。采用 peek + commit先预取构造显示名创建成功后才
# 提交计数器,失败不消耗编号,避免跳号。
role_seq = multi_agent_state.peek_agent_id_for_role(role_id)
# 全局 agent_id 是纯内部实现细节(任务字典 key / task_id 生成),
# 不暴露给模型与用户,也不接受模型指定:自动分配对话级最小空闲正整数。
agent_id = self.sub_agent_manager.next_free_agent_id(
conv_id, extra_used=set(multi_agent_state.agents.keys())
)
# 构造显示名
display_name = role.display_name(int(role_seq))
# 构造多智能体版系统提示词(含动态上下文注入)
@ -2194,7 +2193,7 @@ class MainTerminalToolsExecutionMixin:
pass
# 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用)
result = self.sub_agent_manager.create_sub_agent(
agent_id=int(agent_id),
agent_id=agent_id,
summary=summary_text,
task=arguments.get("task", ""),
run_in_background=False,
@ -2211,6 +2210,9 @@ class MainTerminalToolsExecutionMixin:
)
# 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。
# run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。
if result.get("success"):
# 创建成功才提交角色内编号;失败时 peek 未提交,编号不被消耗
multi_agent_state.commit_agent_id_for_role(role_id, role_seq)
except Exception as exc:
logger.exception("[multi_agent] create_sub_agent failed")
result = {"success": False, "error": str(exc)}
@ -2277,6 +2279,21 @@ class MainTerminalToolsExecutionMixin:
pass
elif tool_name == "terminate_sub_agent":
if getattr(self, "multi_agent_mode", False):
# 多智能体模式:按显示名寻址,内部解析为全局 agent_id
conv_id = self.context_manager.current_conversation_id
state = self.sub_agent_manager.get_multi_agent_state(conv_id)
display_name = str(arguments.get("display_name") or "").strip()
inst = state.get_instance_by_display_name(display_name) if state else None
if not inst:
available = "".join(state.list_display_names()) if state else ""
result = {"success": False, "error": f"未找到子智能体「{display_name}」。当前已有: {available or '(无)'}"}
else:
result = self.sub_agent_manager.terminate_sub_agent(agent_id=inst.agent_id)
if isinstance(result, dict):
result["display_name"] = inst.display_name
state.mark_status(inst.agent_id, "terminated")
else:
result = self.sub_agent_manager.terminate_sub_agent(
agent_id=arguments.get("agent_id")
)
@ -2286,17 +2303,33 @@ class MainTerminalToolsExecutionMixin:
# 前端 UI 手动终止走 server/conversation.py API 直调 manager不受影响。
if isinstance(result, dict):
result.pop("system_message", None)
# 多智能体模式:同步状态到 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":
if getattr(self, "multi_agent_mode", False):
# 多智能体模式:按显示名列表查询,内部解析为全局 agent_id
names = arguments.get("display_names")
if not isinstance(names, list) or not names:
result = {"success": False, "error": "display_names 必须是非空数组(子智能体显示名,如 UI Operator_1"}
else:
conv_id = self.context_manager.current_conversation_id
state = self.sub_agent_manager.get_multi_agent_state(conv_id)
agent_ids = []
not_found_names = []
for name in names:
inst = state.get_instance_by_display_name(str(name)) if state else None
if inst:
agent_ids.append(inst.agent_id)
else:
not_found_names.append(str(name))
results = []
if agent_ids:
# agent_ids 非空时 manager 侧总是返回 success=True
r = self.sub_agent_manager.get_sub_agent_status(agent_ids=agent_ids)
results.extend(r.get("results") or [])
for name in not_found_names:
results.append({"found": False, "display_name": name, "error": "子智能体不存在"})
result = {"success": True, "results": results}
else:
result = self.sub_agent_manager.get_sub_agent_status(
agent_ids=arguments.get("agent_ids", [])
)
@ -2308,18 +2341,27 @@ class MainTerminalToolsExecutionMixin:
else:
try:
from modules.multi_agent.state import build_master_message_to_sub_agent
agent_id = int(arguments.get("agent_id", 0))
display_name = str(arguments.get("display_name") or "").strip()
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:
# 显示名寻址:模型只知道角色内编号显示名(如 UI Operator_1
# 全局 agent_id 在内部解析,不暴露给模型
inst = state.get_instance_by_display_name(display_name)
if not inst:
available = "".join(state.list_display_names()) or "(无)"
result = {"success": False, "error": f"未找到子智能体「{display_name}」。当前已有: {available}"}
else:
agent_id = inst.agent_id
# 构造消息文本并插入子对话
text = build_master_message_to_sub_agent(message)
ma_debug(
"tool_send_message_to_sub_agent",
agent_id=agent_id,
display_name=display_name,
raw_message=str(message)[:500],
wrapped_message_preview=text[:500],
conversation_id=conv_id,
@ -2328,11 +2370,11 @@ class MainTerminalToolsExecutionMixin:
if not ok:
latest = self.sub_agent_manager._latest_task_for_agent(agent_id)
if latest and latest.get("status") == "terminated":
result = {"success": False, "error": f"子智能体 {agent_id} 已被手动终结,无法再接收消息。如需继续工作,请创建新的子智能体。"}
result = {"success": False, "error": f"{display_name} 已被手动终结,无法再接收消息。如需继续工作,请创建新的子智能体。"}
else:
result = {"success": False, "error": f"子智能体 {agent_id} 不存在或已结束"}
result = {"success": False, "error": f"{display_name} 不存在或已结束"}
else:
result = {"success": True, "agent_id": agent_id}
result = {"success": True, "display_name": display_name}
ma_debug(
"tool_send_message_to_sub_agent_result",
agent_id=agent_id,
@ -2349,8 +2391,17 @@ class MainTerminalToolsExecutionMixin:
result = {"success": False, "error": "该工具仅在多智能体模式下可用"}
else:
try:
agent_id = int(arguments.get("agent_id", 0))
result = self.sub_agent_manager.stop_sub_agent(agent_id=agent_id)
display_name = str(arguments.get("display_name") or "").strip()
conv_id = self.context_manager.current_conversation_id
state = self.sub_agent_manager.get_multi_agent_state(conv_id)
inst = state.get_instance_by_display_name(display_name) if state else None
if not inst:
available = "".join(state.list_display_names()) if state else ""
result = {"success": False, "error": f"未找到子智能体「{display_name}」。当前已有: {available or '(无)'}"}
else:
result = self.sub_agent_manager.stop_sub_agent(agent_id=inst.agent_id)
if isinstance(result, dict):
result["display_name"] = inst.display_name
except Exception as exc:
logger.exception("[multi_agent] stop_sub_agent failed")
result = {"success": False, "error": str(exc)}

View File

@ -287,19 +287,46 @@ class MultiAgentState:
# 一个 agent 可能同时只阻塞在一个 ask 工具上(最简实现)
# key = agent_id, value = question_id表示当前 agent 正阻塞等待)
self.agent_blocking_question: Dict[int, str] = {}
# 主智能体通过 sleep(wait_sub_agent_output_ids) 等待某个子智能体下一次输出
# 主智能体通过 sleep(wait_sub_agent_output) 等待某个子智能体下一次输出
# key = agent_id, value = asyncio.Future结果为完整消息文本
self.output_waits: Dict[int, asyncio.Future] = {}
# 角色实例计数role_id -> 已分配的最大 agent_id数字
# 用于创建新实例时自动递增编号,但允许调用方显式指定
# 角色实例计数role_id -> 已成功创建实例使用的最大角色内编号
# 角色内编号是显示名后缀(如 UI Operator_1也是唯一对模型/用户暴露的编号;
# 全局 agent_id 为内部实现细节,不对外暴露。
# 采用 peek + commit 两步:创建失败不消耗编号,避免跳号。
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 peek_agent_id_for_role(self, role_id: str) -> int:
"""预取指定角色的下一个角色内编号(不递增计数器)。
创建子智能体时先 peek 构造显示名创建成功后必须调
commit_agent_id_for_role 提交失败则不提交编号不被消耗
"""
return self.role_counters.get(role_id, 0) + 1
def commit_agent_id_for_role(self, role_id: str, seq: int) -> None:
"""创建成功后提交角色内编号(单调递增,不回退)。"""
if seq > self.role_counters.get(role_id, 0):
self.role_counters[role_id] = seq
def get_instance_by_display_name(self, display_name: str) -> Optional[AgentInstance]:
"""按显示名(如 UI Operator_1查找实例精确匹配优先大小写不敏感兜底。"""
name = (display_name or "").strip()
if not name:
return None
for a in self.agents.values():
if a.display_name == name:
return a
lowered = name.lower()
for a in self.agents.values():
if a.display_name.lower() == lowered:
return a
return None
def list_display_names(self) -> List[str]:
"""返回当前所有实例的显示名(用于错误提示)。"""
return [a.display_name for a in self.agents.values()]
def register_instance(self, instance: AgentInstance) -> None:
if instance.agent_id in self.agents:
@ -339,7 +366,7 @@ class MultiAgentState:
a.status = status
if last_output:
a.last_output = last_output
# 当子智能体进入终态/idle 时,立即取消 sleep(wait_sub_agent_output_ids) 的等待
# 当子智能体进入终态/idle 时,立即取消 sleep(wait_sub_agent_output) 的等待
if status in ("failed", "terminated", "idle"):
self._cancel_output_wait(agent_id, status)
@ -348,12 +375,14 @@ class MultiAgentState:
fut = self.output_waits.pop(agent_id, None)
if not fut or fut.done():
return
inst = self.agents.get(agent_id)
name = inst.display_name if inst else str(agent_id)
if status == "terminated":
msg = f"子智能体 {agent_id} 已终止,无法继续等待输出。"
msg = f"子智能体 {name} 已终止,无法继续等待输出。"
elif status == "failed":
msg = f"子智能体 {agent_id} 已失败,无法继续等待输出。该子智能体可被复活,可用 send_message_to_sub_agent 重新激活。"
msg = f"子智能体 {name} 已失败,无法继续等待输出。该子智能体可被复活,可用 send_message_to_sub_agent 重新激活。"
else:
msg = f"子智能体 {agent_id} 已进入空闲状态,无法继续等待输出。可用 send_message_to_sub_agent 重新激活。"
msg = f"子智能体 {name} 已进入空闲状态,无法继续等待输出。可用 send_message_to_sub_agent 重新激活。"
try:
loop = fut.get_loop()
if loop and not loop.is_closed():
@ -394,7 +423,7 @@ class MultiAgentState:
def has_pending_master_messages(self) -> bool:
return len(self.pending_master_messages) > 0
# ----- 等待子智能体输出sleep wait_sub_agent_output_ids -----
# ----- 等待子智能体输出sleep wait_sub_agent_output -----
def register_output_wait(
self, agent_id: int, loop: AbstractEventLoop
) -> asyncio.Future:
@ -406,13 +435,13 @@ class MultiAgentState:
fut: asyncio.Future = loop.create_future()
inst = self.agents.get(agent_id)
if not inst:
fut.set_exception(ValueError(f"未找到子智能体 {agent_id}"))
fut.set_exception(ValueError("未找到子智能体"))
return fut
if inst.status in ("terminated", "failed"):
fut.set_exception(
RuntimeError(
f"子智能体 {agent_id} 当前状态为 {inst.status},无法等待输出"
f"子智能体 {inst.display_name} 当前状态为 {inst.status},无法等待输出"
)
)
return fut

View File

@ -49,10 +49,6 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]:
"type": "string",
"description": "要交给该子智能体执行的任务描述。要求包含:目标、范围、产出、注意事项。",
},
"agent_id": {
"type": "integer",
"description": "(可选)手动指定实例编号;不传时自动递增。",
},
"thinking_mode": {
"type": "string",
"enum": ["fast", "thinking"],
@ -74,9 +70,9 @@ def _master_tool_stop_sub_agent() -> Dict[str, Any]:
"parameters": {
"type": "object",
"properties": _inject_intent({
"agent_id": {"type": "integer", "description": "要暂停的子智能体编号"},
"display_name": {"type": "string", "description": "要暂停的子智能体显示名(如 UI Operator_1"},
}),
"required": ["agent_id"],
"required": ["display_name"],
},
},
}
@ -91,9 +87,9 @@ def _master_tool_terminate_sub_agent() -> Dict[str, Any]:
"parameters": {
"type": "object",
"properties": _inject_intent({
"agent_id": {"type": "integer", "description": "要终止的子智能体编号"},
"display_name": {"type": "string", "description": "要终止的子智能体显示名(如 UI Operator_1"},
}),
"required": ["agent_id"],
"required": ["display_name"],
},
},
}
@ -112,10 +108,10 @@ def _master_tool_send_message_to_sub_agent() -> Dict[str, Any]:
"parameters": {
"type": "object",
"properties": _inject_intent({
"agent_id": {"type": "integer", "description": "目标子智能体编号"},
"display_name": {"type": "string", "description": "目标子智能体显示名(如 UI Operator_1"},
"message": {"type": "string", "description": "要插入的消息或新任务正文。"},
}),
"required": ["agent_id", "message"],
"required": ["display_name", "message"],
},
},
}
@ -183,7 +179,7 @@ def _master_tool_list_active_sub_agents() -> Dict[str, Any]:
"type": "function",
"function": {
"name": "list_active_sub_agents",
"description": "列出当前多智能体会话中所有活跃/已创建的子智能体实例agent_id/role/display_name/status)。",
"description": "列出当前多智能体会话中所有已创建的子智能体实例(显示名/角色/状态/任务摘要)。",
"parameters": {"type": "object", "properties": _inject_intent({})},
},
}
@ -198,9 +194,9 @@ def _master_tool_get_sub_agent_status() -> Dict[str, Any]:
"parameters": {
"type": "object",
"properties": _inject_intent({
"agent_ids": {"type": "array", "items": {"type": "integer"}, "description": "要查询的子智能体编号列表"},
"display_names": {"type": "array", "items": {"type": "string"}, "description": "要查询的子智能体显示名列表(如 [\"UI Operator_1\", \"Researcher_2\"]"},
}),
"required": ["agent_ids"],
"required": ["display_names"],
},
},
}
@ -254,11 +250,11 @@ def _sub_tool_ask_other_agent() -> Dict[str, Any]:
"parameters": {
"type": "object",
"properties": _inject_intent({
"target_agent_id": {"type": "integer", "description": "目标子智能体编号"},
"target_display_name": {"type": "string", "description": "目标子智能体显示名(如 Researcher_2可先用 list_active_sub_agents 查看"},
"question": {"type": "string", "description": "提问内容。"},
"question_id": {"type": "string", "description": "(可选)问题 id。"},
}),
"required": ["target_agent_id", "question"],
"required": ["target_display_name", "question"],
},
},
}
@ -276,11 +272,10 @@ def _sub_tool_answer_other_agent() -> Dict[str, Any]:
"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"],
"required": ["question_id", "answer"],
},
},
}

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Set
from config import SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_MAX_ACTIVE
@ -53,6 +53,36 @@ class SubAgentCreationMixin:
used = self.conversation_agents.setdefault(conversation_id, [])
return agent_id not in used
def next_free_agent_id(self, conversation_id: Optional[str], extra_used: Optional[Set[int]] = None) -> int:
"""分配对话级全局最小空闲 agent_id。
agent_id 是对话全局唯一的内部编号不对外暴露模型与用户只看到
角色内编号显示名多智能体模式下创建实例时由系统自动分配
不接受调用方指定
"""
used: Set[int] = set()
if conversation_id:
for aid in self.conversation_agents.get(conversation_id, []) or []:
try:
used.add(int(aid))
except (TypeError, ValueError):
continue
for task in self.tasks.values():
if conversation_id and task.get("conversation_id") != conversation_id:
continue
try:
aid = task.get("agent_id")
if aid is not None:
used.add(int(aid))
except (TypeError, ValueError):
continue
if extra_used:
used.update(extra_used)
n = 1
while n in used:
n += 1
return n
def _mark_agent_id_used(self, conversation_id: str, agent_id: int):
used = self.conversation_agents.setdefault(conversation_id, [])
if agent_id not in used:

View File

@ -260,6 +260,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
return {"success": False, "error": "缺少对话ID无法创建子智能体"}
if not self._ensure_agent_slot_available(conversation_id, agent_id):
# 多智能体模式的 agent_id 由系统自动分配且不对外暴露,
# 走到这里说明自动分配与其他写入路径竞争出错,不应把内部编号抛给模型
if multi_agent_mode:
return {
"success": False,
"error": "内部错误:实例编号分配冲突,请重试创建。"
}
return {
"success": False,
"error": f"该对话已使用过编号 {agent_id},请更换新的子智能体代号。"
@ -354,7 +361,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
multi_agent_state.register_instance(inst)
except ValueError:
shutil.rmtree(task_root, ignore_errors=True)
return {"success": False, "error": f"agent_id {agent_id} 已在该会话中使用"}
# 多智能体模式的 agent_id 是内部编号,不把具体值抛给模型
return {"success": False, "error": "内部错误:实例注册冲突,请重试创建。"}
sub_agent = SubAgentTask(
manager=self,
@ -427,7 +435,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
message = f"子智能体{agent_id} 已创建任务ID: {task_id}"
if multi_agent_mode and display_name:
message = f"{display_name} 已创建任务ID: {task_id}"
# 多智能体模式:对用户/模型只暴露显示名task_id 为内部细节不进文案
message = f"{display_name} 已创建。"
print(f"{OUTPUT_FORMATS['info']} {message}")
ma_debug(
"manager_create_sub_agent",
@ -607,11 +616,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
agent_id=real_agent_id,
had_instance=bool(sub_agent),
)
display_name = task.get("display_name") or f"子智能体{real_agent_id}"
return {
"success": True,
"task_id": real_task_id,
"agent_id": real_agent_id,
"message": f"子智能体{real_agent_id} 已暂停,可用 send_message_to_sub_agent 重新激活。",
"display_name": task.get("display_name") or None,
"message": f"{display_name} 已暂停,可用 send_message_to_sub_agent 重新激活。",
}
def terminate_sub_agent(
@ -853,6 +864,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"task_id": task["task_id"],
"status": status,
"summary": task.get("summary"),
"display_name": task.get("display_name"),
"created_at": task.get("created_at"),
"updated_at": task.get("updated_at"),
"deliverables_dir": task.get("deliverables_dir"),

View File

@ -31,6 +31,26 @@ from modules.multi_agent.debug_logger import ma_debug
logger = setup_logger(__name__)
class SubAgentModelCallError(RuntimeError):
"""子智能体模型请求失败。
received_any=False请求阶段失败未收到任何文本/思考/工具调用可重试
received_any=True已开始收到流内容后断开输出期间断开直接失败不重试
语义与主智能体 run_streaming_attempts can_retry 判定一致
not full_response and not tool_calls and not current_thinking
"""
def __init__(self, message: str, *, received_any: bool = False):
super().__init__(message)
self.received_any = received_any
# 模型请求失败重试策略(与主智能体 max_api_retries=4 / retry_delay_seconds=10 对齐:
# 最多重试 4 次,即共 5 次尝试,重试间隔 10 秒)
_SUB_AGENT_MAX_API_RETRIES = 4
_SUB_AGENT_RETRY_DELAY_SECONDS = 10
# 多智能体模式下额外加载的工具定义
def _load_multi_agent_sub_agent_tools() -> List[Dict[str, Any]]:
try:
@ -310,7 +330,80 @@ class SubAgentTask:
# 安全点:写入延迟的上下文通知(不触发新一轮工作,仅让下一轮模型调用看到)
self._flush_pending_notifications()
# 模型请求(带失败重试,与主智能体同构:最多 5 次尝试、重试间隔 10s、
# 仅当零接收——未收到任何文本/思考/工具调用——时重试;
# 已开始收到内容后断开属于输出期间故障,直接失败不重试)
assistant_message = ""
reasoning = ""
tool_calls = []
usage = None
call_error: Optional[SubAgentModelCallError] = None
for api_attempt in range(_SUB_AGENT_MAX_API_RETRIES + 1):
if self._cancelled:
raise asyncio.CancelledError()
try:
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
call_error = None
break
except SubAgentModelCallError as exc:
call_error = exc
can_retry = api_attempt < _SUB_AGENT_MAX_API_RETRIES and not exc.received_any
ma_debug(
"sub_agent_api_attempt_failed",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
turn=turn,
attempt=api_attempt + 1,
max_attempts=_SUB_AGENT_MAX_API_RETRIES + 1,
received_any=exc.received_any,
can_retry=can_retry,
error=str(exc)[:500],
)
if not can_retry:
break
# 重试本身也是一次额外 API 请求
self.stats["api_calls"] += 1
# 重试等待期间必须响应软停止/硬取消
wait_until = time.time() + _SUB_AGENT_RETRY_DELAY_SECONDS
while time.time() < wait_until:
if self._cancelled:
raise asyncio.CancelledError()
if self._soft_stop:
break
await asyncio.sleep(0.2)
continue
if call_error is not None:
if self.multi_agent_mode and not call_error.received_any:
# 5 次尝试全部失败(均为零接收):子智能体不终止,转为 idle
# 并向 Team Leader 汇报错误,等待检查网络后重新下达指令
error_report = (
f"⚠️ 模型请求连续 {_SUB_AGENT_MAX_API_RETRIES + 1} 次失败(网络或 API 异常):"
f"{call_error}。本轮任务无法继续,我已进入空闲状态,请检查网络/模型服务后重新给我下达指令。"
)
ma_debug(
"sub_agent_api_retries_exhausted_idle",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
turn=turn,
)
self._forward_output_to_master(error_report, is_final=True)
self._mark_idle()
self._idle = True
self._persist_conversation(partial_summary=error_report[:200])
continue
# 输出期间断开(已开始收到内容)或传统后台模式:直接失败,
# 异常上抛由 run() 捕获后 _write_failure 落盘 failed 状态
if self.multi_agent_mode:
# 同步把失败状态告知 Team Leader避免主智能体无感知空等
self._forward_output_to_master(
f"⚠️ 模型输出中断(收到部分内容后连接断开):{call_error}。本轮任务失败。",
is_final=True,
)
raise call_error
if usage:
self._apply_usage(usage)
@ -443,7 +536,7 @@ class SubAgentTask:
try:
from modules.multi_agent.state import build_sub_agent_output_text
msg = build_sub_agent_output_text(self.display_name, output_text.strip(), is_final=is_final)
# 如果该 agent 正被 sleep(wait_sub_agent_output_ids) 等待,把输出交给等待方,
# 如果该 agent 正被 sleep(wait_sub_agent_output) 等待,把输出交给等待方,
# 不再插入主对话(避免同一条消息出现两遍)
if self.multi_agent_state.claim_output_wait(self.agent_id, msg):
ma_debug(
@ -651,12 +744,21 @@ class SubAgentTask:
model_key: str,
tools: List[Dict[str, Any]],
) -> tuple:
"""调用模型并解析 assistant 消息。"""
"""调用模型并解析 assistant 消息。
失败时抛出 SubAgentModelCallError携带 received_any 标记区分
请求阶段失败可重试输出期间断开直接失败
"""
assistant_message = ""
reasoning = ""
tool_calls: List[Dict[str, Any]] = []
usage = None
# 是否已收到任何实质内容(文本/思考/工具调用)
received_any = False
# 已发出「正在调用」进度事件的 tool_call index避免重复发
calling_emitted: Set[int] = set()
try:
async for chunk in client.chat(self.messages, tools=tools, stream=True):
if self._soft_stop:
# 软停止:优雅中断当前模型调用,后续由 _run_loop 丢弃半成品并进入 idle
@ -665,14 +767,26 @@ class SubAgentTask:
# 硬取消:立即抛出 CancelledError确保不会返回半成品的 tool_calls 继续执行
raise asyncio.CancelledError()
if chunk.get("error"):
raise RuntimeError(f"API 调用失败: {chunk.get('error')}")
error_info = chunk.get("error")
if isinstance(error_info, dict):
error_text = (
error_info.get("error_message")
or error_info.get("error_text")
or str(error_info)
)
else:
error_text = str(error_info)
raise SubAgentModelCallError(f"API 调用失败: {error_text}", received_any=received_any)
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
assistant_message += delta["content"]
received_any = True
if delta.get("reasoning_content"):
reasoning += delta["reasoning_content"]
received_any = True
elif delta.get("reasoning_details"):
received_any = True
rd = delta["reasoning_details"]
if isinstance(rd, list):
reasoning += "".join(str(d.get("text") or "") for d in rd)
@ -685,6 +799,7 @@ class SubAgentTask:
idx = tc.get("index")
if idx is None:
continue
received_any = True
while len(tool_calls) <= idx:
tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
existing = tool_calls[idx]
@ -695,9 +810,30 @@ class SubAgentTask:
existing["function"]["name"] += fn["name"]
if fn.get("arguments"):
existing["function"]["arguments"] += fn["arguments"]
# 工具名与 id 就位后立刻发出「正在调用」进度事件,让前端在参数
# 流式生成期间就能显示该工具条目(与主智能体的「正在调用工具」对齐)。
# 与后续 running/completed 事件共用同一 tool_call id前端按 id 更新同一条目
if (
idx not in calling_emitted
and existing["function"]["name"]
and existing.get("id")
):
calling_emitted.add(idx)
self.emit("progress", {
"id": existing["id"],
"tool": existing["function"]["name"],
"status": "calling",
"args": {},
"ts": int(time.time() * 1000),
})
if chunk.get("usage"):
usage = chunk["usage"]
except (asyncio.CancelledError, SubAgentModelCallError):
raise
except Exception as exc:
# chat 流本身抛出的漏网异常(如底层连接错误未被转为 error chunk 时)
raise SubAgentModelCallError(f"API 调用异常: {exc}", received_any=received_any) from exc
return assistant_message, reasoning, tool_calls, usage
@ -757,7 +893,7 @@ class SubAgentTask:
return {"success": True, "answer": answer, "question_id": question_id}
if name == "ask_other_agent":
target_id = int(args.get("target_agent_id") or 0)
target_name = str(args.get("target_display_name") or "").strip()
question = str(args.get("question") or "").strip()
question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}")
ma_debug(
@ -765,21 +901,25 @@ class SubAgentTask:
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
target_agent_id=target_id,
target_display_name=target_name,
question_id=question_id,
question=question[:500],
)
if not target_id or not question:
if not target_name or not question:
return {"success": False, "error": "参数缺失"}
# 查找目标实例
target_inst = state.get_instance(target_id)
# 显示名寻址:子智能体只知道对方的角色内编号显示名,
# 全局 agent_id 在内部解析,不暴露给模型
target_inst = state.get_instance_by_display_name(target_name)
if not target_inst:
return {"success": False, "error": f"agent {target_id} 不存在"}
available = "".join(
n for n in state.list_display_names() if n != self.display_name
) or "(无)"
return {"success": False, "error": f"未找到子智能体「{target_name}」。当前可用: {available}"}
# 构造提问消息并插入到目标子对话;同时要求其在下一轮调用 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)
self.manager.inject_message_to_sub_agent(target_inst.agent_id, msg)
# 阻塞等待回答
try:
answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600))
@ -788,7 +928,7 @@ class SubAgentTask:
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 全局唯一,足以定位等待方,无需提问方编号
question_id = str(args.get("question_id") or "")
answer = str(args.get("answer") or "").strip()
if not question_id or not answer:

View File

@ -144,7 +144,7 @@
当子智能体正在工作时,你的行为必须遵循以下规则:
- **严格禁止反复查看状态**:不要在子智能体运行期间反复调用 `get_sub_agent_status` 或 `list_active_sub_agents` 来查看进度。子智能体的输出会自动以消息形式插入你的对话,你不需要主动轮询。反复查状态是浪费时间的行为。
- **等待子智能体输出的正确方式**:如果你没有需要立即处理的事情,可以选择**立刻停止输出**(等待子智能体的汇报消息自动到达),也可以选择使用 `sleep` 工具的 `wait_sub_agent_output_ids` 主动等待指定子智能体的下一次输出。不要在子智能体运行期间反复调用 `sleep` 做无意义的短延迟。
- **等待子智能体输出的正确方式**:如果你没有需要立即处理的事情,可以选择**立刻停止输出**(等待子智能体的汇报消息自动到达),也可以选择使用 `sleep` 工具的 `wait_sub_agent_output`(传子智能体显示名)主动等待指定子智能体的下一次输出。不要在子智能体运行期间反复调用 `sleep` 做无意义的短延迟。
- **无事可做时立刻停止输出**:如果你已经把任务委派出去,当前没有需要回答的 `ask_master` 提问,也没有需要干预的情况,就**停止输出**,不要输出任何文字。子智能体的产出会以 user 消息自动插入你的对话,届时你再继续工作。
- **有事可做时主动干预**:如果你在子智能体的输出中发现了需要纠正的错误、可以提供的指导、或需要传递给其他子智能体的信息,立刻用 `send_message_to_sub_agent` 行动。这是你运行期间最有价值的工作。
@ -286,8 +286,9 @@ id: ask_fse_001
## 关于显示名
- 主智能体固定显示名:`Team Leader`
- 子智能体显示名:`{角色名}_{agent_id}`,如 `UI Operator_1`、`Full-Stack Engineer_2`
- 一个角色可以有多个实例(同 role_id 多 agent_id
- 子智能体显示名:`{角色名}_{角色内编号}`,如 `UI Operator_1`、`Full-Stack Engineer_2`
- 编号按角色独立递增:同一角色的多个实例编号依次为 1、2、3……不同角色各自从 1 开始(例如已存在 Researcher_1~3 时,第一个 UI Operator 仍然是 UI Operator_1
- 所有涉及目标子智能体的工具参数一律使用显示名寻址:`send_message_to_sub_agent` / `stop_sub_agent` / `terminate_sub_agent` 传 `display_name``get_sub_agent_status` 传 `display_names``sleep` 的 `wait_sub_agent_output` 传显示名
## 关于通信协议的三条硬性原则

View File

@ -66,9 +66,10 @@
- 工具会阻塞等待 Team Leader 通过 `answer_sub_agent_question` 给出回答
- 你的 question 会以 XML「提问」格式被插入主对话
- 提问要具体、可回答,不要问开放式问题让 Team Leader 猜你的意思
- **要问其他子智能体时**:调用 `ask_other_agent`,传入 target_agent_id 与 question
- **要问其他子智能体时**:调用 `ask_other_agent`,传入 target_display_name对方显示名如 Researcher_2与 question
- 等待对方调用 `answer_other_agent` 回答
- **要回答其他子智能体的提问时**:调用 `answer_other_agent`,传入 source_agent_id 与 question_id 和 answer
- 不确定有哪些队友时,先调用 `list_active_sub_agents` 查看
- **要回答其他子智能体的提问时**:调用 `answer_other_agent`,传入 question_id 和 answer
- 你的回答直接作为对方 `ask_other_agent` 工具的结果返回(不会以 user 消息插入对话)
- **查询当前活跃子智能体**:调用 `list_active_sub_agents`

View File

@ -192,6 +192,8 @@ function renderDefaultResult(result: any, args: any, name: string): string {
'file_path',
'agent_id',
'target_agent_id',
'display_name',
'target_display_name',
'question',
'role_id',
'url'
@ -204,6 +206,8 @@ function renderDefaultResult(result: any, args: any, name: string): string {
file_path: '路径',
agent_id: '子智能体 ID',
target_agent_id: '目标子智能体 ID',
display_name: '子智能体',
target_display_name: '目标子智能体',
question: '问题',
role_id: '角色 ID',
url: 'URL'
@ -827,9 +831,9 @@ function renderSleep(result: any, args: any): string {
html += `<div><strong>原因:</strong>${escapeHtml(String(args.reason))}</div>`;
}
} else if (mode === 'wait_sub_agent_output') {
const agentId = result?.agent_id ?? args?.wait_sub_agent_output_ids ?? '';
if (agentId !== '') {
html += `<div><strong>等待子智能体:</strong>#${escapeHtml(String(agentId))}</div>`;
const targetName = result?.display_name ?? args?.wait_sub_agent_output ?? '';
if (targetName !== '') {
html += `<div><strong>等待子智能体:</strong>${escapeHtml(String(targetName))}</div>`;
}
} else if (mode === 'wait_sub_agent_ids') {
const agentIds = Array.isArray(result?.agent_ids) ? result.agent_ids : [];
@ -1454,6 +1458,8 @@ function renderEasterEgg(result: any, args: any): string {
function renderCreateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已创建', '✗ 创建失败');
const agentId = result.agent_id ?? args.agent_id ?? '';
// 多智能体模式:只展示角色内编号显示名,全局 agent_id/task_id 不暴露
const displayName = result.display_name ?? '';
const taskId = result.task_id ?? '';
const deliverablesDir = result.deliverables_dir ?? '';
const taskDescription = args.task ?? '';
@ -1474,13 +1480,15 @@ function renderCreateSubAgent(result: any, args: any): string {
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
if (displayName) {
html += `<div><strong>子智能体:</strong>${escapeHtml(String(displayName))}</div>`;
} else if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
if (taskId !== '' && !displayName) {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
if (deliverablesDir !== '') {
if (deliverablesDir !== '' && !displayName) {
html += `<div><strong>交付目录:</strong>${escapeHtml(String(deliverablesDir))}</div>`;
}
if (taskDescription) {
@ -1518,16 +1526,19 @@ function renderCreateSubAgent(result: any, args: any): string {
function renderTerminateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已关闭', '✗ 关闭失败');
const displayName = result.display_name ?? args.display_name ?? '';
const agentId = result.agent_id ?? args.agent_id ?? '';
const taskId = result.task_id ?? '';
const message = result.message ?? result.system_message ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
if (displayName) {
html += `<div><strong>子智能体:</strong>${escapeHtml(String(displayName))}</div>`;
} else if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
if (taskId !== '' && !displayName) {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
@ -1561,7 +1572,9 @@ function renderGetSubAgentStatus(result: any): string {
const summary = item.summary ?? item.final_result?.message ?? item.final_result?.summary ?? '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">子智能体 #${escapeHtml(String(agentId))}</div>`;
const itemDisplayName = item.display_name || '';
const itemHeader = itemDisplayName || `子智能体 #${agentId}`;
html += `<div class="sub-agent-status-header">${escapeHtml(String(itemHeader))}</div>`;
if (!found) {
html += '<div class="tool-result-meta">';
@ -1580,7 +1593,7 @@ function renderGetSubAgentStatus(result: any): string {
} else {
html += `<div><strong>状态:</strong>${escapeHtml(status || '未知')}</div>`;
}
if (taskId !== '') {
if (taskId !== '' && !itemDisplayName) {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
@ -1601,11 +1614,14 @@ function renderGetSubAgentStatus(result: any): string {
function renderSendMessageToSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已发送', '✗ 发送失败');
const displayName = args.display_name ?? result.display_name ?? '';
const agentId = args.agent_id ?? result.agent_id ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
if (displayName) {
html += `<div><strong>子智能体:</strong>${escapeHtml(String(displayName))}</div>`;
} else if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (!result?.success && result?.error) {
@ -1727,7 +1743,8 @@ function renderListActiveSubAgents(result: any): string {
const lastOutput = agent.last_output || '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">#${escapeHtml(String(agentId))} ${escapeHtml(displayName)} [${escapeHtml(status)}]</div>`;
// 只展示角色内编号显示名,不暴露全局 agent_id
html += `<div class="sub-agent-status-header">${escapeHtml(displayName)} [${escapeHtml(status)}]</div>`;
html += '<div class="tool-result-meta">';
if (summary) {
html += `<div><strong>任务:</strong>${escapeHtml(String(summary))}</div>`;

View File

@ -33,7 +33,7 @@
:class="[item.kind === 'tool' ? 'feed-tool' : 'feed-text', { 'is-new': animatedKeys.has(item.key) }]"
>
<template v-if="item.kind === 'tool'">
<span v-if="item.state === 'running'" class="qd-tool-spinner"></span>
<span v-if="item.state === 'running' || item.state === 'calling'" class="qd-tool-spinner"></span>
<span v-else class="qd-tool-done">{{ item.state === 'failed' ? '✕' : '✓' }}</span>
<span class="tool-name">{{ item.toolName }}</span>
<span class="tool-param" :title="item.text">{{ item.text }}</span>
@ -257,6 +257,7 @@ const tokensTitle = computed(() => `上下文 ${currentTokens.value.toLocaleStri
function normalizeStatus(status?: string) {
if (status === 'running' || status === 'in_progress') return 'running';
if (status === 'calling') return 'calling';
if (status === 'completed' || status === 'done' || status === 'success') return 'completed';
if (status === 'failed' || status === 'error') return 'failed';
return status || 'running';
@ -324,11 +325,24 @@ const timelineItems = computed<(ToolTimelineItem | OutputTimelineItem)[]>(() =>
typeof entry.content === 'string'
) {
flushToolGroup();
rawItems.push({
// output
// assistant tool_calls output
// assistant
const outputItem: OutputTimelineItem = {
kind: 'output',
key: `output-${entry.ts || index}`,
content: entry.content
});
};
let cut = rawItems.length;
while (cut > 0) {
const tail = rawItems[cut - 1];
if (tail.kind === 'tool' && !isEntryTerminal(tail.entry?.status)) {
cut -= 1;
} else {
break;
}
}
rawItems.splice(cut, 0, outputItem);
return;
}
@ -346,6 +360,19 @@ const timelineItems = computed<(ToolTimelineItem | OutputTimelineItem)[]>(() =>
return;
}
// tool_call id
//
if (entry.id) {
const prior = rawItems.find(
(item) =>
item.kind === 'tool' && item.entry.id === entry.id && !isEntryTerminal(item.entry.status)
);
if (prior && prior.kind === 'tool') {
prior.entry = { ...prior.entry, ...entry };
return;
}
}
flushToolGroup();
let key = baseKey;
let suffix = 0;
@ -367,7 +394,7 @@ const timelineItems = computed<(ToolTimelineItem | OutputTimelineItem)[]>(() =>
kind: 'tool' as const,
key: item.key,
state,
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中',
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : state === 'calling' ? '调用中' : '进行中',
toolName: item.entry.tool || '工具',
text: buildText(item.entry)
};

View File

@ -84,6 +84,7 @@ const close = () => {
const normalizeStatus = (status?: string) => {
if (status === 'running' || status === 'in_progress') return 'running';
if (status === 'calling') return 'calling';
if (status === 'completed' || status === 'done' || status === 'success') return 'completed';
if (status === 'failed' || status === 'error') return 'failed';
return status || 'running';
@ -184,12 +185,25 @@ const timelineItems = computed(() => {
entries.forEach((entry: ActivityEntry, index: number) => {
if (entry?.type === 'progress' && entry?.subtype === 'output' && typeof entry.content === 'string') {
flushToolGroup();
rawItems.push({
kind: 'output',
// output
// assistant tool_calls output
// assistant
const outputItem = {
kind: 'output' as const,
key: `output-${entry.ts || index}`,
content: entry.content,
isFinal: !!entry.is_final,
});
};
let cut = rawItems.length;
while (cut > 0) {
const tail = rawItems[cut - 1];
if (tail.kind === 'tool' && !isTerminalStatus(tail.entry?.status)) {
cut -= 1;
} else {
break;
}
}
rawItems.splice(cut, 0, outputItem);
return;
}
@ -205,6 +219,21 @@ const timelineItems = computed(() => {
return;
}
// tool_call id
//
if (entry.id) {
const prior = rawItems.find(
(item) =>
item.kind === 'tool' &&
item.entry.id === entry.id &&
!isTerminalStatus(item.entry.status)
);
if (prior && prior.kind === 'tool') {
prior.entry = { ...prior.entry, ...entry };
return;
}
}
flushToolGroup();
let key = baseKey;
let suffix = 0;
@ -224,7 +253,7 @@ const timelineItems = computed(() => {
kind: 'tool' as const,
key: item.key,
state,
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中',
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : state === 'calling' ? '调用中' : '进行中',
text: buildText(item.entry)
};
});

View File

@ -2519,8 +2519,9 @@ body[data-theme='dark'] {
box-shadow: none !important;
}
/* hover 与输入栏按钮一致(--hover-bg与菜单底色拉开对比 */
.dropdown-item:hover:not(.disabled) {
background: var(--surface-muted) !important;
background: var(--hover-bg) !important;
}
}

View File

@ -141,9 +141,13 @@ def _format_sub_agent_stats(stats: Optional[Dict[str, Any]]) -> str:
def _format_create_sub_agent(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("create_sub_agent", result_data)
# 多智能体模式:对模型只暴露角色内编号显示名,全局 agent_id/task_id 为内部细节
display_name = result_data.get("display_name")
status = result_data.get("status")
if display_name:
return f"{display_name} 已创建(状态 {status or 'running'})。"
agent_id = result_data.get("agent_id")
task_id = result_data.get("task_id")
status = result_data.get("status")
refs = result_data.get("copied_references") or []
ref_note = f",附带 {len(refs)} 份参考文件" if refs else ""
deliver_dir = result_data.get("deliverables_dir")
@ -203,9 +207,11 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str:
return "未找到子智能体状态。"
blocks = []
for item in results:
# 多智能体模式:优先使用角色内编号显示名,不暴露全局 agent_id
agent_id = item.get("agent_id")
label = item.get("display_name") or f"#{agent_id}"
if not item.get("found"):
blocks.append(f"子智能体 #{agent_id} 不存在。")
blocks.append(f"子智能体 {label} 不存在。")
continue
status = item.get("status")
summary = None
@ -221,13 +227,13 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str:
stats_text = _format_sub_agent_stats(item.get("stats"))
if status == "completed":
lines = [f"子智能体 #{agent_id} 已完成"]
lines = [f"子智能体 {label} 已完成"]
elif status == "terminated":
lines = [f"子智能体 #{agent_id} 已终止"]
lines = [f"子智能体 {label} 已终止"]
elif status in {"failed", "timeout"}:
lines = [f"⚠️ 子智能体 #{agent_id} 状态 {status}"]
lines = [f"⚠️ 子智能体 {label} 状态 {status}"]
else:
lines = [f"子智能体 #{agent_id} 状态: {status}"]
lines = [f"子智能体 {label} 状态: {status}"]
if stats_text:
lines.append(stats_text)
if status == "completed" and isinstance(elapsed_seconds, (int, float)):
@ -250,6 +256,9 @@ def _format_close_sub_agent(result_data: Dict[str, Any]) -> str:
def _format_terminate_sub_agent(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("terminate_sub_agent", result_data)
display_name = result_data.get("display_name")
if display_name:
return f"已强制关闭子智能体 {display_name}"
agent_id = result_data.get("agent_id")
task_id = result_data.get("task_id")
message = result_data.get("message") or "子智能体已被强制关闭。"
@ -261,6 +270,9 @@ def _format_terminate_sub_agent(result_data: Dict[str, Any]) -> str:
def _format_send_message_to_sub_agent(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("send_message_to_sub_agent", result_data)
display_name = result_data.get("display_name")
if display_name:
return f"已向 {display_name} 发送消息。"
agent_id = result_data.get("agent_id")
if agent_id is not None:
return f"已向子智能体 #{agent_id} 发送消息。"
@ -270,8 +282,12 @@ def _format_send_message_to_sub_agent(result_data: Dict[str, Any]) -> str:
def _format_stop_sub_agent(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("stop_sub_agent", result_data)
agent_id = result_data.get("agent_id")
display_name = result_data.get("display_name")
message = result_data.get("message") or "子智能体已暂停。"
if display_name:
# manager 返回的 message 已是「{显示名} 已暂停…」格式,直接返回避免重复
return message
agent_id = result_data.get("agent_id")
if agent_id is not None:
return f"已暂停子智能体 #{agent_id}{message}"
return message
@ -332,7 +348,8 @@ def _format_active_sub_agents_list(agents: List[Dict[str, Any]]) -> str:
status = agent.get("status") or "unknown"
summary = agent.get("summary") or ""
last_output = agent.get("last_output") or ""
lines.append(f"#{agent_id} {display_name} [{status}]")
# 只暴露角色内编号显示名,不暴露全局 agent_id
lines.append(f"{display_name} [{status}]")
if summary:
lines.append(f" 任务:{summary}")
if last_output:

View File

@ -144,8 +144,7 @@ def _format_sleep(result_data: Dict[str, Any]) -> str:
mode = result_data.get("mode")
if mode == "wait_sub_agent_output":
message = result_data.get("message") or ""
agent_id = result_data.get("agent_id")
header = f"已收到子智能体 {agent_id} 的输出"
header = f"已收到 {result_data.get('display_name') or '子智能体'} 的输出"
if message:
return f"{header}\n\n{message}"
return header