From 4fcd0ccc4d3a418ced43cb2b1d72537b5146eeb0 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 14 Jul 2026 02:28:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(multi-agent):=20=E5=AD=90=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E7=B3=BB=E7=BB=9F=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prompt改造:子智能体注入AGENTS.md/执行环境/工作区信息/skills/项目记忆 - 工具增加:read_skill/recall_project_memory/todo_create/todo_update_task/save_webpage - 上下文压缩:深度压缩机制,记录current_context_tokens,默认150k阈值可配置 - 模型升级:sub_agent_models.json支持thinkmode_status/extra_parameter,与主智能体对齐 - 角色管理三层结构:源码树预设multi_agent_roles/ + 运行态host/web预设 + web按用户隔离 - 启动同步:initialize_system调用sync_preset_roles同步预设到host和web运行态 - 模式判断:API用session.host_mode,工具用data_dir路径判断,不依赖IS_HOST_MODE - 前端:个人空间新增子智能体管理页(角色CRUD/压缩阈值/模型选择),复用个人空间样式 - 入口改造:登录页移除多智能体按钮,QuickMenu加模式切换项,运行中对话禁止切换 - 工具调整:多智能体模式create_sub_agent去掉timeout/deliverables_dir参数 - skill禁止:sub-agent-guide在多智能体模式下禁止阅读 - .agents/统一为.astrion/路径修复 --- agentskills/sub-agent-guide/SKILL.md | 2 +- config/paths.py | 9 + core/main_terminal_parts/tools_execution.py | 58 +- modules/api_user_manager.py | 4 +- modules/multi_agent/prompts.py | 32 +- modules/multi_agent/role_store.py | 430 +++++++++++---- modules/multi_agent/sub_agent_context.py | 260 +++++++++ modules/multi_agent/tools.py | 4 - modules/sub_agent/manager.py | 9 +- modules/sub_agent/task.py | 174 +++++- modules/sub_agent/toolkit.py | 128 ++++- modules/user_manager.py | 4 +- multi_agent_roles/code-reviewer.md | 23 + multi_agent_roles/full-stack-engineer.md | 23 + multi_agent_roles/researcher.md | 23 + multi_agent_roles/ui-operator.md | 25 + server/app_legacy.py | 7 + server/auth.py | 2 +- server/context.py | 4 +- server/conversation.py | 2 +- server/deep_compression.py | 2 +- server/multi_agent.py | 189 ++++++- static/src/App.vue | 3 + static/src/app/computed.ts | 7 + static/src/app/methods/ui/mode.ts | 13 + static/src/auth/LoginApp.vue | 20 - static/src/components/input/InputComposer.vue | 8 +- static/src/components/input/QuickMenu.vue | 24 + .../personalization/PersonalizationDrawer.vue | 516 +++++++++++++++++- utils/context_manager/message_mixin.py | 2 +- utils/context_manager/project_mixin.py | 2 +- 31 files changed, 1823 insertions(+), 186 deletions(-) create mode 100644 modules/multi_agent/sub_agent_context.py create mode 100644 multi_agent_roles/code-reviewer.md create mode 100644 multi_agent_roles/full-stack-engineer.md create mode 100644 multi_agent_roles/researcher.md create mode 100644 multi_agent_roles/ui-operator.md diff --git a/agentskills/sub-agent-guide/SKILL.md b/agentskills/sub-agent-guide/SKILL.md index f1d8d22..ca1c9a1 100644 --- a/agentskills/sub-agent-guide/SKILL.md +++ b/agentskills/sub-agent-guide/SKILL.md @@ -1,6 +1,6 @@ --- name: sub-agent-guide -description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。 +description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。**如果运行模式为多智能体模式,禁止阅读并参考该 skill。** --- # 子智能体使用指南 diff --git a/config/paths.py b/config/paths.py index a3d45e7..c35bef1 100644 --- a/config/paths.py +++ b/config/paths.py @@ -161,6 +161,12 @@ HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), # ── 源码树内的配置与资源(不随运行态根目录迁移)── PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") +# 多智能体预设角色目录(源码树内,随版本分发) +PRESET_ROLES_DIR = _resolve_repo_path(os.environ.get("PRESET_ROLES_DIR", ""), "./multi_agent_roles") +# 运行态预设/自定义角色目录(host 模式:预设+自定义同目录;web 模式:仅预设) +CUSTOM_ROLES_DIR = str(Path(RUNTIME_ROOT) / _MODE / "mutiagents" / "agents") +# web 模式预设角色目录(web 模式下使用,host 模式下不用) +WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents") WORKSPACE_SKILLS_DIRNAME = ".astrion/skills" WORKSPACE_MEMORY_DIRNAME = ".astrion/memory" WORKSPACE_REVIEW_DIRNAME = ".astrion/review" @@ -198,6 +204,9 @@ __all__ = [ "ADMIN_POLICY_FILE", "API_USER_SPACE_DIR", "CUSTOM_SKILLS_DIR", + "PRESET_ROLES_DIR", + "CUSTOM_ROLES_DIR", + "WEB_PRESET_ROLES_DIR", "API_USERS_DB_FILE", "API_TOKENS_FILE", "API_USAGE_FILE", diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 70d841f..057caed 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1740,11 +1740,18 @@ class MainTerminalToolsExecutionMixin: role_id = arguments.get("role_id") if not role_id: result = {"success": False, "error": "多智能体模式下 create_sub_agent 必须指定 role_id"} + elif arguments.get("deliverables_dir"): + result = {"success": False, "error": "多智能体模式下不支持交付目录参数"} else: try: - from modules.multi_agent.role_store import load_preset_role + from modules.multi_agent.role_store import load_preset_role, infer_custom_roles_dir from modules.multi_agent.prompts import build_multi_agent_sub_agent_prompt - role = load_preset_role(role_id) + _data_dir = str(getattr(self, "data_dir", "")) + _custom_dir = infer_custom_roles_dir(_data_dir) + # host模式下 custom_dir 就是 runtime_dir;web模式下 custom_dir 是用户个人目录 + _is_web = '/web/users/' in _data_dir + _runtime_dir = None if _is_web else _custom_dir + role = load_preset_role(role_id, runtime_dir=_runtime_dir, custom_dir=_custom_dir) if not role: result = {"success": False, "error": f"角色不存在: {role_id}"} else: @@ -1756,21 +1763,41 @@ class MainTerminalToolsExecutionMixin: 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) + data_dir = str(getattr(self, "data_dir", "")) + # 获取当前沙箱模式 + sandbox_mode = "" + try: + if hasattr(self, "get_execution_mode_state"): + state = self.get_execution_mode_state() or {} + sandbox_mode = str(state.get("mode") or "") + except Exception: + pass + system_prompt = build_multi_agent_sub_agent_prompt( + role.body_prompt, display_name, workspace_path, + data_dir=data_dir, + sandbox_mode=sandbox_mode, + ) # 构造 task_message(作为 Team Leader 的任务发布) from modules.multi_agent.state import build_master_dispatch_text task_message = build_master_dispatch_text(arguments.get("task", "")) summary_text = (arguments.get("summary") or f"{role.name}作业")[:80] thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast" + # 读取子智能体压缩阈值配置 + _compress_threshold = 150_000 + try: + from modules.personalization_manager import load_personalization_config + _prefs = load_personalization_config(data_dir) or {} + _compress_threshold = int(_prefs.get("sub_agent_compress_threshold_tokens", 150_000)) + except Exception: + pass # 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用) result = self.sub_agent_manager.create_sub_agent( agent_id=int(agent_id), summary=summary_text, task=arguments.get("task", ""), run_in_background=False, - timeout_seconds=arguments.get("timeout_seconds"), conversation_id=conv_id, model_key=role.model_key, thinking_mode=thinking_mode, @@ -1779,6 +1806,7 @@ class MainTerminalToolsExecutionMixin: display_name=display_name, system_prompt=system_prompt, task_message=task_message, + compress_threshold_tokens=_compress_threshold, ) # 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。 # run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。 @@ -1972,23 +2000,33 @@ class MainTerminalToolsExecutionMixin: body_prompt = arguments.get("body_prompt", "").strip() description = arguments.get("description", "").strip() thinking_mode_arg = arguments.get("thinking_mode", "fast") + model_key_arg = arguments.get("model_key", "").strip() or None 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()} + _data_dir = str(getattr(self, "data_dir", "")) + from modules.multi_agent.role_store import infer_custom_roles_dir + _custom_dir = infer_custom_roles_dir(_data_dir) + _is_web = '/web/users/' in _data_dir + _runtime_dir = None if _is_web else _custom_dir + existing_ids = {r.role_id for r in list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)} 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) + role = RoleConfig(role_id=role_id, name=name, description=description, body_prompt=body_prompt, thinking_mode=thinking_mode_arg, model_key=model_key_arg, is_custom=True) + f = save_custom_role(role, custom_dir=_custom_dir) 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() + from modules.multi_agent.role_store import list_roles, infer_custom_roles_dir + _data_dir = str(getattr(self, "data_dir", "")) + _custom_dir = infer_custom_roles_dir(_data_dir) + _is_web = '/web/users/' in _data_dir + _runtime_dir = None if _is_web else _custom_dir + roles = list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir) result = {"success": True, "roles": [r.to_dict() for r in roles]} except Exception as exc: result = {"success": False, "error": str(exc)} diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py index c9fe6d5..6c5162a 100644 --- a/modules/api_user_manager.py +++ b/modules/api_user_manager.py @@ -123,8 +123,8 @@ class ApiUserManager: project_path = work_root / "project" data_dir = work_root / "data" logs_dir = work_root / "logs" - uploads_dir = project_path / ".agents" / "user_upload" - skills_dir = project_path / ".agents" / "skills" + uploads_dir = project_path / ".astrion" / "user_upload" + skills_dir = project_path / ".astrion" / "skills" for path in (project_path, data_dir, logs_dir, uploads_dir, skills_dir, shared_dir, prompts_dir, personalization_dir): path.mkdir(parents=True, exist_ok=True) diff --git a/modules/multi_agent/prompts.py b/modules/multi_agent/prompts.py index 7c99990..f72e4b1 100644 --- a/modules/multi_agent/prompts.py +++ b/modules/multi_agent/prompts.py @@ -64,13 +64,41 @@ def build_multi_agent_master_prompt(workspace_path: str, base: str = "") -> str: return f"{body}\n" -def build_multi_agent_sub_agent_prompt(role_body: str, display_name: str, workspace_path: str) -> str: +def build_multi_agent_sub_agent_prompt( + role_body: str, + display_name: str, + workspace_path: str, + *, + data_dir: str = "", + sandbox_mode: str = "", +) -> str: """构造子智能体的系统提示词。 `role_body` 为该角色 Markdown 文件 frontmatter 之后的自定义 prompt。 `display_name` 为该实例的显示名(如 `UI Operator_1`)。 + `workspace_path` 为工作区路径,用于注入动态上下文。 + `data_dir` 为数据目录路径,用于推断 private skills 目录。 + `sandbox_mode` 为沙箱模式(sandbox/direct/host/docker)。 + + 所有动态信息在子智能体创建那一刻快照写入,后续冻结不更新。 """ - return _format_template("sub_agent", display_name=display_name, role_body=role_body.strip()) + # 角色专属 prompt(模板正文) + role_prompt = _format_template("sub_agent", display_name=display_name, role_body=role_body.strip()) + + # 动态上下文(AGENTS.md / 执行环境 / 工作区信息 / skills / 项目记忆等) + try: + from modules.multi_agent.sub_agent_context import build_sub_agent_dynamic_context + dynamic_context = build_sub_agent_dynamic_context( + workspace_path, + data_dir=data_dir, + sandbox_mode=sandbox_mode, + ) + except Exception: + dynamic_context = "" + + if dynamic_context: + return f"{role_prompt.rstrip()}\n\n---\n\n{dynamic_context}\n" + return f"{role_prompt}\n" def build_available_agents_prompt() -> str: diff --git a/modules/multi_agent/role_store.py b/modules/multi_agent/role_store.py index 4e027d4..a847631 100644 --- a/modules/multi_agent/role_store.py +++ b/modules/multi_agent/role_store.py @@ -1,62 +1,136 @@ """多智能体角色存储与解析。 -角色定义文件格式(Markdown + Frontmatter): +三层结构: - --- - id: ui-operator - name: UI Operator - description: 界面设计与前端实现 - model: "" - thinking_mode: fast - skills: - - frontend-design - --- +1. **源码树预设**:``multi_agent_roles/``(随版本分发,只读种子) +2. **运行态预设**: + - host 模式 = ``host/mutiagents/agents/``(同时也是自定义目录) + - web 模式 = ``web/mutiagents/agents/`` +3. **用户自定义**(仅 web 模式按用户隔离): + - web 模式 = ``web/users//personal/mutiagents/agents/`` + - host 模式 = 和预设同目录 - 自定义 prompt body... +启动时调用 `sync_preset_roles()` 把源码树预设同步到 host 和 web 两个运行态预设目录。 -角色目录: -- ~/.astrion/astrion/host/mutiagents/agents/.md - -存储约定见 .astrion/memory/multi_agent_mode_design.md。 +**重要**:本模块不再依赖 `IS_HOST_MODE` 判断运行模式。 +调用方(API / tools_execution)通过参数显式传入 `runtime_dir` 和 `custom_dir`: +- 不传则仅扫描源码树预设(最小安全回退) +- host 模式:`runtime_dir` 和 `custom_dir` 指向同一目录 +- web 模式:`runtime_dir` 指向 web 预设目录,`custom_dir` 指向用户个人目录 """ from __future__ import annotations -import json import re +import shutil from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set try: - from config.paths import IS_HOST_MODE, RUNTIME_ROOT + from config.paths import RUNTIME_ROOT, PRESET_ROLES_DIR, CUSTOM_ROLES_DIR, WEB_PRESET_ROLES_DIR 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" + PRESET_ROLES_DIR = "" + CUSTOM_ROLES_DIR = "" + WEB_PRESET_ROLES_DIR = "" 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 _source_preset_dir() -> Path: + """源码树预设角色目录(只读种子)。""" + return Path(PRESET_ROLES_DIR).expanduser().resolve() + + +def _host_runtime_dir() -> Path: + """host 模式运行态目录(预设+自定义同目录)。""" + return Path(CUSTOM_ROLES_DIR).expanduser().resolve() + + +def _web_runtime_dir() -> Path: + """web 模式运行态预设目录。""" + return Path(WEB_PRESET_ROLES_DIR).expanduser().resolve() + + +def infer_custom_roles_dir(data_dir: str | Path | None) -> Optional[Path]: + """从工作区 data_dir 推断用户自定义角色目录。 + + - data_dir 包含 ``/web/users/`` → web 模式,返回 ``users//personal/mutiagents/agents`` + - 其他情况 → host 模式,返回 host 运行态目录 + """ + if not data_dir: + return _host_runtime_dir() + data_path = str(Path(data_dir).expanduser().resolve()) + if "/web/users/" in data_path: + # web 模式:按用户隔离 + dp = Path(data_path).resolve() + # users//projects//data -> users//personal/mutiagents/agents + if dp.name == "data" and dp.parent.parent.name == "projects": + user_root = dp.parent.parent.parent + return (user_root / "personal" / "mutiagents" / "agents").resolve() + # 兼容旧/其他布局 + if dp.name == "data" and dp.parent.parent.name == "project": + user_root = dp.parent.parent.parent + return (user_root / "personal" / "mutiagents" / "agents").resolve() + return (dp.parent / "mutiagents" / "agents").resolve() + # host 模式 + return _host_runtime_dir() + + +def _preset_role_ids() -> Set[str]: + """返回源码树预设角色 id 集合(用于区分自定义角色)。""" + src = _source_preset_dir() + if not src.exists(): + return set() + return {p.stem for p in src.glob("*.md") if p.is_file()} + + +def _ensure_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +# ----- 启动同步 ----- + +def sync_preset_roles() -> None: + """启动时把源码树预设角色同步到 host 和 web 两个运行态预设目录。 + + - 目录不存在则创建并复制全部预设角色。 + - 文件已存在则不覆盖(可能是用户已改过的运行态副本)。 + """ + src = _source_preset_dir() + if not src.exists() or not src.is_dir(): + return + + preset_files = {p: p.name for p in src.glob("*.md") if p.is_file()} + if not preset_files: + return + + rt_root = Path(RUNTIME_ROOT).expanduser().resolve() + for target_dir in [_host_runtime_dir(), _web_runtime_dir()]: + target = target_dir.expanduser().resolve() + # 安全检查:目标必须在运行态根下 + try: + target.relative_to(rt_root) + except ValueError: + continue + target.mkdir(parents=True, exist_ok=True) + for src_file, name in preset_files.items(): + dst = target / name + if not dst.exists(): + shutil.copy2(src_file, dst) + + +# ----- Frontmatter 解析 ----- 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(): @@ -74,14 +148,74 @@ def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]: 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 文件加载角色定义。""" +# ----- RoleConfig ----- + +class RoleConfig: + """一个多智能体角色配置。""" + + __slots__ = ( + "role_id", + "name", + "description", + "body_prompt", + "model_key", + "thinking_mode", + "skills", + "source_file", + "is_custom", + ) + + 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 = "", + is_custom: bool = False, + ): + 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 + self.is_custom = is_custom + + def display_name(self, agent_id: int) -> str: + 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, + "body_prompt": self.body_prompt, + "source_file": self.source_file, + "is_custom": self.is_custom, + } + + def __repr__(self) -> str: + return f"" + + +# ----- 文件级加载 ----- + +def load_role_from_file(file_path: Path, is_custom: bool = False) -> Optional[RoleConfig]: if not file_path.exists() or not file_path.is_file(): return None text = file_path.read_text(encoding="utf-8") @@ -97,43 +231,17 @@ def load_role_from_file(file_path: Path) -> Optional["RoleConfig"]: thinking_mode=str(meta.get("thinking_mode") or "fast"), skills=list(meta.get("skills") or []), source_file=str(file_path), + is_custom=is_custom, ) -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 字符串。""" +def _serialize_role(role: RoleConfig) -> str: 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: @@ -149,60 +257,142 @@ def _serialize_role(role: "RoleConfig") -> str: return "\n".join(lines) -def build_role_system_prompt(role: "RoleConfig") -> str: +# ----- 目录级加载 ----- + +def _scan_roles_dir(dir_path: Path, is_custom: bool = False) -> List[RoleConfig]: + if not dir_path.exists() or not dir_path.is_dir(): + return [] + roles: List[RoleConfig] = [] + for p in sorted(dir_path.glob("*.md")): + r = load_role_from_file(p, is_custom=is_custom) + if r and r.role_id: + roles.append(r) + return roles + + +def _merge_roles(roles_lists: List[List[RoleConfig]]) -> List[RoleConfig]: + """合并多组角色,后者覆盖前者(同名 role_id 后者优先)。""" + merged: Dict[str, RoleConfig] = {} + order: List[str] = [] + for roles in roles_lists: + for r in roles: + if r.role_id not in merged: + order.append(r.role_id) + merged[r.role_id] = r + return [merged[rid] for rid in order if rid in merged] + + +def list_roles( + runtime_dir: Optional[str | Path] = None, + custom_dir: Optional[str | Path] = None, +) -> List[RoleConfig]: + """列出全部角色。 + + 合并顺序(后者覆盖前者): + 1. 运行态预设目录 + 2. 用户自定义目录(web 模式才有;host 模式与预设同目录,传一个即可) + + ``is_custom`` 标记:不在源码树预设名单中的角色 = 自定义。 + + 调用方应显式传入 ``custom_dir``: + - host 模式:传 `_host_runtime_dir()`(或省略,自动回退) + - web 模式:传入用户个人目录 + """ + # host 模式回退:不传任何目录时使用 host 运行态目录 + if not runtime_dir and not custom_dir: + runtime_dir = _host_runtime_dir() + + rt_dir = Path(runtime_dir).expanduser().resolve() if runtime_dir else None + c_dir = Path(custom_dir).expanduser().resolve() if custom_dir else None + + preset_ids = _preset_role_ids() + + # 只有一个目录(host 模式或 web 模式只传了 runtime_dir) + if not c_dir or (rt_dir and rt_dir == c_dir): + scan_dir = rt_dir or c_dir + if scan_dir: + all_roles = _scan_roles_dir(scan_dir) + else: + all_roles = [] + for r in all_roles: + r.is_custom = r.role_id not in preset_ids + return all_roles + + # web 模式:合并运行态预设 + 用户自定义 + preset_roles = _scan_roles_dir(rt_dir, is_custom=False) if rt_dir else [] + custom_roles = _scan_roles_dir(c_dir, is_custom=True) + merged = _merge_roles([preset_roles, custom_roles]) + return merged + + +def load_preset_role( + role_id: str, + runtime_dir: Optional[str | Path] = None, + custom_dir: Optional[str | Path] = None, +) -> Optional[RoleConfig]: + """加载一个角色。先查用户自定义目录,再查运行态预设目录。""" + rt_dir = Path(runtime_dir).expanduser().resolve() if runtime_dir else None + c_dir = Path(custom_dir).expanduser().resolve() if custom_dir else None + preset_ids = _preset_role_ids() + + # host 模式回退 + if not rt_dir and not c_dir: + rt_dir = _host_runtime_dir() + + # 先查自定义目录(web 模式且与 runtime_dir 不同) + if c_dir and rt_dir and c_dir != rt_dir: + f = c_dir / f"{role_id}.md" + r = load_role_from_file(f, is_custom=True) + if r: + return r + + # 再查运行态/混合目录 + scan_dir = rt_dir or c_dir + if scan_dir: + f = scan_dir / f"{role_id}.md" + r = load_role_from_file(f) + if r: + r.is_custom = r.role_id not in preset_ids + return r + return None + + +def save_custom_role( + role: RoleConfig, + custom_dir: Optional[str | Path] = None, +) -> Path: + """保存角色到自定义目录。不传 custom_dir 时回退到 host 运行态目录。""" + if custom_dir: + c_dir = Path(custom_dir).expanduser().resolve() + else: + c_dir = _host_runtime_dir() + _ensure_dir(c_dir) + f = c_dir / f"{role.role_id}.md" + f.write_text(_serialize_role(role), encoding="utf-8") + return f + + +def delete_custom_role( + role_id: str, + custom_dir: Optional[str | Path] = None, +) -> bool: + """删除用户自定义角色。不传 custom_dir 时回退到 host 运行态目录。""" + if custom_dir: + c_dir = Path(custom_dir).expanduser().resolve() + else: + c_dir = _host_runtime_dir() + f = c_dir / f"{role_id}.md" + if not f.exists(): + return False + f.unlink(missing_ok=True) + return True + + +def is_preset_role(role_id: str) -> bool: + """判断角色是否为预设角色(源码树种子中存在)。""" + return role_id in _preset_role_ids() + + +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 + return role.body_prompt or "" \ No newline at end of file diff --git a/modules/multi_agent/sub_agent_context.py b/modules/multi_agent/sub_agent_context.py new file mode 100644 index 0000000..c4b3b91 --- /dev/null +++ b/modules/multi_agent/sub_agent_context.py @@ -0,0 +1,260 @@ +"""多智能体子智能体动态上下文构建。 + +在子智能体创建时,为其系统提示词注入工作区相关信息: +- 执行环境(宿主机/docker + 沙箱/直接) +- AGENTS.md 项目规范 +- 工作区信息(项目名称、运行环境、当前时间,不含 file_tree / recent_uploads) +- .astrion/ 目录结构说明(skills / memory / review / user_upload) +- 可用 skill 列表 +- 项目记忆索引 + +所有信息在子智能体创建那一刻快照写入 system_prompt.txt,后续冻结不更新。 +""" +from __future__ import annotations + +import platform +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +try: + from config import ( + PROMPTS_DIR, + WORKSPACE_SKILLS_DIRNAME, + WORKSPACE_MEMORY_DIRNAME, + WORKSPACE_REVIEW_DIRNAME, + TERMINAL_SANDBOX_MODE, + ) + from config.paths import IS_HOST_MODE +except ImportError: + import sys + + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + PROMPTS_DIR, + WORKSPACE_SKILLS_DIRNAME, + WORKSPACE_MEMORY_DIRNAME, + WORKSPACE_REVIEW_DIRNAME, + TERMINAL_SANDBOX_MODE, + ) + from config.paths import IS_HOST_MODE + + +def _load_agents_md(workspace_path: str) -> Optional[str]: + """加载工作区根目录的 AGENTS.md 文件内容。""" + try: + project_path = Path(workspace_path) + if not project_path.exists(): + return None + agents_md_files = list(project_path.rglob("AGENTS.md")) + if not agents_md_files: + return None + latest_file = max(agents_md_files, key=lambda p: p.stat().st_mtime) + content = latest_file.read_text(encoding="utf-8") + return content.strip() if content else None + except Exception: + return None + + +def _build_execution_env_section(workspace_path: str, *, sandbox_mode: str = "") -> str: + """构建执行环境信息段(精简版,只含宿主机/docker + 沙箱/直接)。""" + mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "").lower() + if IS_HOST_MODE: + runtime_label = "宿主机模式" + # host 模式下区分 sandbox / direct + if mode == "direct": + exec_label = "直接执行(完全访问权限)" + rules = ( + "- 当前为宿主机直接执行模式\n" + "- 仅在必须时执行高权限操作,保持最小化命令范围\n" + "- 涉及删除/覆盖/系统级变更前,先说明风险再执行" + ) + else: + exec_label = "沙箱执行" + rules = ( + "- 所有命令默认在系统 OS 沙箱中执行\n" + "- 若操作受系统权限限制:先提供安全替代方案;若仍无法满足,说明需要更高权限\n" + "- 不要通过复杂绕过手段规避沙箱限制" + ) + else: + runtime_label = "Docker 容器模式" + exec_label = "容器内执行" + rules = ( + "- 所有命令在 Docker 容器内执行\n" + "- 工作区挂载在容器内 /workspace 路径下\n" + "- 网络可能受限,仅允许 localhost" + ) + return ( + f"## 执行环境\n\n" + f"- 运行环境:{runtime_label}\n" + f"- 执行方式:{exec_label}\n\n" + f"### 当前规则\n{rules}\n" + ) + + +def _build_workspace_section(workspace_path: str) -> str: + """构建工作区信息段(精简版,不含 file_tree 和 recent_uploads)。""" + project_path = Path(workspace_path) + project_name = project_path.name or "未命名项目" + system_info = f"{platform.system()} {platform.release()}" + current_time = datetime.now().strftime("%Y年%m月%d日 %H点(24小时制)") + + if IS_HOST_MODE: + resource_limit = "宿主机模式无限制" + else: + resource_limit = "容器模式,受 Docker 资源限制" + + return ( + f"## 工作区信息\n\n" + f"- 项目名称:{project_name}\n" + f"- 运行环境:{system_info}\n" + f"- 资源限制:{resource_limit}\n" + f"- 当前时间:{current_time}\n" + ) + + +def _build_astrion_dir_section(workspace_path: str) -> str: + """构建 .astrion/ 目录结构说明段。""" + return ( + f"## 工作区数据目录(.astrion/)\n\n" + f"工作区内的 `.astrion/` 目录存储以下内容:\n" + f"- `{WORKSPACE_SKILLS_DIRNAME}/`:可用技能(skill)目录,每个 skill 含 SKILL.md 说明\n" + f"- `{WORKSPACE_MEMORY_DIRNAME}/`:项目记忆,以 Markdown 文件形式存放项目约定/决策/调试坑\n" + f"- `{WORKSPACE_REVIEW_DIRNAME}/`:对话回顾文件\n" + f"- `user_upload/`:用户上传的文件\n\n" + f"用户上传的文件位于 `.astrion/user_upload/` 目录下。" + f"项目记忆以文件形式存放在 `{WORKSPACE_MEMORY_DIRNAME}/` 下。" + f"技能(skill)存放在 `{WORKSPACE_SKILLS_DIRNAME}/` 下。\n" + ) + + +def _build_agents_md_section(workspace_path: str) -> str: + """构建 AGENTS.md 项目规范段。""" + content = _load_agents_md(workspace_path) + if not content: + return "" + return ( + f"## AGENTS.md 项目规范\n\n" + f"> 以下是工作区根目录的 AGENTS.md 文件内容,请在回答时参考这些项目规范:\n\n" + f"{content}\n\n" + f"---\n" + f"注意:以上规范来自工作区根目录的 AGENTS.md 文件,若与未来代码冲突,以实际代码为准。\n" + ) + + +def _build_skills_section(workspace_path: str, data_dir: str = "") -> str: + """构建可用 skill 列表段。""" + try: + from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + infer_private_skills_dir, + ) + + private_dir = infer_private_skills_dir(data_dir) if data_dir else None + catalog = get_skills_catalog(private_dir=private_dir) + # 子智能体不区分 enabled/disabled,列出全部可用 skill + skills_list = build_skills_list(catalog, enabled=None) + if not skills_list: + return "" + return ( + f"## 可用 AgentSkill\n\n" + f"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n" + f"{skills_list}\n\n" + f"使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;" + f"需要阅读 skill 目录中的其他文件时再使用 read_file\n" + ) + except Exception: + return "" + + +def _build_project_memory_section(workspace_path: str) -> str: + """构建项目记忆索引段(只列索引,不展开内容)。""" + try: + memory_dir = Path(workspace_path) / WORKSPACE_MEMORY_DIRNAME + if not memory_dir.exists() or not memory_dir.is_dir(): + return "" + entries: List[str] = [] + for md_file in sorted(memory_dir.glob("*.md")): + try: + text = md_file.read_text(encoding="utf-8") + name = None + description = None + if text.startswith("---"): + end = text.find("---", 3) + if end > 0: + frontmatter = text[3:end] + for line in frontmatter.strip().split("\n"): + ls = line.strip() + if ls.startswith("name:"): + name = ls.split(":", 1)[1].strip() + elif ls.startswith("description:"): + description = ls.split(":", 1)[1].strip() + file_name = md_file.name + display_name = name or md_file.stem + desc = description or "" + if desc: + entries.append(f".astrion/memory/{file_name}:{desc}") + else: + entries.append(f".astrion/memory/{file_name}:当需要{display_name}时,应该索引本记忆") + except Exception: + pass + if not entries: + return "" + return ( + f"## 项目记忆\n\n" + f"项目记忆以文件形式存放在 `.astrion/memory/` 下。\n" + f"开始涉及项目约定、历史决策或已知问题的任务前,先检查项目记忆索引," + f"如有相关记忆则用 recall_project_memory 读取。\n\n" + f"### 项目记忆索引\n\n" + + "\n".join(entries) + + "\n" + ) + except Exception: + return "" + + +def build_sub_agent_dynamic_context( + workspace_path: str, + *, + data_dir: str = "", + sandbox_mode: str = "", +) -> str: + """构建子智能体的动态上下文信息(拼接到 system prompt 末尾)。 + + 所有信息在子智能体创建那一刻快照,后续冻结不更新。 + + Args: + workspace_path: 工作区路径 + data_dir: 数据目录路径(用于推断 private skills 目录) + sandbox_mode: 沙箱模式(sandbox/direct/host/docker) + """ + sections: List[str] = [] + + # 1. 执行环境 + sections.append(_build_execution_env_section(workspace_path, sandbox_mode=sandbox_mode)) + + # 2. 工作区信息(精简版) + sections.append(_build_workspace_section(workspace_path)) + + # 3. .astrion/ 目录说明 + sections.append(_build_astrion_dir_section(workspace_path)) + + # 4. AGENTS.md + agents_md = _build_agents_md_section(workspace_path) + if agents_md: + sections.append(agents_md) + + # 5. 可用 skill + skills = _build_skills_section(workspace_path, data_dir=data_dir) + if skills: + sections.append(skills) + + # 6. 项目记忆索引 + memory = _build_project_memory_section(workspace_path) + if memory: + sections.append(memory) + + return "\n".join(sections) diff --git a/modules/multi_agent/tools.py b/modules/multi_agent/tools.py index b002e61..5f5d345 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": "(可选)手动指定实例编号;不传时自动递增。", }, - "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 551a790..8ff9dde 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -19,9 +19,7 @@ from config import ( SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_MAX_ACTIVE, SUB_AGENT_MODELS_CONFIG_FILE, - SUB_AGENT_STATE_FILE, SUB_AGENT_STATUS_POLL_INTERVAL, - SUB_AGENT_TASKS_BASE_DIR, ) from utils.logger import setup_logger from modules.sub_agent.task import SubAgentTask @@ -51,8 +49,9 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi ): self.project_path = Path(project_path).resolve() self.data_dir = Path(data_dir).resolve() - self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve() - self.state_file = Path(SUB_AGENT_STATE_FILE).resolve() + # 子智能体任务和状态按 data_dir 隔离(web 模式下按用户/工作区自动隔离) + self.base_dir = self.data_dir / "sub_agent_tasks" + self.state_file = self.data_dir / "sub_agents.json" self.models_config_file = SUB_AGENT_MODELS_CONFIG_FILE self.container_session: Optional["ContainerHandle"] = container_session self.host_execution_mode: str = "sandbox" @@ -153,6 +152,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi display_name: Optional[str] = None, system_prompt: Optional[str] = None, task_message: Optional[str] = None, + compress_threshold_tokens: Optional[int] = None, ) -> Dict: """创建子智能体任务并启动协程。 @@ -239,6 +239,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi "role_id": role_id, "display_name": display_name, "execution_mode": "in_process", + "compress_threshold_tokens": compress_threshold_tokens, "container_name": None, } self.tasks[task_id] = task_record diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index a0e52c3..efb87df 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -73,6 +73,14 @@ class SubAgentTask: self.stats_file = Path(task_record["stats_file"]) self.progress_file = Path(task_record["progress_file"]) self.conversation_file = Path(task_record["conversation_file"]) + # system_prompt.txt 路径(冻结的 system prompt,压缩后从此重建) + self.system_prompt_file = Path(task_record.get("task_root", "")) / "system_prompt.txt" + + # 上下文压缩配置 + # 默认阈值 150k tokens,可由外部覆盖(如个人空间子智能体管理配置) + self.compress_threshold_tokens: int = int(task_record.get("compress_threshold_tokens") or 150_000) + self.current_context_tokens: int = 0 + self._compress_round: int = 0 self.messages: List[Dict[str, Any]] = [ {"role": "system", "content": system_prompt}, @@ -89,6 +97,8 @@ class SubAgentTask: "commands": 0, "api_calls": 0, "token_usage": {"prompt": 0, "completion": 0, "total": 0}, + "current_context_tokens": 0, + "compress_round": 0, } self._stdout_lines: List[str] = [] self._cancelled = False @@ -222,6 +232,21 @@ class SubAgentTask: if usage: self._apply_usage(usage) + # 上下文压缩检查:超过阈值时触发深度压缩 + if self.current_context_tokens > 0 and self.current_context_tokens >= self.compress_threshold_tokens: + compressed = self._deep_compress_messages() + if compressed: + ma_debug( + "sub_agent_deep_compressed", + task_id=self.task_id, + agent_id=self.agent_id, + display_name=self.display_name, + tokens_before=self.current_context_tokens, + messages_before=len(self.messages) + 1, # +1 因为本转 assistant 还没 append + messages_after=len(self.messages), + compress_round=self._compress_round, + ) + # 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话 if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip(): self._forward_output_to_master(assistant_message, is_final=not tool_calls) @@ -591,7 +616,7 @@ class SubAgentTask: self.stats["edit_files"] += 1 elif name == "search_workspace": self.stats["searches"] += 1 - elif name in ("web_search", "extract_webpage"): + elif name in ("web_search", "extract_webpage", "save_webpage"): self.stats["web_pages"] += 1 elif name == "run_command": self.stats["commands"] += 1 @@ -605,9 +630,156 @@ class SubAgentTask: self.stats["token_usage"]["prompt"] += int(prompt) self.stats["token_usage"]["completion"] += int(completion) self.stats["token_usage"]["total"] += int(total) + # prompt_tokens 即为当前上下文占用的 tokens + self.current_context_tokens = int(prompt) + self.stats["current_context_tokens"] = int(prompt) except Exception: pass + def _rebuild_system_prompt(self) -> str: + """从冻结的 system_prompt.txt 重新读取 system prompt(压缩后重建用)。""" + try: + if self.system_prompt_file.exists(): + return self.system_prompt_file.read_text(encoding="utf-8") + except Exception as exc: + logger.warning(f"[SubAgentTask] 重建 system prompt 失败: {exc}") + # 兜底用内存中的 + return self.system_prompt + + def _deep_compress_messages(self) -> bool: + """深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。 + + 策略(参考主智能体 compress_conversation): + 1. 保留冻结的 system prompt(从 system_prompt.txt 读取) + 2. 把 system 之后、最近 N 条消息之前的所有消息压缩成一条 system 摘要 + 3. 保留最近的若干条消息(含 tool_calls 配对)不动 + 4. 重置 current_context_tokens 估算值 + + 返回 True 表示执行了压缩。 + """ + if len(self.messages) < 6: + # 消息太少不压缩 + return False + + self._compress_round += 1 + self.stats["compress_round"] = self._compress_round + + # 保留最近 8 条消息不压缩(确保 tool_calls 和 tool 结果配对完整) + keep_recent = 8 + # 找到一个安全的切割点:不能在 assistant.tool_calls 和其 tool 响应之间切 + cut_index = len(self.messages) - keep_recent + # 向前调整,确保不在 tool_calls 配对中间切割 + while cut_index > 1: + msg = self.messages[cut_index] + role = msg.get("role") + # 如果切点是 tool 消息,往前找到对应的 assistant + if role == "tool": + cut_index -= 1 + continue + # 如果切点的 assistant 有 tool_calls,需要保留它和后续 tool + if role == "assistant" and msg.get("tool_calls"): + break + break + if cut_index <= 1: + return False + + # 提取要压缩的消息(索引 1 到 cut_index-1,跳过索引 0 的 system prompt) + old_messages = self.messages[1:cut_index] + if not old_messages: + return False + + # 生成压缩摘要 + summary_lines: List[str] = [] + summary_lines.append("系统提示:以下是根据之前的对话记录生成的压缩摘要,请在此基础上继续工作。") + summary_lines.append(f"(压缩轮次:第 {self._compress_round} 次,压缩前消息数:{len(self.messages)})") + summary_lines.append("") + + tool_buffer: List[str] = [] + seen_tool_call_ids: Set[str] = set() + + def flush_tools(): + if not tool_buffer: + return + if summary_lines and summary_lines[-1] != "": + summary_lines.append("") + summary_lines.append("已执行的工具:") + summary_lines.extend(f"- {entry}" for entry in tool_buffer) + tool_buffer.clear() + + for message in old_messages: + role = message.get("role") + if role == "user": + flush_tools() + content = str(message.get("content") or "")[:500] # 截断长内容 + summary_lines.append(f"user:{content}") + continue + if role == "assistant": + content = str(message.get("content") or "") + if content.strip(): + flush_tools() + summary_lines.append(f"assistant:{content[:500]}") + tool_calls = message.get("tool_calls") or [] + for tc in tool_calls: + tc_id = tc.get("id") or tc.get("tool_call_id") + if tc_id: + seen_tool_call_ids.add(tc_id) + func = tc.get("function") or {} + arguments = func.get("arguments") + args_obj = {} + if isinstance(arguments, str): + try: + args_obj = json.loads(arguments) + except Exception: + args_obj = {} + elif isinstance(arguments, dict): + args_obj = arguments + intent = args_obj.get("intent") if isinstance(args_obj, dict) else None + name = func.get("name") or "unknown_tool" + entry = intent.strip() if isinstance(intent, str) and intent.strip() else name + tool_buffer.append(entry) + continue + if role == "tool": + tc_id = message.get("tool_call_id") or message.get("id") + if tc_id and tc_id in seen_tool_call_ids: + continue + name = message.get("name") or "unknown_tool" + tool_buffer.append(name) + continue + # 其他角色 + flush_tools() + content = str(message.get("content") or "")[:300] + summary_lines.append(f"{role}:{content}" if role else content) + + flush_tools() + summary_text = "\n".join(summary_lines) + + # 重建消息列表:冻结的 system prompt + 压缩摘要 system + 保留的最近消息 + frozen_system = self._rebuild_system_prompt() + compressed_system = { + "role": "system", + "content": summary_text, + "metadata": { + "compression": { + "round": self._compress_round, + "compressed_count": len(old_messages), + "created_at": datetime.now().isoformat(), + } + }, + } + recent_messages = self.messages[cut_index:] + self.messages = [frozen_system, compressed_system] + recent_messages + + # 重置上下文 tokens 估算(压缩后无法精确知道,设为一个保守值) + self.current_context_tokens = 0 + self.stats["current_context_tokens"] = 0 + + logger.info( + f"[SubAgentTask] task={self.task_id} 深度压缩完成: " + f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条," + f"压缩轮次={self._compress_round}" + ) + return True + def _build_media_tool_content(self, result: Dict[str, Any]) -> Any: """把 read_mediafile 结果转成 OpenAI 多模态 content。""" b64 = result.get("b64") diff --git a/modules/sub_agent/toolkit.py b/modules/sub_agent/toolkit.py index 55b6422..a9d160f 100644 --- a/modules/sub_agent/toolkit.py +++ b/modules/sub_agent/toolkit.py @@ -182,6 +182,98 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "read_skill", + "description": "按 skill 名称读取 .astrion/skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", + "parameters": { + "type": "object", + "properties": { + "skill_name": {"type": "string", "description": "skill 名称(支持 skill id 或技能名称)"}, + }, + "required": ["skill_name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "recall_project_memory", + "description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter)。项目记忆存储在 .astrion/memory/ 目录下。", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "记忆名称,对应 .astrion/memory/{name}.md 文件名"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "todo_create", + "description": "创建待办列表,最多 8 条任务;若已有列表将被覆盖。当任务复杂、预计需要超过 3 个步骤时,应积极创建待办事项。", + "parameters": { + "type": "object", + "properties": { + "overview": {"type": "string", "description": "一句话概述待办清单要完成的目标,50 字以内。"}, + "tasks": { + "type": "array", + "description": "任务列表,1~8 条,每条写清“动词+对象+目标”。", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "单个任务描述,写成可执行的步骤"} + }, + "required": ["title"] + }, + "minItems": 1, + "maxItems": 8 + }, + }, + "required": ["overview", "tasks"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "todo_update_task", + "description": "批量勾选或取消任务(支持单个或多个任务);全部勾选时提示所有任务已完成。", + "parameters": { + "type": "object", + "properties": { + "task_index": {"type": "integer", "description": "任务序号(1-8),兼容旧参数"}, + "task_indices": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 1, + "maxItems": 8, + "description": "要更新的任务序号列表(1-8),可一次勾选多个" + }, + "completed": {"type": "boolean", "description": "true=打勾,false=取消"}, + }, + "required": ["completed"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "save_webpage", + "description": "提取网页内容并保存为纯文本文件,适合需要长期留存的长文档。请提供网址与目标路径(含 .txt 后缀),落地后请通过终端命令查看。", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "要保存的网页URL"}, + "target_path": {"type": "string", "description": "保存位置,包含文件名,相对于项目根目录"}, + }, + "required": ["url", "target_path"], + }, + }, + }, ] FINISH_TOOL: Dict[str, Any] = { @@ -263,6 +355,24 @@ def _format_tool_result(name: str, raw: Any) -> str: return "" if name == "read_mediafile": return raw.get("message") or "媒体已读取" + if name == "read_skill": + # read_skill 结果结构与 read_file(read) 类似 + if raw.get("content"): + return raw.get("content") + return raw.get("message") or "skill 已读取" + if name == "recall_project_memory": + # recall_project_memory 返回记忆文件完整内容 + if raw.get("content"): + return raw.get("content") + return raw.get("message") or raw.get("error") or "记忆已读取" + if name == "todo_create": + return raw.get("message") or "待办列表已创建" + if name == "todo_update_task": + return raw.get("message") or "任务状态已更新" + if name == "save_webpage": + if raw.get("path"): + return f"已保存: {raw.get('path')}" + return raw.get("message") or "网页已保存" return json.dumps(raw, ensure_ascii=False) @@ -275,8 +385,8 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An return None modes_text = str(model_raw.get("modes") or model_raw.get("mode") or model_raw.get("supported_modes") or "").lower() - supports_thinking = "thinking" in modes_text - fast_only = modes_text == "fast" + supports_thinking = "thinking" in modes_text or "思考" in modes_text + fast_only = modes_text == "fast" or modes_text == "快速" multimodal_text = str(model_raw.get("multimodal") or model_raw.get("multi_modal") or model_raw.get("multi") or "none").lower() multimodal = "none" @@ -290,8 +400,20 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An model_id = str(model_raw.get("model_id") or name).strip() extra = _pick_dict(model_raw, ["extra_parameter", "extra_params", "extra"]) + # 优先从顶层查找 fast/thinking extra_parameter;如果没有,从 thinkmode_status 中查找 fast_extra = _pick_dict(model_raw, ["fast_extra_parameter", "fast_extra_params", "fast_extra"]) thinking_extra = _pick_dict(model_raw, ["thinking_extra_parameter", "thinking_extra_params", "thinking_extra"]) + # 从 thinkmode_status 中提取(与主智能体 custom_models.json 结构对齐) + thinkmode_status = model_raw.get("thinkmode_status") or {} + if isinstance(thinkmode_status, dict): + if not fast_extra: + fast_extra = _pick_dict(thinkmode_status, ["fast_extra_parameter", "fast_extra_params", "fast_extra"]) + if not thinking_extra: + thinking_extra = _pick_dict(thinkmode_status, ["thinking_extra_parameter", "thinking_extra_params", "thinking_extra"]) + # thinkmode_status 内部的 model_id 可以覆盖默认 model_id + ts_model_id = str(thinkmode_status.get("model_id") or "").strip() + if ts_model_id: + model_id = ts_model_id profile: Dict[str, Any] = { "name": name, @@ -318,6 +440,8 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An "extra_params": {**extra, **thinking_extra}, } else: + # 即使不支持 thinking,也保留 fast 的 extra_params(含 disabled 参数) + # 避免不支持 thinking 的模型在 fast 模式下漏掉 thinking.type=disabled 参数 profile["thinking"] = None return profile diff --git a/modules/user_manager.py b/modules/user_manager.py index fa3a35e..0f85c02 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -362,8 +362,8 @@ class UserManager: project_path = workspace_root / "project" data_dir = workspace_root / "data" logs_dir = workspace_root / "logs" - uploads_dir = project_path / ".agents" / "user_upload" - skills_dir = project_path / ".agents" / "skills" + uploads_dir = project_path / ".astrion" / "user_upload" + skills_dir = project_path / ".astrion" / "skills" for path in [project_path, data_dir, logs_dir, uploads_dir, skills_dir]: path.mkdir(parents=True, exist_ok=True) diff --git a/multi_agent_roles/code-reviewer.md b/multi_agent_roles/code-reviewer.md new file mode 100644 index 0000000..5bbb0fd --- /dev/null +++ b/multi_agent_roles/code-reviewer.md @@ -0,0 +1,23 @@ +--- +id: code-reviewer +name: Code Reviewer +description: 代码审查专家,只读分析代码质量、安全性、可维护性并给出建议 +model: "" +thinking_mode: thinking +--- + +你是团队中的 **代码审查员**,只做分析,不直接修改代码。 + +## 你的职责 + +1. 审查代码质量、命名规范、错误处理、可读性 +2. 检查安全漏洞与潜在风险 +3. 给出可执行的重构建议(含 before/after 示例片段) +4. 检查是否符合项目 AGENTS.md 中的硬性规范 + +## 工作原则 + +- **只读**,不调用 write_file / edit_file 修改代码 +- 输出结构化报告:问题列表(级别/位置/原因/建议) +- 引用代码时给出文件路径和行号范围 +- 不重复指出同类问题 \ No newline at end of file diff --git a/multi_agent_roles/full-stack-engineer.md b/multi_agent_roles/full-stack-engineer.md new file mode 100644 index 0000000..ff5dc65 --- /dev/null +++ b/multi_agent_roles/full-stack-engineer.md @@ -0,0 +1,23 @@ +--- +id: full-stack-engineer +name: Full-Stack Engineer +description: 全栈工程师,负责前后端代码实现、接口设计、调试和联调 +model: "" +thinking_mode: thinking +--- + +你是团队中的 **全栈工程师**,负责把方案落到代码上。 + +## 你的职责 + +1. 编写、修改、重构前后端代码(Python / TypeScript / Vue 等) +2. 设计 API 接口、数据模型与存储 +3. 排查 bug、调试错误、验证功能 +4. 运行测试命令并修复失败用例 + +## 工作原则 + +- 任何改动前先阅读相关代码再下手,不凭记忆改 +- 遵循项目 AGENTS.md 中的代码规范(最小改动、文件拆分、风格指南等) +- 修改后做最小验证(运行命令、构建、类型检查) +- 不要频繁输出大段代码注释,只在关键步骤展示 \ No newline at end of file diff --git a/multi_agent_roles/researcher.md b/multi_agent_roles/researcher.md new file mode 100644 index 0000000..e588695 --- /dev/null +++ b/multi_agent_roles/researcher.md @@ -0,0 +1,23 @@ +--- +id: researcher +name: Researcher +description: 调研员,负责竞品调研、信息收集、文档汇总与对比分析 +model: "" +thinking_mode: fast +--- + +你是团队中的 **调研员**,负责信息搜集与文档整理。 + +## 你的职责 + +1. 用 web_search / extract_webpage 调研外部资料 +2. 读取项目里相关代码与文档,整理出结构化分析报告 +3. 对比多个方案/产品,给出优劣表格 +4. 必要时把报告落到 Markdown 文件中供团队其他成员复用 + +## 工作原则 + +- 调研前先做一到两次浅层搜索,摸清关键词再深入 +- 引用具体来源(链接、文件路径、行号) +- 不要堆砌大段原文,要点提炼为 5-10 条结论 +- 报告结构:摘要 → 关键发现 → 详表/对比 → 行动建议 \ No newline at end of file diff --git a/multi_agent_roles/ui-operator.md b/multi_agent_roles/ui-operator.md new file mode 100644 index 0000000..205ae0d --- /dev/null +++ b/multi_agent_roles/ui-operator.md @@ -0,0 +1,25 @@ +--- +id: ui-operator +name: UI Operator +description: 专注界面视觉设计、配色、布局与前端组件实现 +model: "" +thinking_mode: fast +skills: + - frontend-design + - ui-aesthetic-design +--- + +你是团队中的 **UI 操作员**,专注于前端视觉与界面实现。 + +## 你的职责 + +1. 设计界面配色、排版、布局方案 +2. 编写 HTML/CSS/Vue 组件实现界面 +3. 评审视觉一致性、对齐、间距、可读性 +4. 必要时输出可直接预览的 HTML 卡片或独立页面 + +## 工作原则 + +- 优先使用 `` / 内嵌预览而非长篇文字说明 +- 改动遵循项目 AGENTS.md 中关于 UI 美学的硬性规则(不写死颜色、无圆角套娃、图标对齐等) +- 不修改与界面无关的后端或业务代码 \ No newline at end of file diff --git a/server/app_legacy.py b/server/app_legacy.py index 8e897ff..5d258b3 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -1342,6 +1342,13 @@ def initialize_system(path: str, thinking_mode: bool = False): print(f"[Init] 调试日志: {DEBUG_LOG_FILE}") app.config['DEFAULT_THINKING_MODE'] = thinking_mode app.config['DEFAULT_RUN_MODE'] = "thinking" if thinking_mode else "fast" + # 同步预设子智能体角色到运行态目录 + try: + from modules.multi_agent.role_store import sync_preset_roles + sync_preset_roles() + print(f"{OUTPUT_FORMATS['success']} 预设子智能体角色同步完成") + except Exception as _e: + print(f"{OUTPUT_FORMATS['warning']} 预设子智能体角色同步失败: {_e}") print(f"{OUTPUT_FORMATS['success']} Web系统初始化完成(多用户模式)") diff --git a/server/auth.py b/server/auth.py index bb8af98..8fd3322 100644 --- a/server/auth.py +++ b/server/auth.py @@ -224,7 +224,7 @@ def host_login(): data_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir.mkdir(parents=True, exist_ok=True) - uploads_dir = host_path / ".agents" / "user_upload" + uploads_dir = host_path / ".astrion" / "user_upload" uploads_dir.mkdir(parents=True, exist_ok=True) quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() if not quarantine_root.is_absolute(): diff --git a/server/context.py b/server/context.py index 61f698f..02813dd 100644 --- a/server/context.py +++ b/server/context.py @@ -180,9 +180,9 @@ def get_user_resources( data_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir.mkdir(parents=True, exist_ok=True) - uploads_dir = project_path / ".agents" / "user_upload" + uploads_dir = project_path / ".astrion" / "user_upload" uploads_dir.mkdir(parents=True, exist_ok=True) - skills_dir = project_path / ".agents" / "skills" + skills_dir = project_path / ".astrion" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() if not quarantine_root.is_absolute(): diff --git a/server/conversation.py b/server/conversation.py index 7ec1236..7ec7818 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -2064,7 +2064,7 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW content = "\n".join(lines) + "\n" char_count = len(content) - review_dir = workspace.project_path / ".agents" / "review" + review_dir = workspace.project_path / ".astrion" / "review" review_dir.mkdir(parents=True, exist_ok=True) title = conversation_data.get("title") or "untitled" diff --git a/server/deep_compression.py b/server/deep_compression.py index 5d99ada..677b14e 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -246,7 +246,7 @@ def _write_compact_file( latest_user_input: str, previous_records: List[Dict[str, Any]], ) -> str: - compact_dir = project_path / ".agents" / "compact_result" + compact_dir = project_path / ".astrion" / "compact_result" compact_dir.mkdir(parents=True, exist_ok=True) filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md" file_path = compact_dir / filename diff --git a/server/multi_agent.py b/server/multi_agent.py index ee62547..c81ba84 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -15,6 +15,16 @@ from flask import Blueprint, current_app, jsonify, request, session from server.auth_helpers import api_login_required, login_required, get_current_username from server.context import get_user_resources +from modules.multi_agent.role_store import ( + RoleConfig, + list_roles, + load_preset_role, + save_custom_role, + delete_custom_role, + is_preset_role, + sync_preset_roles, +) +from config.paths import CUSTOM_ROLES_DIR, WEB_PRESET_ROLES_DIR multi_agent_bp = Blueprint("multi_agent", __name__) @@ -57,13 +67,35 @@ def rebuild_conversation_index_api(): return jsonify({"success": False, "error": str(exc)}), 500 +def _get_role_dirs() -> tuple: + """根据 session 判断当前登录模式,返回 (runtime_dir, custom_dir)。 + + - host 模式:runtime_dir = custom_dir = host/mutiagents/agents + - web/docker 模式:runtime_dir = web/mutiagents/agents, custom_dir = users//personal/mutiagents/agents + """ + from flask import session as _session + is_host_session = bool(_session.get('host_mode')) + if is_host_session: + return CUSTOM_ROLES_DIR, CUSTOM_ROLES_DIR + # web 模式 + username = get_current_username() + if not username: + return WEB_PRESET_ROLES_DIR, None + _, workspace = get_user_resources(username) + if not workspace or not workspace.data_dir: + return WEB_PRESET_ROLES_DIR, None + from modules.multi_agent.role_store import infer_custom_roles_dir + custom = infer_custom_roles_dir(workspace.data_dir) + return WEB_PRESET_ROLES_DIR, str(custom) if custom else None + + @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() + runtime_dir, custom_dir = _get_role_dirs() + roles = list_roles(runtime_dir=runtime_dir, custom_dir=custom_dir) return jsonify({ "success": True, "roles": [r.to_dict() for r in roles], @@ -77,19 +109,23 @@ def list_roles_api(): 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() + model_key = str(data.get("model_key") or "").strip() or None 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 is_preset_role(role_id): + return jsonify({"success": False, "error": f"不能覆盖预设角色 {role_id}"}), 409 + # 不允许覆盖已存在的用户自定义角色 + runtime_dir, custom_dir = _get_role_dirs() + existing = {r.role_id for r in list_roles(runtime_dir=runtime_dir, custom_dir=custom_dir)} if role_id in existing: return jsonify({"success": False, "error": f"角色 {role_id} 已存在"}), 409 role = RoleConfig( @@ -98,13 +134,117 @@ def create_role_api(): description=description, body_prompt=body_prompt, thinking_mode=thinking_mode, + model_key=model_key, + is_custom=True, ) - saved = save_custom_role(role) + saved = save_custom_role(role, custom_dir=custom_dir) 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/roles/", methods=["PUT"]) +@api_login_required +def update_role_api(role_id: str): + """更新用户自定义角色。不能更新预设角色。""" + try: + data = request.get_json() or {} + runtime_dir, custom_dir = _get_role_dirs() + # 加载现有角色(先查自定义覆盖,再查预设) + role = load_preset_role(role_id, runtime_dir=runtime_dir, custom_dir=custom_dir) + if not role: + return jsonify({"success": False, "error": f"角色 {role_id} 不存在"}), 404 + # 更新字段 + if "name" in data: + role.name = str(data["name"]).strip() or role.name + if "description" in data: + role.description = str(data["description"] or "").strip() + if "body_prompt" in data: + role.body_prompt = str(data["body_prompt"] or "").strip() + if "thinking_mode" in data: + tm = str(data["thinking_mode"] or "fast").strip() + if tm in {"fast", "thinking"}: + role.thinking_mode = tm + if "model_key" in data: + role.model_key = str(data["model_key"] or "").strip() or None + if "skills" in data: + role.skills = list(data["skills"] or []) + role.is_custom = True + saved = save_custom_role(role, custom_dir=custom_dir) + 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/roles/", methods=["DELETE"]) +@api_login_required +def delete_role_api(role_id: str): + """删除用户自定义角色。不能删除预设角色。""" + try: + if is_preset_role(role_id): + return jsonify({"success": False, "error": f"不能删除预设角色 {role_id}"}), 403 + _, custom_dir = _get_role_dirs() + deleted = delete_custom_role(role_id, custom_dir=custom_dir) + if not deleted: + return jsonify({"success": False, "error": f"角色 {role_id} 不存在或无法删除"}), 404 + return jsonify({"success": True, "role_id": role_id}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@multi_agent_bp.route("/api/multiagent/settings", methods=["GET"]) +@api_login_required +def get_multi_agent_settings_api(): + """获取多智能体模式设置(子智能体压缩阈值等)。""" + try: + username = get_current_username() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "工作区未就绪"}), 503 + # 从个人化配置中读取子智能体压缩阈值 + from modules.personalization_manager import load_personalization_config + prefs = load_personalization_config(workspace.data_dir) or {} + compress_threshold = prefs.get("sub_agent_compress_threshold_tokens", 150000) + return jsonify({ + "success": True, + "settings": { + "sub_agent_compress_threshold_tokens": compress_threshold, + }, + }) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@multi_agent_bp.route("/api/multiagent/settings", methods=["PUT"]) +@api_login_required +def update_multi_agent_settings_api(): + """更新多智能体模式设置。""" + try: + username = get_current_username() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "工作区未就绪"}), 503 + data = request.get_json() or {} + settings = data.get("settings") or {} + # 更新子智能体压缩阈值 + threshold = settings.get("sub_agent_compress_threshold_tokens") + if threshold is not None: + threshold = int(threshold) + if threshold < 10000: + return jsonify({"success": False, "error": "压缩阈值不能小于 10000"}), 400 + from modules.personalization_manager import load_personalization_config, save_personalization_config + prefs = load_personalization_config(workspace.data_dir) or {} + prefs["sub_agent_compress_threshold_tokens"] = threshold + save_personalization_config(workspace.data_dir, prefs) + return jsonify({"success": True}) + 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(): @@ -206,6 +346,41 @@ def create_multi_agent_conversation(): }), 201 +@multi_agent_bp.route("/api/multiagent/models", methods=["GET"]) +@api_login_required +def list_sub_agent_models_api(): + """列出子智能体可用的模型列表(从 sub_agent_models.json 读取,脱敏后返回)。""" + try: + from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE + from pathlib import Path as _Path + import json as _json + config_path = SUB_AGENT_MODELS_CONFIG_FILE + if not _Path(config_path).exists(): + return jsonify({"success": True, "models": [], "default_model": ""}) + raw = _json.loads(_Path(config_path).read_text(encoding="utf-8")) + models_raw = raw.get("models", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else []) + default_model = str(raw.get("default_model", "")) if isinstance(raw, dict) else "" + # 脱敏:只返回 name / modes / multimodal / max_output / max_context + safe_models = [] + for m in models_raw: + if not isinstance(m, dict): + continue + safe_models.append({ + "name": m.get("name") or m.get("model_name") or "", + "modes": m.get("modes") or m.get("mode") or "", + "multimodal": m.get("multimodal") or "none", + "max_output": m.get("max_output") or m.get("max_tokens") or 0, + "max_context": m.get("max_context") or m.get("context_window") or 0, + }) + return jsonify({ + "success": True, + "models": safe_models, + "default_model": default_model, + }) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @multi_agent_bp.route("/api/multiagent/active_sub_agents", methods=["GET"]) @api_login_required def list_active_sub_agents_api(): diff --git a/static/src/App.vue b/static/src/App.vue index 2321e2b..f078282 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -328,6 +328,8 @@ :goal-mode-armed="goalModeArmed" :goal-running="goalRunning" :goal-progress="goalProgress" + :multi-agent-mode="multiAgentMode" + :has-running-task="composerBusy || anyWorkspaceHasRunningTask" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" @restore-user-question="restoreUserQuestionDialog" @update:input-message="inputSetMessage" @@ -366,6 +368,7 @@ @delete-runtime-message="handleDeleteRuntimeMessage" @composer-height-change="handleComposerHeightChange" @toggle-goal-mode="handleToggleGoalMode" + @toggle-agent-mode="handleToggleAgentMode" @open-goal-dialog="goalDialogOpen = true" @open-git-changes-panel="openGitChangesPanel" /> diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index f5834e8..c6dcd2b 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -231,6 +231,13 @@ export const computed = { ); }); }, + anyWorkspaceHasRunningTask() { + const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []; + return tasks.some((task: any) => { + const status = String(task?.status || ''); + return ['pending', 'running', 'cancel_requested'].includes(status); + }); + }, streamingUi() { return this.streamingMessage || this.hasPendingToolActions(); }, diff --git a/static/src/app/methods/ui/mode.ts b/static/src/app/methods/ui/mode.ts index fca1c94..84cea8b 100644 --- a/static/src/app/methods/ui/mode.ts +++ b/static/src/app/methods/ui/mode.ts @@ -65,6 +65,19 @@ export const modeMethods = { this.modeMenuOpen = false; this.modelMenuOpen = false; }, + handleToggleAgentMode() { + if (!this.isConnected) { + return; + } + // 有运行中对话时不允许切换 + if (this.composerBusy || this.anyWorkspaceHasRunningTask) { + return; + } + this.inputCloseMenus(); + // 多智能体模式 → 传统模式;传统模式 → 多智能体模式 + const target = this.multiAgentMode ? '/new' : '/multiagent/new'; + window.location.href = target; + }, toggleModeMenu() { if (!this.isConnected || this.streamingMessage) { return; diff --git a/static/src/auth/LoginApp.vue b/static/src/auth/LoginApp.vue index a785223..bf62709 100644 --- a/static/src/auth/LoginApp.vue +++ b/static/src/auth/LoginApp.vue @@ -38,14 +38,6 @@ 宿主机模式(免登录) - -
{{ error }}
@@ -133,18 +125,6 @@ const doHostLogin = async (redirectUrl = '/') => { 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/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 9260146..f9fbedb 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -353,6 +353,8 @@ :execution-mode-enabled="executionModeEnabled" :goal-mode-armed="goalModeArmed" :goal-running="goalRunning" + :multi-agent-mode="multiAgentMode" + :has-running-task="hasRunningTask" @quick-upload="triggerQuickUpload" @pick-images="$emit('pick-images')" @pick-video="$emit('pick-video')" @@ -371,6 +373,7 @@ @open-review="$emit('open-review')" @open-path-authorization="$emit('open-path-authorization')" @toggle-goal-mode="$emit('toggle-goal-mode')" + @toggle-agent-mode="$emit('toggle-agent-mode')" />
+ +
+ + + +
+ 角色列表 + +
+ +
加载中...
+
+ 暂无角色 +
+ +
+
+
+
+ {{ role.name }} + + {{ role.is_custom ? '自定义' : '预设' }} + +
+
{{ role.description || '无描述' }}
+
+ ID: {{ role.role_id }} · 思考模式: {{ role.thinking_mode }} +
+
+
+ + +
+
+
+ + + +
+
+
+

{{ editingRole ? '编辑角色' : '新建角色' }}

+ +
+
+ + + +
+ + 思考模式 + fast = 快速响应,thinking = 深度推理 + +
+ +
+ + +
+
+
+
+ + 模型 + 留空则使用默认模型 + +
+ +
+ + +
+
+
+
+ Prompt Body(Markdown) + +
+
+ +
+
+
+ + +
@@ -1888,6 +2133,8 @@ type PersonalTab = | 'tools' | 'files' | 'data' + | 'voice' + | 'sub-agents' | 'admin'; const baseTabs = [ @@ -1900,7 +2147,8 @@ const baseTabs = [ { id: 'tools', label: '工具与 Skills', icon: 'wrench' }, { id: 'files', label: '文件与图片', icon: 'file' }, { id: 'data', label: '数据管理', icon: 'layers' }, - { id: 'voice', label: '语音模型', icon: 'mic' } + { id: 'voice', label: '语音模型', icon: 'mic' }, + { id: 'sub-agents', label: '子智能体', icon: 'bot' } ] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>; const sessionRole = ref(''); @@ -2498,6 +2746,11 @@ watch( if (isVisible && tab === 'data') { fetchUsageSummary(); } + if (isVisible && tab === 'sub-agents') { + loadSubAgentRoles(); + loadSubAgentSettings(); + loadSubAgentModels(); + } if ( isVisible && tab === 'general' && @@ -2510,6 +2763,145 @@ watch( } ); +// ----- 子智能体管理 ----- +const subAgentRoles = ref([]); +const subAgentRolesLoading = ref(false); +const subAgentCompressThreshold = ref(150000); +const subAgentSettingsSaving = ref(false); +const subAgentModels = ref([]); +const roleEditorOpen = ref(false); +const editingRole = ref(null); +const roleSaving = ref(false); +const roleForm = ref({ + role_id: '', + name: '', + description: '', + body_prompt: '', + thinking_mode: 'fast', + model_key: '' +}); + +const loadSubAgentRoles = async () => { + subAgentRolesLoading.value = true; + try { + const resp = await fetch('/api/multiagent/roles', { credentials: 'same-origin' }); + const data = await resp.json(); + if (data.success) { + subAgentRoles.value = data.roles || []; + } + } catch (e) { + // 静默处理 + } finally { + subAgentRolesLoading.value = false; + } +}; + +const loadSubAgentModels = async () => { + try { + const resp = await fetch('/api/multiagent/models', { credentials: 'same-origin' }); + const data = await resp.json(); + if (data.success) { + subAgentModels.value = data.models || []; + } + } catch (e) { + // 静默处理 + } +}; + +const loadSubAgentSettings = async () => { + try { + const resp = await fetch('/api/multiagent/settings', { credentials: 'same-origin' }); + const data = await resp.json(); + if (data.success && data.settings) { + subAgentCompressThreshold.value = data.settings.sub_agent_compress_threshold_tokens || 150000; + } + } catch (e) { + // 静默处理 + } +}; + +const saveSubAgentSettings = async () => { + subAgentSettingsSaving.value = true; + try { + await fetch('/api/multiagent/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ settings: { sub_agent_compress_threshold_tokens: subAgentCompressThreshold.value } }) + }); + } finally { + subAgentSettingsSaving.value = false; + } +}; + +const openRoleEditor = (role?: any) => { + if (role) { + editingRole.value = role; + roleForm.value = { + role_id: role.role_id || '', + name: role.name || '', + description: role.description || '', + body_prompt: role.body_prompt || '', + thinking_mode: role.thinking_mode || 'fast', + model_key: role.model_key || '' + }; + } else { + editingRole.value = null; + roleForm.value = { role_id: '', name: '', description: '', body_prompt: '', thinking_mode: 'fast', model_key: '' }; + } + closeDropdown(); + roleEditorOpen.value = true; +}; + +const closeRoleEditor = () => { + roleEditorOpen.value = false; + editingRole.value = null; + closeDropdown(); +}; + +const saveRole = async () => { + roleSaving.value = true; + try { + const isEdit = !!editingRole.value; + const url = isEdit + ? `/api/multiagent/roles/${roleForm.value.role_id}` + : '/api/multiagent/roles'; + const method = isEdit ? 'PUT' : 'POST'; + const resp = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + ...roleForm.value, + model_key: roleForm.value.model_key || null + }) + }); + const data = await resp.json(); + if (data.success) { + closeRoleEditor(); + await loadSubAgentRoles(); + } + } finally { + roleSaving.value = false; + } +}; + +const deleteRole = async (role: any) => { + if (!confirm(`确认删除角色「${role.name}」?`)) return; + try { + const resp = await fetch(`/api/multiagent/roles/${role.role_id}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + const data = await resp.json(); + if (data.success) { + await loadSubAgentRoles(); + } + } catch (e) { + // 静默处理 + } +}; + const setActiveTab = (tab: PersonalTab) => { activeTab.value = tab; closeDropdown(); @@ -2886,6 +3278,123 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {