From 13689e80c2d426c6e6993656938333b825f37b90 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 3 May 2026 15:28:36 +0800 Subject: [PATCH 01/15] fix(paths): stabilize workspace and MCP cwd resolution --- config/paths.py | 32 +++++++--- config/sub_agent.py | 20 ++++++- core/main_terminal.py | 7 +++ modules/mcp_client_manager.py | 26 ++++++++- modules/search_engine.py | 21 +++---- test/test_config_paths_resolution.py | 87 ++++++++++++++++++++++++++++ test/test_mcp_integration.py | 45 ++++++++++++++ 7 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 test/test_config_paths_resolution.py diff --git a/config/paths.py b/config/paths.py index d9d3ae6..17f7c68 100644 --- a/config/paths.py +++ b/config/paths.py @@ -1,27 +1,41 @@ """项目路径与目录配置。""" import os +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_repo_path(raw_value: str, default: str) -> str: + candidate = str(raw_value or "").strip() or str(default) + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + else: + path = path.resolve() + return str(path) # 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录 -DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project") +DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project") # 宿主机模式工作区配置文件(JSON) -HOST_WORKSPACES_FILE = os.environ.get("HOST_WORKSPACES_FILE", "./config/host_workspaces.json") +HOST_WORKSPACES_FILE = _resolve_repo_path(os.environ.get("HOST_WORKSPACES_FILE", ""), "./config/host_workspaces.json") # 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理) -HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH) -PROMPTS_DIR = "./prompts" -DATA_DIR = "./data" -LOGS_DIR = "./logs" -AGENT_SKILLS_DIR = "./agentskills" +HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), DEFAULT_PROJECT_PATH) +PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") +DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data") +LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs") +AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") WORKSPACE_SKILLS_DIRNAME = "skills" # 多用户空间 -USER_SPACE_DIR = "./users" +USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users") USERS_DB_FILE = f"{DATA_DIR}/users.json" INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json" ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json" # API 专用用户与工作区(与网页用户隔离) -API_USER_SPACE_DIR = "./api/users" +API_USER_SPACE_DIR = _resolve_repo_path(os.environ.get("API_USER_SPACE_DIR", ""), "./api/users") API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json" API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json" API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" diff --git a/config/sub_agent.py b/config/sub_agent.py index 493304f..74d387e 100644 --- a/config/sub_agent.py +++ b/config/sub_agent.py @@ -1,6 +1,20 @@ """子智能体相关配置。""" import os +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_repo_path(raw_value: str, default: str) -> str: + candidate = str(raw_value or "").strip() or str(default) + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + else: + path = path.resolve() + return str(path) # 子智能体服务 SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092") @@ -8,9 +22,9 @@ SUB_AGENT_DEFAULT_TIMEOUT = int(os.environ.get("SUB_AGENT_DEFAULT_TIMEOUT", "180 SUB_AGENT_STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0")) # 存储与并发限制 -SUB_AGENT_TASKS_BASE_DIR = os.environ.get("SUB_AGENT_TASKS_BASE_DIR", "./sub_agent/tasks") -SUB_AGENT_PROJECT_RESULTS_DIR = os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", "./project/sub_agent_results") -SUB_AGENT_STATE_FILE = os.environ.get("SUB_AGENT_STATE_FILE", "./data/sub_agents.json") +SUB_AGENT_TASKS_BASE_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_TASKS_BASE_DIR", ""), "./sub_agent/tasks") +SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), "./project/sub_agent_results") +SUB_AGENT_STATE_FILE = _resolve_repo_path(os.environ.get("SUB_AGENT_STATE_FILE", ""), "./data/sub_agents.json") SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5")) __all__ = [ diff --git a/core/main_terminal.py b/core/main_terminal.py index 0f67719..67cc66b 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -396,6 +396,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi except Exception: pass + # MCP stdio 客户端是长连接子进程,切换工作区后需重建,确保后续 cwd 与路径映射一致。 + if getattr(self, "mcp_client_manager", None): + try: + self.mcp_client_manager.close_all_clients() + except Exception: + pass + # 强制下次请求重新同步 skills(含 AGENTS.md 场景) self._skills_synced_project_path = None diff --git a/modules/mcp_client_manager.py b/modules/mcp_client_manager.py index 54bf2ad..8f35b30 100644 --- a/modules/mcp_client_manager.py +++ b/modules/mcp_client_manager.py @@ -211,7 +211,12 @@ class _StdioMCPClient: if not use_container: env = dict(os.environ) env.update(env_raw) - return [command_raw, *args_raw], cwd_raw, env + resolved_cwd = cwd_raw + if not resolved_cwd and session is not None: + workspace_hint = self._normalize_workspace_path(getattr(session, "workspace_path", "")) + if workspace_hint: + resolved_cwd = workspace_hint + return [command_raw, *args_raw], resolved_cwd, env container_name = str(getattr(session, "container_name", "") or "").strip() if not container_name: @@ -731,6 +736,7 @@ class MCPClientManager: self.registry = registry self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION) self.container_session = container_session + self._container_session_signature = self._compute_session_signature(container_session) self._latest_alias_map: Dict[str, MCPToolBinding] = {} self._client_pool: Dict[str, MCPClientPoolEntry] = {} self._pool_lock = threading.RLock() @@ -741,11 +747,27 @@ class MCPClientManager: except Exception: pass + @staticmethod + def _compute_session_signature(session: Optional["ContainerHandle"]) -> Optional[Tuple[str, str, str, str, str]]: + if not session: + return None + return ( + str(getattr(session, "mode", "") or ""), + str(getattr(session, "workspace_path", "") or ""), + str(getattr(session, "mount_path", "") or ""), + str(getattr(session, "container_name", "") or ""), + str(getattr(session, "sandbox_bin", "") or ""), + ) + def set_container_session(self, session: Optional["ContainerHandle"]) -> None: - if session is self.container_session: + next_signature = self._compute_session_signature(session) + if next_signature == self._container_session_signature: + # 引用对象可能相同(甚至被原地修改),这里仍刷新引用,便于后续读取最新字段。 + self.container_session = session return self.close_all_clients() self.container_session = session + self._container_session_signature = next_signature @staticmethod def _server_signature(server: Dict[str, Any]) -> str: diff --git a/modules/search_engine.py b/modules/search_engine.py index 356ed9a..bccf932 100644 --- a/modules/search_engine.py +++ b/modules/search_engine.py @@ -4,16 +4,16 @@ import httpx import json from typing import Dict, Optional, Any, List from datetime import datetime +from pathlib import Path import re try: - from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS + from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR except ImportError: import sys - from pathlib import Path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) - from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS + from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR class SearchEngine: def __init__(self): @@ -286,19 +286,16 @@ class SearchEngine: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"search_{timestamp}.json" - file_path = f"./data/searches/{filename}" - - # 确保目录存在 - import os - os.makedirs(os.path.dirname(file_path), exist_ok=True) + file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename + file_path.parent.mkdir(parents=True, exist_ok=True) # 保存结果 - with open(file_path, 'w', encoding='utf-8') as f: + with file_path.open('w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2) print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}") - return file_path + return str(file_path) def load_results(self, filename: str) -> Optional[Dict]: """ @@ -310,10 +307,10 @@ class SearchEngine: Returns: 搜索结果字典或None """ - file_path = f"./data/searches/{filename}" + file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename try: - with open(file_path, 'r', encoding='utf-8') as f: + with file_path.open('r', encoding='utf-8') as f: return json.load(f) except FileNotFoundError: print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}") diff --git a/test/test_config_paths_resolution.py b/test/test_config_paths_resolution.py new file mode 100644 index 0000000..58e997f --- /dev/null +++ b/test/test_config_paths_resolution.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class ConfigPathsResolutionTest(unittest.TestCase): + def setUp(self): + self.repo_root = Path(__file__).resolve().parents[1] + + def _load_paths(self, *, cwd: Path, extra_env: dict | None = None) -> dict: + code = """ +import json +import config +print(json.dumps({ + "DEFAULT_PROJECT_PATH": config.DEFAULT_PROJECT_PATH, + "HOST_WORKSPACES_FILE": config.HOST_WORKSPACES_FILE, + "DATA_DIR": config.DATA_DIR, + "LOGS_DIR": config.LOGS_DIR, + "USER_SPACE_DIR": config.USER_SPACE_DIR, + "API_USER_SPACE_DIR": config.API_USER_SPACE_DIR, + "SUB_AGENT_STATE_FILE": config.SUB_AGENT_STATE_FILE, +}, ensure_ascii=False)) +""" + env = dict(os.environ) + env["PYTHONPATH"] = str(self.repo_root) + if extra_env: + env.update(extra_env) + completed = subprocess.run( + [sys.executable, "-c", code], + cwd=str(cwd), + env=env, + capture_output=True, + text=True, + check=True, + ) + lines = [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()] + self.assertTrue(lines, f"stdout is empty, stderr={completed.stderr}") + return json.loads(lines[-1]) + + def test_default_paths_are_resolved_from_repo_root(self): + with tempfile.TemporaryDirectory() as td: + data = self._load_paths(cwd=Path(td)) + + for key, raw in data.items(): + value = Path(str(raw)) + self.assertTrue(value.is_absolute(), f"{key} is not absolute: {raw}") + self.assertTrue( + str(value).startswith(str(self.repo_root)), + f"{key} should be anchored to repo root: {raw}", + ) + + def test_relative_env_overrides_also_anchor_to_repo_root(self): + with tempfile.TemporaryDirectory() as td: + data = self._load_paths( + cwd=Path(td), + extra_env={ + "DEFAULT_PROJECT_PATH": "./workspace_rel", + "DATA_DIR": "./data_rel", + "LOGS_DIR": "./logs_rel", + "USER_SPACE_DIR": "./users_rel", + "HOST_WORKSPACES_FILE": "./config/host_workspaces_rel.json", + "SUB_AGENT_STATE_FILE": "./data/sub_agents_rel.json", + }, + ) + + self.assertEqual(data["DEFAULT_PROJECT_PATH"], str((self.repo_root / "workspace_rel").resolve())) + self.assertEqual(data["DATA_DIR"], str((self.repo_root / "data_rel").resolve())) + self.assertEqual(data["LOGS_DIR"], str((self.repo_root / "logs_rel").resolve())) + self.assertEqual(data["USER_SPACE_DIR"], str((self.repo_root / "users_rel").resolve())) + self.assertEqual( + data["HOST_WORKSPACES_FILE"], + str((self.repo_root / "config" / "host_workspaces_rel.json").resolve()), + ) + self.assertEqual( + data["SUB_AGENT_STATE_FILE"], + str((self.repo_root / "data" / "sub_agents_rel.json").resolve()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mcp_integration.py b/test/test_mcp_integration.py index baad8b0..08c442f 100644 --- a/test/test_mcp_integration.py +++ b/test/test_mcp_integration.py @@ -478,6 +478,51 @@ class MCPIntegrationTest(unittest.TestCase): self.assertNotIn("/opt/homebrew/bin/npx", launch_cmd) self.assertIn("/Users/jojo/Desktop", launch_cmd) + def test_stdio_host_mode_uses_workspace_as_default_cwd(self): + workspace = (self.project_dir / "workspace_a").resolve() + workspace.mkdir(parents=True, exist_ok=True) + fake_session = SimpleNamespace( + mode="host", + workspace_path=str(workspace), + mount_path=str(workspace), + container_name=None, + sandbox_bin="docker", + ) + server = { + "command": sys.executable, + "args": ["-V"], + "cwd": "", + "env": {}, + } + client = _StdioMCPClient(server, timeout_seconds=10, protocol_version="2025-06-18", container_session=fake_session) + _, cwd, _ = client._prepare_launch() + self.assertEqual(cwd, str(workspace)) + + def test_set_container_session_detects_inplace_workspace_change(self): + manager = MCPClientManager(self.registry) + close_calls = [] + original_close = manager.close_all_clients + manager.close_all_clients = lambda: close_calls.append("closed") # type: ignore[assignment] + try: + session = SimpleNamespace( + mode="host", + workspace_path=str((self.project_dir / "ws_a").resolve()), + mount_path="/workspace", + container_name="", + sandbox_bin="docker", + ) + manager.set_container_session(session) + self.assertEqual(len(close_calls), 1) + + session.workspace_path = str((self.project_dir / "ws_b").resolve()) + manager.set_container_session(session) + self.assertEqual(len(close_calls), 2) + + manager.set_container_session(session) + self.assertEqual(len(close_calls), 2) + finally: + manager.close_all_clients = original_close # type: ignore[assignment] + def test_stateful_stdio_server_reuses_persistent_session(self): stateful_script = self._write_temp_server( "stateful_mcp_server.py", From 6aba89ae9b05730d781f657c6143e7cfa94a613e Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 3 May 2026 15:46:16 +0800 Subject: [PATCH 02/15] feat(edit-file): add replace_all for precise or global replacement --- core/main_terminal_parts/tools_definition.py | 6 ++++++ core/main_terminal_parts/tools_execution.py | 10 +++++++++- easyagent/src/tools/edit_file.js | 16 ++++++++++++---- modules/file_manager.py | 10 +++++++--- prompts/main_system.txt | 2 +- prompts/main_system_qwenvl.txt | 2 +- scripts/api_tool_role_experiment.py | 3 ++- server/chat_flow_tool_loop.py | 5 ++++- 8 files changed, 42 insertions(+), 12 deletions(-) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index ffff9d9..82b7e9d 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -545,6 +545,12 @@ class MainTerminalToolsDefinitionMixin: "new_string": { "type": "string", "description": "用于替换的新文本(必须不同于 old_string)" + }, + "replace_all": { + "type": "boolean", + "enum": [True, False], + "description": "是否替换所有匹配内容。false=仅替换首个匹配,true=替换全部匹配。", + "default": False } }), "required": ["file_path", "old_string", "new_string"] diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index e252cd5..b927a11 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1065,16 +1065,24 @@ class MainTerminalToolsExecutionMixin: path = arguments.get("file_path") old_text = arguments.get("old_string") new_text = arguments.get("new_string") + replace_all = arguments.get("replace_all", False) if not path: result = {"success": False, "error": "缺少必要参数: file_path"} elif old_text is None or new_text is None: result = {"success": False, "error": "缺少必要参数: old_string/new_string"} + elif not isinstance(replace_all, bool): + result = {"success": False, "error": "replace_all 必须是 true 或 false"} elif old_text == new_text: result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"} elif not old_text: result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"} else: - result = self.file_manager.replace_in_file(path, old_text, new_text) + result = self.file_manager.replace_in_file( + path, + old_text, + new_text, + replace_all=replace_all + ) elif tool_name == "create_folder": result = self.file_manager.create_folder(arguments["path"]) diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js index d947320..4d3cd20 100644 --- a/easyagent/src/tools/edit_file.js +++ b/easyagent/src/tools/edit_file.js @@ -45,6 +45,10 @@ function editFileTool(workspace, args) { const target = resolvePath(workspace, args.file_path); const oldString = args.old_string ?? ''; const newString = args.new_string ?? ''; + const replaceAll = args.replace_all ?? false; + if (typeof replaceAll !== 'boolean') { + return { success: false, error: 'replace_all 必须是 true 或 false' }; + } const ctx = getContainerContext(); if (ctx) { const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx); @@ -70,8 +74,10 @@ function editFileTool(workspace, args) { if (!original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } - const updated = original.split(oldString).join(newString); - const replacements = original.split(oldString).length - 1; + const replacements = replaceAll ? (original.split(oldString).length - 1) : 1; + const updated = replaceAll + ? original.split(oldString).join(newString) + : original.replace(oldString, newString); const writeResp = execContainerAction(ctx, 'write_file', { path: rel, content: updated, @@ -104,8 +110,10 @@ function editFileTool(workspace, args) { if (!creating && !original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } - const updated = creating ? newString : original.split(oldString).join(newString); - let replacements = creating ? 0 : original.split(oldString).length - 1; + const updated = creating + ? newString + : (replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString)); + let replacements = creating ? 0 : (replaceAll ? (original.split(oldString).length - 1) : 1); if (creating && newString) replacements = 1; fs.writeFileSync(target, updated, 'utf8'); const diff = diffSummary(original, updated); diff --git a/modules/file_manager.py b/modules/file_manager.py index f4e0a1c..97233f5 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -1166,7 +1166,7 @@ class FileManager: "error": write_error } - def replace_in_file(self, path: str, old_text: str, new_text: str) -> Dict: + def replace_in_file(self, path: str, old_text: str, new_text: str, replace_all: bool = False) -> Dict: """替换文件中的内容""" # 先读取文件 result = self.read_file(path) @@ -1196,8 +1196,12 @@ class FileManager: # 替换内容 if old_text: - new_content = content.replace(old_text, new_text) - count = content.count(old_text) + if replace_all: + count = content.count(old_text) + new_content = content.replace(old_text, new_text) + else: + count = 1 + new_content = content.replace(old_text, new_text, 1) else: # 空文件直接写入新内容 new_content = new_text diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 81ccfb3..84ccc62 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换 +- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index d46e047..e41502b 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换 +- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/scripts/api_tool_role_experiment.py b/scripts/api_tool_role_experiment.py index ca018fc..463d00a 100644 --- a/scripts/api_tool_role_experiment.py +++ b/scripts/api_tool_role_experiment.py @@ -80,7 +80,8 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]: "properties": { "file_path": {"type": "string", "description": "目标文件的相对路径"}, "old_string": {"type": "string", "description": "需要替换的原文"}, - "new_string": {"type": "string", "description": "替换后的新内容"} + "new_string": {"type": "string", "description": "替换后的新内容"}, + "replace_all": {"type": "boolean", "description": "是否替换全部匹配", "default": False} }, "required": ["file_path", "old_string", "new_string"] } diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 2a04a4a..cf13f07 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -45,7 +45,9 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di file_path = args.get("file_path") old_string = args.get("old_string") new_string = args.get("new_string") + replace_all = args.get("replace_all", False) preview["file_path"] = file_path + preview["replace_all"] = replace_all if not file_path: preview["summary"] = "缺少 file_path" return preview @@ -82,7 +84,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di "old_start_line": start_line_no, "old_end_line": end_line_no, } - preview["summary"] = f"编辑 {file_path} 第 {start_line_no}-{end_line_no} 行" + mode_text = "全部匹配" if replace_all is True else "首个匹配" + preview["summary"] = f"编辑 {file_path} 第 {start_line_no}-{end_line_no} 行({mode_text})" else: preview["edit_context"] = { "before": [], From f198c0c63e08d03c6d52d3c4df049f1345a271a4 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 3 May 2026 15:46:34 +0800 Subject: [PATCH 03/15] fix(host-workspace): make config writes atomic and lock-protected --- modules/host_workspace_manager.py | 123 ++++++++++++++++++------------ 1 file changed, 75 insertions(+), 48 deletions(-) diff --git a/modules/host_workspace_manager.py b/modules/host_workspace_manager.py index fb77587..0ebc07f 100644 --- a/modules/host_workspace_manager.py +++ b/modules/host_workspace_manager.py @@ -3,7 +3,10 @@ from __future__ import annotations import json +import os import re +import tempfile +import threading from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union @@ -11,6 +14,7 @@ from config import DEFAULT_PROJECT_PATH, HOST_WORKSPACES_FILE _WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$") _REPO_ROOT = Path(__file__).resolve().parents[1] +_HOST_WORKSPACE_LOCK = threading.RLock() def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path: @@ -43,17 +47,39 @@ def _default_payload() -> Dict[str, Any]: } -def _ensure_config_file(path: Path) -> Dict[str, Any]: +def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fp: + json.dump(data, fp, ensure_ascii=False, indent=2) + fp.flush() + os.fsync(fp.fileno()) + os.replace(tmp_path, path) + finally: + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + + +def _ensure_config_file(path: Path, *, strict: bool = False) -> Dict[str, Any]: path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): return data - except Exception: - pass + if strict: + raise RuntimeError("host_workspaces 配置格式错误(非 JSON 对象),已停止写入以避免覆盖原文件") + except Exception as exc: + if strict: + raise RuntimeError(f"host_workspaces 配置解析失败,已停止写入以避免覆盖原文件: {exc}") from exc + # 只回退到内存默认值,不覆盖磁盘文件 + return _default_payload() data = _default_payload() - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + _atomic_write_json(path, data) return data @@ -107,7 +133,8 @@ def load_host_workspace_catalog( config_path: Optional[Union[str, Path]] = None, ) -> Dict[str, Any]: cfg_path = _resolve_config_path(config_path) - payload = _ensure_config_file(cfg_path) + with _HOST_WORKSPACE_LOCK: + payload = _ensure_config_file(cfg_path, strict=False) raw_workspaces = payload.get("workspaces") if not isinstance(raw_workspaces, list): raw_workspaces = [] @@ -174,56 +201,56 @@ def create_host_workspace( config_path: Optional[Union[str, Path]] = None, ) -> Dict[str, Any]: cfg_path = _resolve_config_path(config_path) - payload = _ensure_config_file(cfg_path) + with _HOST_WORKSPACE_LOCK: + payload = _ensure_config_file(cfg_path, strict=True) - raw_workspaces = payload.get("workspaces") - if not isinstance(raw_workspaces, list): - raw_workspaces = [] + raw_workspaces = payload.get("workspaces") + if not isinstance(raw_workspaces, list): + raw_workspaces = [] - normalized_path = _normalize_workspace_path(path) - normalized_path.mkdir(parents=True, exist_ok=True) - target_path_str = str(normalized_path) + normalized_path = _normalize_workspace_path(path) + normalized_path.mkdir(parents=True, exist_ok=True) + target_path_str = str(normalized_path) - for idx, item in enumerate(raw_workspaces): - existing = _normalize_entry(item, idx) - if not existing: - continue - if existing.get("path") == target_path_str: - # 已存在相同路径则直接返回 - return { - "created": False, - "workspace": existing, - "catalog": load_host_workspace_catalog(config_path=cfg_path), - } + for idx, item in enumerate(raw_workspaces): + existing = _normalize_entry(item, idx) + if not existing: + continue + if existing.get("path") == target_path_str: + # 已存在相同路径则直接返回 + return { + "created": False, + "workspace": existing, + "catalog": load_host_workspace_catalog(config_path=cfg_path), + } - clean_label = str(label or "").strip() - base_id_seed = clean_label or normalized_path.name or "workspace" - base_id = _slugify_workspace_id(base_id_seed) - existing_ids = { - _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) - for i, item in enumerate(raw_workspaces) - if isinstance(item, dict) - } - workspace_id = base_id - suffix = 2 - while workspace_id in existing_ids: - workspace_id = f"{base_id}-{suffix}" - suffix += 1 + clean_label = str(label or "").strip() + base_id_seed = clean_label or normalized_path.name or "workspace" + base_id = _slugify_workspace_id(base_id_seed) + existing_ids = { + _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) + for i, item in enumerate(raw_workspaces) + if isinstance(item, dict) + } + workspace_id = base_id + suffix = 2 + while workspace_id in existing_ids: + workspace_id = f"{base_id}-{suffix}" + suffix += 1 - workspace = { - "workspace_id": workspace_id, - "label": clean_label or workspace_id, - "path": target_path_str, - } - raw_workspaces.append(workspace) - payload["workspaces"] = raw_workspaces + workspace = { + "workspace_id": workspace_id, + "label": clean_label or workspace_id, + "path": target_path_str, + } + raw_workspaces.append(workspace) + payload["workspaces"] = raw_workspaces - default_id = str(payload.get("default_workspace_id") or "").strip() - if set_default or not default_id: - payload["default_workspace_id"] = workspace_id + default_id = str(payload.get("default_workspace_id") or "").strip() + if set_default or not default_id: + payload["default_workspace_id"] = workspace_id - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + _atomic_write_json(cfg_path, payload) return { "created": True, From 8de62759124d10b1f464f7128490c030be838826 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 5 May 2026 13:51:52 +0800 Subject: [PATCH 04/15] fix(file): handle empty text segment reads --- modules/container_file_proxy.py | 10 ++++++++++ modules/file_manager.py | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/modules/container_file_proxy.py b/modules/container_file_proxy.py index a2ee6a2..ce8b158 100644 --- a/modules/container_file_proxy.py +++ b/modules/container_file_proxy.py @@ -122,6 +122,16 @@ def _read_text_segment(root, payload): } data, lines = _read_text(target) total = len(lines) + if total == 0: + return { + "success": True, + "path": rel, + "content": "", + "size": size, + "line_start": 0, + "line_end": 0, + "total_lines": 0 + } line_start = start if start and start > 0 else 1 line_end = end if end and end >= line_start else total if line_start > total: diff --git a/modules/file_manager.py b/modules/file_manager.py index 97233f5..cd84f03 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -495,6 +495,17 @@ class FileManager: lines = result["lines"] total_lines = len(lines) + if total_lines == 0: + relative_path = self._relative_path(full_path) + return { + "success": True, + "path": relative_path, + "content": "", + "size": result["size"], + "line_start": 0, + "line_end": 0, + "total_lines": 0, + } start = start_line if start_line and start_line > 0 else 1 end = end_line if end_line and end_line >= start else total_lines if start > total_lines: From 971f29b0590e381d5ac23ae7cb62ace39bd5192d Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 5 May 2026 13:54:51 +0800 Subject: [PATCH 05/15] chore(debug): add connection heartbeat diagnostics --- server/status.py | 51 ++++++++++- server/utils_common.py | 8 ++ static/src/app/methods/ui.ts | 159 ++++++++++++++++++++++++++++++++++- static/src/app/state.ts | 5 ++ 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/server/status.py b/server/status.py index 05a213a..644efbe 100644 --- a/server/status.py +++ b/server/status.py @@ -1,6 +1,7 @@ from __future__ import annotations import time import re +import os from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session @@ -20,9 +21,12 @@ from modules.host_workspace_manager import ( resolve_host_workspace, ) from utils.host_workspace_debug import write_host_workspace_debug +from .utils_common import log_conn_diag from . import state status_bp = Blueprint('status', __name__) +STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "false", "False"} +STATUS_DIAG_SLOW_MS = int(os.environ.get("STATUS_DIAG_SLOW_MS", "1200") or 1200) def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]: @@ -70,9 +74,33 @@ def _is_host_mode_request() -> bool: @with_terminal def get_status(terminal, workspace, username): """获取系统状态(包含对话、容器、版本等信息)""" - status = terminal.get_status() + started_at = time.perf_counter() + heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() + diag_requested = STATUS_DIAG_VERBOSE or request.args.get("diag") == "1" + timings = { + "terminal_ms": 0.0, + "terminals_ms": 0.0, + "conversation_meta_ms": 0.0, + "container_ms": 0.0, + "policy_ms": 0.0, + } + phase_started_at = time.perf_counter() + try: + status = terminal.get_status() + except Exception as exc: + timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 + total_ms = (time.perf_counter() - started_at) * 1000 + log_conn_diag( + f"status hb={heartbeat_id or '-'} user={username} phase=terminal.get_status " + f"total_ms={total_ms:.1f} terminal_ms={timings['terminal_ms']:.1f} error={exc}" + ) + raise + timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 + phase_started_at = time.perf_counter() if terminal.terminal_manager: status['terminals'] = terminal.terminal_manager.list_terminals() + timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000 + phase_started_at = time.perf_counter() try: current_conv = terminal.context_manager.current_conversation_id status.setdefault('conversation', {})['current_id'] = current_conv @@ -92,13 +120,18 @@ def get_status(terminal, workspace, username): "job_id": meta.get("compression_job_id"), } except Exception as exc: - print(f"[Status] 获取当前对话信息失败: {exc}") + log_conn_diag(f"status conversation-meta-failed user={username} error={exc}") + timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['project_path'] = str(workspace.project_path) + phase_started_at = time.perf_counter() try: status['container'] = container_manager.get_container_status(username) except Exception as exc: status['container'] = {"success": False, "error": str(exc)} + log_conn_diag(f"status container-status-failed user={username} error={exc}") + timings["container_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['version'] = AGENT_VERSION + phase_started_at = time.perf_counter() try: policy = resolve_admin_policy(user_manager.get_user(username)) status['admin_policy'] = { @@ -109,6 +142,20 @@ def get_status(terminal, workspace, username): } except Exception: pass + timings["policy_ms"] = (time.perf_counter() - phase_started_at) * 1000 + total_ms = (time.perf_counter() - started_at) * 1000 + is_slow = total_ms >= STATUS_DIAG_SLOW_MS + if diag_requested or is_slow: + conversation = status.get("conversation") or {} + log_conn_diag( + f"status hb={heartbeat_id or '-'} user={username} host_mode={bool(session.get('host_mode'))} " + f"workspace_id={session.get('workspace_id') or '-'} " + f"conversation_id={conversation.get('current_id') or '-'} " + f"total_ms={total_ms:.1f} slow={is_slow} " + f"terminal_ms={timings['terminal_ms']:.1f} terminals_ms={timings['terminals_ms']:.1f} " + f"conversation_meta_ms={timings['conversation_meta_ms']:.1f} " + f"container_ms={timings['container_ms']:.1f} policy_ms={timings['policy_ms']:.1f}" + ) return jsonify(status) diff --git a/server/utils_common.py b/server/utils_common.py index 0a6f141..bf66a4c 100644 --- a/server/utils_common.py +++ b/server/utils_common.py @@ -144,6 +144,7 @@ DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "debug_stream.log" CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log" CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log" STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log" +CONN_DIAG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "conn_diag.log" def _write_log(file_path: Path, message: str) -> None: @@ -158,6 +159,11 @@ def debug_log(message): _write_log(DEBUG_LOG_FILE, message) +def log_conn_diag(message: str): + """连接诊断日志:统一关键词并直接落盘到独立文件。""" + _write_log(CONN_DIAG_LOG_FILE, f"[CONN_DIAG] {message}") + + def log_backend_chunk(conversation_id: str, iteration: int, chunk_index: int, elapsed: float, char_len: int, content_preview: str): preview = content_preview.replace('\n', '\\n') _write_log( @@ -192,4 +198,6 @@ __all__ = [ "CHUNK_BACKEND_LOG_FILE", "CHUNK_FRONTEND_LOG_FILE", "STREAMING_DEBUG_LOG_FILE", + "CONN_DIAG_LOG_FILE", + "log_conn_diag", ] diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 0fcb8ca..60908ce 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -60,6 +60,61 @@ function uiBounceTrace( console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload); } +function isConnectionDiagEnabled() { + if (typeof window === 'undefined') return true; + try { + const explicit = (window as any).__CONN_DIAG__; + if (explicit === false || explicit === '0') return false; + if (explicit === true || explicit === '1') return true; + const localFlag = window.localStorage?.getItem('connDiag'); + if (localFlag === '0' || localFlag === 'false') return false; + if (localFlag === '1' || localFlag === 'true') return true; + // 默认常开:无需手动启动 + return true; + } catch { + return true; + } +} + +function pushConnectionDiagRecord(record: Record) { + if (typeof window === 'undefined') return; + try { + const w = window as any; + if (!Array.isArray(w.__CONN_DIAG_LOGS__)) { + w.__CONN_DIAG_LOGS__ = []; + } + w.__CONN_DIAG_LOGS__.push(record); + const max = 400; + if (w.__CONN_DIAG_LOGS__.length > max) { + w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - max); + } + } catch { + // ignore + } +} + +function connectionDiag( + level: 'log' | 'warn' | 'error', + event: string, + payload: Record = {}, + options: { force?: boolean } = {} +) { + const force = !!options.force; + const enabled = isConnectionDiagEnabled(); + if (!enabled && !force) { + return; + } + const record = { + ts: new Date().toISOString(), + event, + ...payload + }; + pushConnectionDiagRecord(record); + const logger = + level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + logger('[CONN_DIAG]', event, record); +} + function parseSubAgentDoneLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); if (!content) { @@ -1970,23 +2025,105 @@ export const uiMethods = { }, async checkConnectionHealth() { + const seq = Number(this.connectionHeartbeatSeq || 0) + 1; + this.connectionHeartbeatSeq = seq; + const requestId = `${Date.now()}-${seq}`; + const startedAt = Date.now(); + const diagEnabled = isConnectionDiagEnabled(); + const statusUrl = diagEnabled ? '/api/status?diag=1' : '/api/status'; + const wasConnected = !!this.isConnected; + let responseStatus: number | null = null; const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; try { - const response = await fetch('/api/status', { + const response = await fetch(statusUrl, { method: 'GET', cache: 'no-store', + headers: { + 'X-Connection-Heartbeat': requestId + }, signal: controller?.signal }); + responseStatus = response.status; if (!response.ok) { throw new Error(`HTTP ${response.status}`); } + const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0; this.isConnected = true; this.connectionHeartbeatFailCount = 0; + this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; + this.connectionHeartbeatLastStatusCode = responseStatus; + this.connectionHeartbeatLastError = ''; + if (!wasConnected) { + this.connectionHeartbeatLastChangeAt = Date.now(); + connectionDiag( + 'warn', + 'health-recovered', + { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + failCountBeforeRecover, + status: responseStatus, + diagEnabled, + conversationId: this.currentConversationId || null, + taskInProgress: !!this.taskInProgress, + streamingMessage: !!this.streamingMessage, + visibility: document?.visibilityState || 'unknown', + online: typeof navigator !== 'undefined' ? navigator.onLine : null + }, + { force: true } + ); + } else if ( + isConnectionDiagEnabled() && + (this.connectionHeartbeatLastLatencyMs >= 700 || seq <= 3 || seq % 60 === 0) + ) { + connectionDiag('log', 'health-ok', { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + status: responseStatus, + visibility: document?.visibilityState || 'unknown' + }); + } } catch (error) { - this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; + const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1; + this.connectionHeartbeatFailCount = nextFailCount; + this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; + this.connectionHeartbeatLastStatusCode = responseStatus; + const errName = error?.name || 'Error'; + const errMessage = error?.message || String(error); + this.connectionHeartbeatLastError = `${errName}: ${errMessage}`; // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 this.isConnected = false; + if (wasConnected) { + this.connectionHeartbeatLastChangeAt = Date.now(); + } + const shouldLogFail = wasConnected || nextFailCount <= 3 || nextFailCount % 10 === 0; + connectionDiag( + shouldLogFail ? 'warn' : 'log', + 'health-failed', + { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + failCount: nextFailCount, + status: responseStatus, + diagEnabled, + wasConnected, + nowConnected: !!this.isConnected, + errorName: errName, + errorMessage: errMessage, + timeout: errName === 'AbortError', + visibility: document?.visibilityState || 'unknown', + online: typeof navigator !== 'undefined' ? navigator.onLine : null, + conversationId: this.currentConversationId || null, + taskInProgress: !!this.taskInProgress, + streamingMessage: !!this.streamingMessage, + hasPendingTools: typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null + }, + { force: shouldLogFail } + ); } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -2000,6 +2137,15 @@ export const uiMethods = { } this.connectionHeartbeatActive = true; this.connectionHeartbeatFailCount = 0; + connectionDiag( + 'log', + 'heartbeat-start', + { + connectedIntervalMs: this.connectionHeartbeatIntervalMs, + disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs + }, + { force: true } + ); const runHeartbeat = async () => { if (!this.connectionHeartbeatActive) { return; @@ -2018,6 +2164,14 @@ export const uiMethods = { ? this.connectionHeartbeatDisconnectedIntervalMs : 1000; const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; + if (isConnectionDiagEnabled() && (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3)) { + connectionDiag('log', 'heartbeat-next', { + seq: this.connectionHeartbeatSeq, + isConnected: !!this.isConnected, + failCount: this.connectionHeartbeatFailCount, + nextInterval + }); + } this.connectionHeartbeatTimer = window.setTimeout(() => { runHeartbeat(); }, nextInterval); @@ -2033,6 +2187,7 @@ export const uiMethods = { clearTimeout(this.connectionHeartbeatTimer); this.connectionHeartbeatTimer = null; } + connectionDiag('log', 'heartbeat-stop', {}, { force: true }); }, openRealtimeTerminal() { diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 0b70be8..76b298f 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -139,6 +139,11 @@ export function dataState() { connectionHeartbeatTimer: null, connectionHeartbeatActive: false, connectionHeartbeatFailCount: 0, + connectionHeartbeatSeq: 0, + connectionHeartbeatLastLatencyMs: 0, + connectionHeartbeatLastError: '', + connectionHeartbeatLastStatusCode: null, + connectionHeartbeatLastChangeAt: 0, connectionHeartbeatIntervalMs: 8000, connectionHeartbeatDisconnectedIntervalMs: 1000, From 4e7da0de23b71a4a5516d1c9d10cfbadc8b89b60 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 5 May 2026 15:24:26 +0800 Subject: [PATCH 06/15] fix(connection): use lightweight heartbeat with single-flight and fail threshold --- server/status.py | 22 +++++++++++++++++++ static/src/app/methods/ui.ts | 42 ++++++++++++++++++++++++++++++------ static/src/app/state.ts | 3 +++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/server/status.py b/server/status.py index 644efbe..85f4ea6 100644 --- a/server/status.py +++ b/server/status.py @@ -69,6 +69,28 @@ def _is_host_mode_request() -> bool: return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host" +@status_bp.route('/api/health') +@api_login_required +def get_health(): + """轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。""" + started_at = time.perf_counter() + heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() + username = session.get("username") or "-" + payload = { + "success": True, + "status": "ok", + "server_time_ms": int(time.time() * 1000), + } + elapsed_ms = (time.perf_counter() - started_at) * 1000 + if request.args.get("diag") == "1" or elapsed_ms >= 800: + log_conn_diag( + f"health hb={heartbeat_id or '-'} user={username} " + f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} " + f"workspace_id={session.get('workspace_id') or '-'}" + ) + return jsonify(payload) + + @status_bp.route('/api/status') @api_login_required @with_terminal diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 60908ce..1dba2f6 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -2025,18 +2025,32 @@ export const uiMethods = { }, async checkConnectionHealth() { + if (this.connectionHeartbeatInFlight) { + connectionDiag('log', 'health-skip-inflight', { + seq: this.connectionHeartbeatSeq, + isConnected: !!this.isConnected, + failCount: this.connectionHeartbeatFailCount + }); + return; + } + this.connectionHeartbeatInFlight = true; const seq = Number(this.connectionHeartbeatSeq || 0) + 1; this.connectionHeartbeatSeq = seq; const requestId = `${Date.now()}-${seq}`; const startedAt = Date.now(); const diagEnabled = isConnectionDiagEnabled(); - const statusUrl = diagEnabled ? '/api/status?diag=1' : '/api/status'; + const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health'; const wasConnected = !!this.isConnected; let responseStatus: number | null = null; const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; + const timeoutMs = + typeof this.connectionHeartbeatRequestTimeoutMs === 'number' && + this.connectionHeartbeatRequestTimeoutMs > 0 + ? this.connectionHeartbeatRequestTimeoutMs + : 5000; + const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null; try { - const response = await fetch(statusUrl, { + const response = await fetch(healthUrl, { method: 'GET', cache: 'no-store', headers: { @@ -2066,6 +2080,7 @@ export const uiMethods = { failCountBeforeRecover, status: responseStatus, diagEnabled, + endpoint: healthUrl, conversationId: this.currentConversationId || null, taskInProgress: !!this.taskInProgress, streamingMessage: !!this.streamingMessage, @@ -2083,6 +2098,7 @@ export const uiMethods = { seq, elapsedMs: this.connectionHeartbeatLastLatencyMs, status: responseStatus, + endpoint: healthUrl, visibility: document?.visibilityState || 'unknown' }); } @@ -2094,12 +2110,20 @@ export const uiMethods = { const errName = error?.name || 'Error'; const errMessage = error?.message || String(error); this.connectionHeartbeatLastError = `${errName}: ${errMessage}`; - // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 - this.isConnected = false; - if (wasConnected) { + const failThreshold = + typeof this.connectionHeartbeatFailThreshold === 'number' && + this.connectionHeartbeatFailThreshold > 0 + ? this.connectionHeartbeatFailThreshold + : 3; + const shouldDisconnect = nextFailCount >= failThreshold; + // 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连” + if (shouldDisconnect) { + this.isConnected = false; + } + if (wasConnected && shouldDisconnect) { this.connectionHeartbeatLastChangeAt = Date.now(); } - const shouldLogFail = wasConnected || nextFailCount <= 3 || nextFailCount % 10 === 0; + const shouldLogFail = wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0; connectionDiag( shouldLogFail ? 'warn' : 'log', 'health-failed', @@ -2109,9 +2133,12 @@ export const uiMethods = { elapsedMs: this.connectionHeartbeatLastLatencyMs, failCount: nextFailCount, status: responseStatus, + endpoint: healthUrl, diagEnabled, wasConnected, nowConnected: !!this.isConnected, + failThreshold, + shouldDisconnect, errorName: errName, errorMessage: errMessage, timeout: errName === 'AbortError', @@ -2125,6 +2152,7 @@ export const uiMethods = { { force: shouldLogFail } ); } finally { + this.connectionHeartbeatInFlight = false; if (timeoutId) { clearTimeout(timeoutId); } diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 76b298f..d2e60b2 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -144,6 +144,9 @@ export function dataState() { connectionHeartbeatLastError: '', connectionHeartbeatLastStatusCode: null, connectionHeartbeatLastChangeAt: 0, + connectionHeartbeatInFlight: false, + connectionHeartbeatFailThreshold: 3, + connectionHeartbeatRequestTimeoutMs: 5000, connectionHeartbeatIntervalMs: 8000, connectionHeartbeatDisconnectedIntervalMs: 1000, From e58a962dcd5b4404f6d52cd34be9f9482b8b1f87 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 6 May 2026 16:53:32 +0800 Subject: [PATCH 07/15] chore(config): untrack local custom model secrets --- .gitignore | 1 + config/custom_models.json | 144 ------------------------------ config/custom_models.json.example | 6 +- 3 files changed, 4 insertions(+), 147 deletions(-) delete mode 100644 config/custom_models.json diff --git a/.gitignore b/.gitignore index b72e2b9..51dbee8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ doc/ # Host workspace config (local, use .example) config/host_workspaces.json +config/custom_models.json diff --git a/config/custom_models.json b/config/custom_models.json deleted file mode 100644 index ceee03f..0000000 --- a/config/custom_models.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "models": [ - { - "model_name": "kimi-k2.6", - "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", - "visible": true, - "url": "${API_BASE_KIMI_OFFICIAL}", - "apikey": "${API_KEY_KIMI_OFFICIAL}", - "multimodal": "image,video", - "reasoning_capability": "fast,thinking", - "context_window": 256000, - "max_output_tokens": 32768, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "kimi-k2.5", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "enable_thinking": true - } - }, - "extra_parameter": {}, - "model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。" - }, - { - "model_name": "DeepSeek-V4-Flash Max", - "description": "DeepSeek V4 Flash(reasoning_effort=max),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-flash", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "max" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。" - }, - { - "model_name": "DeepSeek-V4-Flah High", - "description": "DeepSeek V4 Flash(reasoning_effort=high),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-flash", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "high" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。" - }, - { - "model_name": "DeepSeek-V4-Pro Max", - "description": "DeepSeek V4 Pro(reasoning_effort=max),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-pro", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "max" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。" - }, - { - "model_name": "DeepSeek-V4-Pro High", - "description": "DeepSeek V4 Pro(reasoning_effort=high),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-pro", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "high" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。" - } - ] -} diff --git a/config/custom_models.json.example b/config/custom_models.json.example index ceee03f..b04e1f8 100644 --- a/config/custom_models.json.example +++ b/config/custom_models.json.example @@ -2,7 +2,7 @@ "models": [ { "model_name": "kimi-k2.6", - "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", + "description": "Kimi-k2.6", "visible": true, "url": "${API_BASE_KIMI_OFFICIAL}", "apikey": "${API_KEY_KIMI_OFFICIAL}", @@ -12,7 +12,7 @@ "max_output_tokens": 32768, "thinkmode_status": { "type": "param_toggle", - "model_id": "kimi-k2.5", + "model_id": "kimi-k2.6", "fast_extra_parameter": { "thinking": { "type": "disabled" @@ -26,7 +26,7 @@ } }, "extra_parameter": {}, - "model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。" + "model_description": "你的基础模型是 Kimi-k2.6,并通过 thinking 参数开启/关闭思考能力。" }, { "model_name": "DeepSeek-V4-Flash Max", From a86781e1265bdd42061964a74fb77b796afd34af Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 6 May 2026 16:53:50 +0800 Subject: [PATCH 08/15] feat(tools): tighten edit_file replace rules and feedback --- core/main_terminal_parts/tools_definition.py | 7 +- core/main_terminal_parts/tools_execution.py | 5 +- easyagent/src/tools/dispatcher.js | 3 +- easyagent/src/tools/edit_file.js | 65 ++++++++++++++----- modules/file_manager.py | 47 ++++++++++++-- prompts/main_system.txt | 2 +- prompts/main_system_qwenvl.txt | 2 +- scripts/api_tool_role_experiment.py | 9 ++- server/chat_flow_tool_loop.py | 19 +++++- .../components/chat/actions/ToolAction.vue | 25 +++++-- .../components/chat/actions/toolRenderers.ts | 25 +++++-- 11 files changed, 169 insertions(+), 40 deletions(-) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 82b7e9d..1e76cf1 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -540,7 +540,7 @@ class MainTerminalToolsDefinitionMixin: }, "old_string": { "type": "string", - "description": "要替换的文本(需与文件内容精确匹配,保留缩进)" + "description": "要替换的文本(需与文件内容精确匹配,保留缩进;建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" }, "new_string": { "type": "string", @@ -549,11 +549,10 @@ class MainTerminalToolsDefinitionMixin: "replace_all": { "type": "boolean", "enum": [True, False], - "description": "是否替换所有匹配内容。false=仅替换首个匹配,true=替换全部匹配。", - "default": False + "description": "是否替换所有匹配内容(必填)。false=仅替换首个匹配,true=替换全部匹配。" } }), - "required": ["file_path", "old_string", "new_string"] + "required": ["file_path", "old_string", "new_string", "replace_all"] } } }, diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index b927a11..3e3963e 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1065,11 +1065,14 @@ class MainTerminalToolsExecutionMixin: path = arguments.get("file_path") old_text = arguments.get("old_string") new_text = arguments.get("new_string") - replace_all = arguments.get("replace_all", False) + replace_all_provided = "replace_all" in arguments + replace_all = arguments.get("replace_all") if replace_all_provided else None if not path: result = {"success": False, "error": "缺少必要参数: file_path"} elif old_text is None or new_text is None: result = {"success": False, "error": "缺少必要参数: old_string/new_string"} + elif not replace_all_provided: + result = {"success": False, "error": "缺少必要参数: replace_all(必须显式传入 true 或 false)"} elif not isinstance(replace_all, bool): result = {"success": False, "error": "replace_all 必须是 true 或 false"} elif old_text == new_text: diff --git a/easyagent/src/tools/dispatcher.js b/easyagent/src/tools/dispatcher.js index 44c86ae..e6a3b57 100644 --- a/easyagent/src/tools/dispatcher.js +++ b/easyagent/src/tools/dispatcher.js @@ -46,7 +46,8 @@ function formatReadFile(result) { function formatEditFile(result) { if (!result.success) return formatFailure(result.error || '修改失败'); const count = typeof result.replacements === 'number' ? result.replacements : 0; - return `已替换 ${count} 处: ${result.path}`; + const msg = result.message ? `,${result.message}` : ''; + return `已替换 ${count} 处: ${result.path}${msg}`; } function formatWebSearch(result) { diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js index 4d3cd20..319d276 100644 --- a/easyagent/src/tools/edit_file.js +++ b/easyagent/src/tools/edit_file.js @@ -41,32 +41,43 @@ function diffSummary(oldText, newText) { return { added, removed, hunks }; } +function countLines(text) { + if (!text) return 0; + return String(text).split(/\r?\n/).length; +} + +function findMatchLineNumbers(content, target) { + if (!target) return []; + const lines = []; + let start = 0; + while (true) { + const idx = content.indexOf(target, start); + if (idx === -1) break; + const lineNo = content.slice(0, idx).split(/\r?\n/).length; + lines.push(lineNo); + start = idx + Math.max(1, target.length); + } + return lines; +} + function editFileTool(workspace, args) { const target = resolvePath(workspace, args.file_path); const oldString = args.old_string ?? ''; const newString = args.new_string ?? ''; - const replaceAll = args.replace_all ?? false; + if (!Object.prototype.hasOwnProperty.call(args || {}, 'replace_all')) { + return { success: false, error: '缺少必要参数: replace_all(必须显式传入 true 或 false)' }; + } + const replaceAll = args.replace_all; if (typeof replaceAll !== 'boolean') { return { success: false, error: 'replace_all 必须是 true 或 false' }; } + const shortOldStringNotice = countLines(oldString) < 3; const ctx = getContainerContext(); if (ctx) { const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx); if (resolved.error) return { success: false, error: resolved.error }; const rel = resolved.relativePath; - if (!oldString) { - const writeResp = execContainerAction(ctx, 'write_file', { - path: rel, - content: newString || '', - mode: 'w', - }); - if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' }; - return { - success: true, - path: resolved.containerPath, - replacements: newString ? 1 : 0, - }; - } + if (!oldString) return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; const readResp = execContainerAction(ctx, 'read_file', { path: rel }); if (!readResp.success) return { success: false, error: readResp.error || '读取失败' }; @@ -74,6 +85,8 @@ function editFileTool(workspace, args) { if (!original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } + const matchedLines = findMatchLineNumbers(original, oldString); + const foundCount = matchedLines.length; const replacements = replaceAll ? (original.split(oldString).length - 1) : 1; const updated = replaceAll ? original.split(oldString).join(newString) @@ -85,10 +98,20 @@ function editFileTool(workspace, args) { }); if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' }; const diff = diffSummary(original, updated); + const messageParts = []; + if (shortOldStringNotice) { + messageParts.push('提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行'); + } + if (foundCount > 1) { + messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}处`); + } return { success: true, path: resolved.containerPath, replacements, + found_matches: foundCount, + matched_lines: matchedLines, + message: messageParts.length > 0 ? messageParts.join(';') : undefined, diff, }; } @@ -104,12 +127,14 @@ function editFileTool(workspace, args) { return { success: false, error: '目标不是文件' }; } const original = fs.readFileSync(target, 'utf8'); - if (!creating && oldString === '') { + if (oldString === '') { return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; } if (!creating && !original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } + const matchedLines = creating ? [] : findMatchLineNumbers(original, oldString); + const foundCount = matchedLines.length; const updated = creating ? newString : (replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString)); @@ -117,10 +142,20 @@ function editFileTool(workspace, args) { if (creating && newString) replacements = 1; fs.writeFileSync(target, updated, 'utf8'); const diff = diffSummary(original, updated); + const messageParts = []; + if (shortOldStringNotice) { + messageParts.push('提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行'); + } + if (foundCount > 1) { + messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}处`); + } return { success: true, path: target, replacements, + found_matches: foundCount, + matched_lines: matchedLines, + message: messageParts.length > 0 ? messageParts.join(';') : undefined, diff, }; } catch (err) { diff --git a/modules/file_manager.py b/modules/file_manager.py index cd84f03..22f321f 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -4,6 +4,7 @@ import os import shutil from pathlib import Path import re +from bisect import bisect_right from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING from datetime import datetime try: @@ -1200,15 +1201,19 @@ class FileManager: "error": "替换的新文本过长,建议分块处理", "suggestion": "请将大内容分成多个小的替换操作" } - + short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3) + # 检查是否包含要替换的内容 if old_text and old_text not in content: return {"success": False, "error": "未找到要替换的内容"} - + + matched_lines = self._find_match_line_numbers(content, old_text) if old_text else [] + found_count = len(matched_lines) if old_text else 0 + # 替换内容 if old_text: if replace_all: - count = content.count(old_text) + count = found_count new_content = content.replace(old_text, new_text) else: count = 1 @@ -1222,9 +1227,43 @@ class FileManager: result = self.write_file(path, new_content) if result["success"]: result["replacements"] = count + message_parts: List[str] = [] + if old_text: + result["found_matches"] = found_count + result["matched_lines"] = matched_lines + if found_count > 1: + line_text = ",".join(str(line_no) for line_no in matched_lines) + message_parts.append(f"发现{found_count}处,于{line_text}行共替换{count}处") + if short_old_text_notice: + message_parts.insert( + 0, + "提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行" + ) + if message_parts: + result["message"] = ";".join(message_parts) print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容") - + return result + + @staticmethod + def _find_match_line_numbers(content: str, target: str) -> List[int]: + """返回 target 在 content 中每个匹配起始位置对应的行号(1-based)。""" + if not target: + return [] + newline_positions = [idx for idx, ch in enumerate(content) if ch == "\n"] + line_numbers: List[int] = [] + search_start = 0 + target_len = len(target) + + while True: + idx = content.find(target, search_start) + if idx < 0: + break + line_no = bisect_right(newline_positions, idx) + 1 + line_numbers.append(line_no) + search_start = idx + max(1, target_len) + + return line_numbers def clear_file(self, path: str) -> Dict: """清空文件内容""" diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 84ccc62..0ebc36f 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配) +- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false`;`old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index e41502b..6280840 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配) +- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false`;`old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/scripts/api_tool_role_experiment.py b/scripts/api_tool_role_experiment.py index 463d00a..c33ce8f 100644 --- a/scripts/api_tool_role_experiment.py +++ b/scripts/api_tool_role_experiment.py @@ -79,11 +79,14 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]: "type": "object", "properties": { "file_path": {"type": "string", "description": "目标文件的相对路径"}, - "old_string": {"type": "string", "description": "需要替换的原文"}, + "old_string": { + "type": "string", + "description": "需要替换的原文(建议至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" + }, "new_string": {"type": "string", "description": "替换后的新内容"}, - "replace_all": {"type": "boolean", "description": "是否替换全部匹配", "default": False} + "replace_all": {"type": "boolean", "description": "是否替换全部匹配(必填)"} }, - "required": ["file_path", "old_string", "new_string"] + "required": ["file_path", "old_string", "new_string", "replace_all"] } } } diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index cf13f07..94e947a 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -45,12 +45,26 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di file_path = args.get("file_path") old_string = args.get("old_string") new_string = args.get("new_string") - replace_all = args.get("replace_all", False) + replace_all_provided = "replace_all" in args + replace_all = args.get("replace_all") if replace_all_provided else None preview["file_path"] = file_path preview["replace_all"] = replace_all if not file_path: preview["summary"] = "缺少 file_path" return preview + if old_string is None or new_string is None: + preview["summary"] = "缺少 old_string/new_string" + return preview + if not replace_all_provided: + preview["summary"] = "缺少 replace_all(必须显式传入 true 或 false)" + return preview + if not isinstance(replace_all, bool): + preview["summary"] = "replace_all 必须是 true 或 false" + return preview + old_text = str(old_string or "") + short_old_text_notice = len(old_text.splitlines()) < 3 + if short_old_text_notice: + preview["notice"] = "提示:old_string 少于3行,允许继续执行;需要批量替换的场景可以单行或不足一行" try: valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path)) if not valid or full_path is None: @@ -62,7 +76,6 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di preview["summary"] = "目标文件不存在,无法生成上下文预览" return preview content = full_path.read_text(encoding="utf-8", errors="ignore") - old_text = str(old_string or "") new_text = str(new_string or "") old_lines = old_text.splitlines() new_lines = new_text.splitlines() @@ -96,6 +109,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di "old_end_line": None, } preview["summary"] = "未在文件中定位到 old_string,显示原始替换内容" + if short_old_text_notice: + preview["summary"] = f"{preview['summary']}(old_string 少于3行,已告知并继续)" except Exception as exc: preview["summary"] = f"生成编辑预览失败: {exc}" return preview diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 00785fd..11d641d 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -270,14 +270,19 @@ function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; - const isAppend = args.append || false; + const appendFlag = typeof args.append === 'boolean' ? args.append : null; + const modeRaw = String(result.mode || '').toLowerCase(); + const writeMode = + appendFlag === true || modeRaw === 'a' + ? '追加' + : appendFlag === false || modeRaw === 'w' + ? '覆盖' + : '无'; let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - if (isAppend) { - html += `
模式:追加
`; - } + html += `
模式:${writeMode}
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } @@ -299,6 +304,15 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已编辑'); + const replaceAllValue = + typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无'; + const foundCount = + typeof result.found_matches === 'number' + ? result.found_matches + : typeof result.replacements === 'number' + ? result.replacements + : null; + const replacedCount = typeof result.replacements === 'number' ? result.replacements : null; // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -310,6 +324,9 @@ function renderEditFile(result: any, args: any): string { let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; + html += `
替换全部:${replaceAllValue}
`; + html += `
找到:${foundCount ?? '无'}处
`; + html += `
替换:${replacedCount ?? '无'}处
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 51ba563..b39b26e 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -210,14 +210,19 @@ function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; - const isAppend = args.append || false; + const appendFlag = typeof args.append === 'boolean' ? args.append : null; + const modeRaw = String(result.mode || '').toLowerCase(); + const writeMode = + appendFlag === true || modeRaw === 'a' + ? '追加' + : appendFlag === false || modeRaw === 'w' + ? '覆盖' + : '无'; let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - if (isAppend) { - html += `
模式:追加
`; - } + html += `
模式:${writeMode}
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } @@ -239,6 +244,15 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已编辑'); + const replaceAllValue = + typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无'; + const foundCount = + typeof result.found_matches === 'number' + ? result.found_matches + : typeof result.replacements === 'number' + ? result.replacements + : null; + const replacedCount = typeof result.replacements === 'number' ? result.replacements : null; // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -250,6 +264,9 @@ function renderEditFile(result: any, args: any): string { let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; + html += `
替换全部:${replaceAllValue}
`; + html += `
找到:${foundCount ?? '无'}处
`; + html += `
替换:${replacedCount ?? '无'}处
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } From 66931c5caa5e3987f5797bf9524742669bcde10b Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 6 May 2026 16:54:04 +0800 Subject: [PATCH 09/15] feat(runtime): add stale-task recovery and manual stop actions --- modules/background_command_manager.py | 199 ++++++++++++++++- modules/sub_agent_manager.py | 206 ++++++++++++++---- server/chat_flow_task_main.py | 8 + server/conversation.py | 70 +++++- static/src/app/methods/taskPolling.ts | 112 +++++++++- .../overlay/BackgroundCommandDialog.vue | 44 +++- .../overlay/SubAgentActivityDialog.vue | 43 +++- static/src/stores/backgroundCommand.ts | 48 ++++ static/src/stores/subAgent.ts | 57 +++++ .../styles/components/overlays/_overlays.scss | 32 ++- .../styles/components/panels/_left-panel.scss | 10 +- 11 files changed, 778 insertions(+), 51 deletions(-) diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 66c2f29..b12548c 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -25,6 +25,7 @@ class BackgroundCommandManager: def __init__(self, project_path: str): self.project_path = Path(project_path).resolve() self._records: Dict[str, Dict[str, Any]] = {} + self._processes: Dict[str, subprocess.Popen] = {} self._lock = threading.RLock() self._cv = threading.Condition(self._lock) @@ -257,6 +258,7 @@ class BackgroundCommandManager: if rec is not None: rec["pid"] = process.pid rec["updated_at"] = time.time() + self._processes[command_id] = process def _reader(stream, collector, rec_key: str): try: @@ -338,13 +340,193 @@ class BackgroundCommandManager: with self._cv: rec = self._records.get(command_id) if rec is not None: - rec["status"] = status - rec["result"] = result - rec["truncated"] = truncated - rec["updated_at"] = time.time() - rec["finished_at"] = time.time() + existing_status = rec.get("status") + existing_result = rec.get("result") + if existing_status == "cancelled" and isinstance(existing_result, dict): + existing_output = str(existing_result.get("output") or "") + if not existing_output and combined_output: + existing_result["output"] = combined_output + rec["result"] = existing_result + rec["updated_at"] = time.time() + rec["finished_at"] = rec.get("finished_at") or time.time() + else: + rec["status"] = status + rec["result"] = result + rec["truncated"] = truncated + rec["updated_at"] = time.time() + rec["finished_at"] = time.time() + self._processes.pop(command_id, None) self._cv.notify_all() + @staticmethod + def _coerce_pid(value: Any) -> Optional[int]: + try: + pid = int(value) + return pid if pid > 0 else None + except (TypeError, ValueError): + return None + + def _is_pid_alive(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.kill(normalized, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + return True + + def _terminate_pid(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.killpg(normalized, signal.SIGINT) + except Exception: + try: + os.kill(normalized, signal.SIGINT) + except Exception: + return False + deadline = time.time() + 2.0 + while time.time() < deadline: + if not self._is_pid_alive(normalized): + return True + time.sleep(0.05) + try: + os.killpg(normalized, signal.SIGKILL) + except Exception: + try: + os.kill(normalized, signal.SIGKILL) + except Exception: + return not self._is_pid_alive(normalized) + return not self._is_pid_alive(normalized) + + @staticmethod + def _is_record_timeout_stale(rec: Dict[str, Any]) -> bool: + try: + created_at = float(rec.get("created_at") or 0) + timeout = float(rec.get("timeout") or 0) + except (TypeError, ValueError): + return False + if created_at <= 0 or timeout <= 0: + return False + return (time.time() - created_at) > (timeout + 120) + + def reconcile_stale_records(self, conversation_id: Optional[str] = None) -> int: + """兜底修正后台命令卡死的 running 状态。""" + changed = 0 + with self._lock: + for rec in self._records.values(): + if not isinstance(rec, dict): + continue + if conversation_id and rec.get("conversation_id") != conversation_id: + continue + if rec.get("status") != "running": + continue + command_id = rec.get("command_id") + process = self._processes.get(command_id) if command_id else None + pid = rec.get("pid") + stale_timeout = self._is_record_timeout_stale(rec) + if process and process.poll() is None and not stale_timeout: + continue + if (not process) and self._is_pid_alive(pid) and not stale_timeout: + continue + if stale_timeout and self._is_pid_alive(pid): + self._terminate_pid(pid) + output = self._build_current_output(rec) + message = ( + "后台指令运行超时,已自动清理运行状态。" + if stale_timeout + else "检测到后台指令进程已退出,已自动清理运行状态。" + ) + rec["status"] = "failed" + rec["result"] = { + "success": False, + "status": "failed", + "command": rec.get("command"), + "output": output, + "return_code": rec.get("result", {}).get("return_code") + if isinstance(rec.get("result"), dict) + else -1, + "truncated": bool(rec.get("truncated")), + "timeout": rec.get("timeout"), + "elapsed_ms": int(max(0.0, (time.time() - float(rec.get("created_at") or time.time())) * 1000)), + "command_id": command_id, + "run_in_background": True, + "message": message, + } + rec["updated_at"] = time.time() + rec["finished_at"] = rec.get("finished_at") or time.time() + changed += 1 + if command_id: + self._processes.pop(command_id, None) + if changed: + self._cv.notify_all() + return changed + + def cancel_command(self, command_id: str) -> Dict[str, Any]: + if not command_id: + return {"success": False, "status": "error", "error": "command_id 不能为空"} + + with self._lock: + rec = self._records.get(command_id) + if not rec: + return {"success": False, "status": "error", "error": f"未找到后台命令: {command_id}"} + status = str(rec.get("status") or "") + if status in TERMINAL_STATUSES: + payload = dict(rec.get("result") or {}) + if payload: + return payload + return { + "success": status == "completed", + "status": status, + "command_id": command_id, + "message": "后台命令已结束", + } + process = self._processes.get(command_id) + pid = rec.get("pid") + + stopped = False + if process and process.poll() is None: + stopped = self._terminate_pid(process.pid) + elif self._is_pid_alive(pid): + stopped = self._terminate_pid(pid) + else: + stopped = True + + with self._cv: + rec = self._records.get(command_id) + if not rec: + return {"success": False, "status": "error", "error": f"未找到后台命令: {command_id}"} + + output = self._build_current_output(rec) + now = time.time() + rec["status"] = "cancelled" + rec["updated_at"] = now + rec["finished_at"] = now + rec["notified"] = True + result = { + "success": False, + "status": "cancelled", + "command": rec.get("command"), + "output": output, + "return_code": None, + "truncated": bool(rec.get("truncated")), + "timeout": rec.get("timeout"), + "elapsed_ms": int(max(0.0, (now - float(rec.get("created_at") or now)) * 1000)), + "command_id": command_id, + "run_in_background": True, + "message": "后台命令已手动停止" if stopped else "后台命令停止请求已发送", + } + rec["result"] = result + self._processes.pop(command_id, None) + self._cv.notify_all() + return dict(result) + def _build_current_output(self, rec: Dict[str, Any]) -> str: output = "".join((rec.get("stdout_chunks") or []) + (rec.get("stderr_chunks") or [])) if MAX_RUN_COMMAND_CHARS and len(output) > MAX_RUN_COMMAND_CHARS: @@ -353,6 +535,7 @@ class BackgroundCommandManager: def wait_for_completion(self, command_id: str, timeout_seconds: Optional[float] = None, claim: bool = False) -> Dict[str, Any]: """阻塞等待后台命令完成。""" + self.reconcile_stale_records() with self._cv: rec = self._records.get(command_id) if not rec: @@ -400,6 +583,7 @@ class BackgroundCommandManager: def poll_updates(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: """获取未通知且未被 sleep 领取的已完成任务。""" updates: List[Dict[str, Any]] = [] + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if conversation_id and rec.get("conversation_id") != conversation_id: @@ -430,6 +614,7 @@ class BackgroundCommandManager: rec["updated_at"] = time.time() def get_record(self, command_id: str) -> Optional[Dict[str, Any]]: + self.reconcile_stale_records() with self._lock: rec = self._records.get(command_id) if not rec: @@ -438,6 +623,7 @@ class BackgroundCommandManager: def get_record_with_output(self, command_id: str) -> Optional[Dict[str, Any]]: """获取单条后台命令记录,并附带当前可读输出。""" + self.reconcile_stale_records() with self._lock: rec = self._records.get(command_id) if not rec: @@ -453,6 +639,7 @@ class BackgroundCommandManager: limit: int = 200, ) -> List[Dict[str, Any]]: """列出后台命令记录(按创建时间倒序)。""" + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: items: List[Dict[str, Any]] = [] for rec in self._records.values(): @@ -468,6 +655,7 @@ class BackgroundCommandManager: def has_pending_for_conversation(self, conversation_id: Optional[str]) -> bool: if not conversation_id: return False + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if rec.get("conversation_id") != conversation_id: @@ -482,6 +670,7 @@ class BackgroundCommandManager: items: List[Dict[str, Any]] = [] if not conversation_id: return items + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if rec.get("conversation_id") != conversation_id: diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 977e248..488d7bb 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -6,6 +6,7 @@ import time import uuid import os import shutil +import signal from pathlib import Path, PurePosixPath from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING @@ -62,6 +63,10 @@ class SubAgentManager: self.conversation_agents: Dict[str, List[int]] = {} self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象 self._load_state() + try: + self.reconcile_task_states() + except Exception: + pass # ------------------------------------------------------------------ # 公共方法 @@ -267,6 +272,7 @@ class SubAgentManager: task_id = task["task_id"] process = self.processes.get(task_id) + pid = task.get("pid") if process and process.poll() is None: # 进程还在运行,终止它 @@ -279,17 +285,17 @@ class SubAgentManager: process.wait() except Exception as exc: return {"success": False, "error": f"终止进程失败: {exc}"} + elif self._is_pid_alive(pid): + if not self._terminate_pid(pid): + return {"success": False, "error": f"终止进程失败: PID {pid} 无法停止"} - task["status"] = "terminated" - task["updated_at"] = time.time() - task["notified"] = True - task["final_result"] = { - "success": False, - "status": "terminated", - "task_id": task_id, - "agent_id": task.get("agent_id"), - "message": "子智能体已被强制关闭。", - } + self.processes.pop(task_id, None) + self._mark_task_terminated( + task, + message="子智能体已被强制关闭。", + system_message=f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。", + notified=True, + ) self._save_state() return { @@ -662,6 +668,155 @@ class SubAgentManager: if migrated: self._save_state() + @staticmethod + def _coerce_pid(value: Any) -> Optional[int]: + try: + pid = int(value) + return pid if pid > 0 else None + except (TypeError, ValueError): + return None + + def _is_pid_alive(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.kill(normalized, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + return True + + def _terminate_pid(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.killpg(normalized, signal.SIGTERM) + except Exception: + try: + os.kill(normalized, signal.SIGTERM) + except Exception: + return False + deadline = time.time() + 5 + while time.time() < deadline: + if not self._is_pid_alive(normalized): + return True + time.sleep(0.1) + try: + os.killpg(normalized, signal.SIGKILL) + except Exception: + try: + os.kill(normalized, signal.SIGKILL) + except Exception: + return not self._is_pid_alive(normalized) + return not self._is_pid_alive(normalized) + + def _mark_task_terminated( + self, + task: Dict[str, Any], + *, + message: str, + system_message: Optional[str] = None, + notified: bool = False, + ) -> Dict[str, Any]: + task["status"] = "terminated" + task["updated_at"] = time.time() + if notified: + task["notified"] = True + result = { + "success": False, + "status": "terminated", + "task_id": task.get("task_id"), + "agent_id": task.get("agent_id"), + "message": message, + "system_message": system_message or message, + } + task["final_result"] = result + return result + + def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool: + try: + created_at = float(task.get("created_at") or 0) + except (TypeError, ValueError): + created_at = 0 + if created_at <= 0: + return False + timeout_seconds = int(task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT or 0) + timeout_seconds = max(timeout_seconds, 1) + grace_seconds = 120 + elapsed = time.time() - created_at + if elapsed <= (timeout_seconds + grace_seconds): + return False + output_file = Path(task.get("output_file", "")) + if output_file.exists(): + return False + progress_file = Path(task.get("progress_file", "")) + if progress_file.exists(): + try: + stale_span = time.time() - progress_file.stat().st_mtime + if stale_span <= grace_seconds: + return False + except Exception: + pass + return True + + def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]: + """刷新单个任务运行态(用于进程句柄丢失/重启后的兜底修正)。""" + status = task.get("status") + if status in TERMINAL_STATUSES.union({"terminated"}): + return {"status": status, "task_id": task.get("task_id")} + + task_id = task.get("task_id") + process = self.processes.get(task_id) if task_id else None + if process: + poll_result = process.poll() + if poll_result is None: + return {"status": "running", "task_id": task_id} + return self._check_task_status(task) + + output_file = Path(task.get("output_file", "")) + if output_file.exists(): + return self._check_task_status(task) + + pid = task.get("pid") + if self._is_pid_alive(pid): + if self._should_force_cleanup_stale_task(task): + return self._mark_task_terminated( + task, + message="子智能体疑似僵尸任务,已超时自动清理运行状态。", + system_message="⚠️ 子智能体长时间未结束,系统已自动清理运行状态。", + notified=True, + ) + return {"status": "running", "task_id": task_id} + + return self._mark_task_terminated( + task, + message="检测到子智能体进程已退出,已自动清理运行状态。", + system_message="⚠️ 子智能体进程异常退出,系统已自动清理运行状态。", + notified=True, + ) + + def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: + """修正运行态任务状态,返回修正条目数。""" + changed = 0 + for task in self.tasks.values(): + if not isinstance(task, dict): + continue + if conversation_id and task.get("conversation_id") != conversation_id: + continue + before_status = task.get("status") + before_notified = task.get("notified") + self._refresh_task_runtime_state(task) + if task.get("status") != before_status or task.get("notified") != before_notified: + changed += 1 + if changed: + self._save_state() + return changed + def _save_state(self): payload = { "tasks": self.tasks, @@ -674,6 +829,7 @@ class SubAgentManager: return f"sub_{agent_id}_{int(time.time())}_{suffix}" def _active_task_count(self, conversation_id: Optional[str] = None) -> int: + self.reconcile_task_states(conversation_id=conversation_id) active = [ t for t in self.tasks.values() if t.get("status") in {"pending", "running"} @@ -729,6 +885,7 @@ class SubAgentManager: return candidate def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]: + self.reconcile_task_states() if task_id: return self.tasks.get(task_id) @@ -761,6 +918,7 @@ class SubAgentManager: def poll_updates(self) -> List[Dict]: """检查运行中的子智能体任务,返回新完成的结果。""" updates: List[Dict] = [] + self.reconcile_task_states() pending_tasks = [ task for task in self.tasks.values() if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) @@ -1082,8 +1240,8 @@ class SubAgentManager: def get_overview(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: """返回子智能体任务概览,用于前端展示。""" + self.reconcile_task_states(conversation_id=conversation_id) overview: List[Dict[str, Any]] = [] - state_changed = False for task_id, task in self.tasks.items(): if conversation_id and task.get("conversation_id") != conversation_id: continue @@ -1103,30 +1261,6 @@ class SubAgentManager: "sub_conversation_id": task.get("sub_conversation_id"), } - # 运行中的任务检查进程状态 - if snapshot["status"] not in TERMINAL_STATUSES and snapshot["status"] != "terminated": - # 检查进程是否还在运行 - process = self.processes.get(task_id) - if process: - poll_result = process.poll() - if poll_result is not None: - # 进程已结束,检查输出 - status_result = self._check_task_status(task) - snapshot["status"] = status_result.get("status", "failed") - if status_result.get("status") in TERMINAL_STATUSES: - task["status"] = status_result["status"] - task["final_result"] = status_result - state_changed = True - else: - # 进程句柄丢失(重启后常见),尝试直接检查输出文件 - logger.debug("[SubAgentManager] 进程句柄缺失,尝试读取输出文件: %s", task_id) - status_result = self._check_task_status(task) - snapshot["status"] = status_result.get("status", snapshot["status"]) - if status_result.get("status") in TERMINAL_STATUSES: - task["status"] = status_result["status"] - task["final_result"] = status_result - state_changed = True - if snapshot["status"] in TERMINAL_STATUSES or snapshot["status"] == "terminated": # 已结束的任务带上最终结果/系统消息,方便前端展示 final_result = task.get("final_result") or {} @@ -1136,6 +1270,4 @@ class SubAgentManager: overview.append(snapshot) overview.sort(key=lambda item: item.get("created_at") or 0, reverse=True) - if state_changed: - self._save_state() return overview diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 5ffeff1..6a743ff 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -1350,6 +1350,10 @@ async def handle_task_with_sender( bg_manager = getattr(web_terminal, "background_command_manager", None) has_running_background_commands = False if manager: + try: + manager.reconcile_task_states(conversation_id=conversation_id) + except Exception as exc: + debug_log(f"[SubAgent] reconcile_task_states failed: {exc}") if not hasattr(web_terminal, "_announced_sub_agent_tasks"): web_terminal._announced_sub_agent_tasks = set() running_tasks = [ @@ -1396,6 +1400,10 @@ async def handle_task_with_sender( # 检查是否有后台 run_command 或待通知任务 if bg_manager and conversation_id: + try: + bg_manager.reconcile_stale_records(conversation_id=conversation_id) + except Exception as exc: + debug_log(f"[BgCmdDebug] reconcile_stale_records failed: {exc}") waiting_items = bg_manager.list_waiting_items(conversation_id) if waiting_items: has_running_background_commands = True diff --git a/server/conversation.py b/server/conversation.py index ea174f9..9f5b135 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -97,6 +97,10 @@ def _terminate_running_sub_agents(terminal: WebTerminal, reason: str = "") -> in current_conv_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) if not current_conv_id: return 0 + try: + manager.reconcile_task_states(conversation_id=current_conv_id) + except Exception: + pass running_tasks = [ task for task in manager.tasks.values() if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) @@ -1166,6 +1170,37 @@ def get_sub_agent_activity(task_id: str, terminal: WebTerminal, workspace: UserW return jsonify({"success": False, "error": str(exc)}), 500 +@conversation_bp.route('/api/sub_agents//terminate', methods=['POST']) +@api_login_required +@with_terminal +def terminate_sub_agent(task_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str): + """手动停止指定子智能体。""" + manager = getattr(terminal, "sub_agent_manager", None) + if not manager: + return jsonify({"success": False, "error": "子智能体管理器不可用"}), 404 + try: + try: + manager._load_state() + except Exception: + pass + + task = manager.tasks.get(task_id) + if not task: + return jsonify({"success": False, "error": "未找到对应子智能体任务"}), 404 + + current_conv_id = terminal.context_manager.current_conversation_id + task_conv_id = task.get("conversation_id") + if current_conv_id and task_conv_id and task_conv_id != current_conv_id: + return jsonify({"success": False, "error": "无权限停止该子智能体任务"}), 403 + + result = manager.terminate_sub_agent(task_id=task_id) + if not result.get("success"): + return jsonify({"success": False, "error": result.get("error") or "停止子智能体失败"}), 400 + return jsonify({"success": True, "data": result}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/background_commands', methods=['GET']) @api_login_required @with_terminal @@ -1184,11 +1219,18 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us records = manager.list_records(conversation_id=conversation_id, limit=limit_num) data = [] + terminal_statuses = {"completed", "failed", "timeout", "cancelled"} for rec in records: result = rec.get("result") if isinstance(rec.get("result"), dict) else {} + status = rec.get("status") + notice_pending = bool( + status in terminal_statuses + and not rec.get("notified") + and not rec.get("claimed_by_sleep") + ) data.append({ "command_id": rec.get("command_id"), - "status": rec.get("status"), + "status": status, "command": rec.get("command"), "conversation_id": rec.get("conversation_id"), "created_at": rec.get("created_at"), @@ -1197,6 +1239,7 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us "timeout": rec.get("timeout"), "return_code": result.get("return_code"), "run_in_background": True, + "notice_pending": notice_pending, }) return jsonify({"success": True, "data": data}) except Exception as exc: @@ -1241,6 +1284,31 @@ def get_background_command_detail(command_id: str, terminal: WebTerminal, worksp return jsonify({"success": False, "error": str(exc)}), 500 +@conversation_bp.route('/api/background_commands//cancel', methods=['POST']) +@api_login_required +@with_terminal +def cancel_background_command(command_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str): + """手动停止指定后台 run_command。""" + manager = getattr(terminal, "background_command_manager", None) + if not manager: + return jsonify({"success": False, "error": "后台命令管理器不可用"}), 404 + try: + rec = manager.get_record(command_id) + if not rec: + return jsonify({"success": False, "error": "未找到对应后台命令"}), 404 + current_conv_id = terminal.context_manager.current_conversation_id + rec_conv_id = rec.get("conversation_id") + if current_conv_id and rec_conv_id and rec_conv_id != current_conv_id: + return jsonify({"success": False, "error": "无权限停止该后台命令"}), 403 + + result = manager.cancel_command(command_id) + if not result.get("status"): + return jsonify({"success": False, "error": result.get("error") or "停止后台命令失败"}), 400 + return jsonify({"success": True, "data": result}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/conversations//duplicate', methods=['POST']) @api_login_required @with_terminal diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 3fa3f45..2856b58 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -146,6 +146,66 @@ export const taskPollingMethods = { clearInterval(this.waitingTaskProbeTimer); this.waitingTaskProbeTimer = null; } + this._waitingProbeStableEmptyCount = 0; + }, + + async inspectWaitingCompletionState() { + const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled']); + let hasSubAgentPending = false; + let hasBackgroundPending = false; + let resolved = true; + + try { + const [subResp, bgResp] = await Promise.all([ + fetch('/api/sub_agents'), + fetch('/api/background_commands?limit=200') + ]); + + if (subResp.ok) { + const subResult = await subResp.json().catch(() => ({})); + const tasks = Array.isArray(subResult?.data) ? subResult.data : []; + const relevantTasks = tasks.filter((task: any) => { + if (!task) return false; + if (task.conversation_id && task.conversation_id !== this.currentConversationId) return false; + return true; + }); + hasSubAgentPending = relevantTasks.some((task: any) => { + if (!task?.run_in_background) return false; + const status = String(task?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!task?.notice_pending; + }); + } else { + resolved = false; + } + + if (bgResp.ok) { + const bgResult = await bgResp.json().catch(() => ({})); + const commands = Array.isArray(bgResult?.data) ? bgResult.data : []; + hasBackgroundPending = commands.some((command: any) => { + if (!command) return false; + const status = String(command?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!command?.notice_pending; + }); + } else { + resolved = false; + } + } catch (error) { + debugNotifyLog('[DEBUG_NOTIFY][ui] inspectWaitingCompletionState:error', { + error: error?.message || String(error) + }); + return { + resolved: false, + hasPending: true, + hasBackgroundPending: this.waitingForBackgroundCommand + }; + } + + return { + resolved, + hasPending: + !resolved || hasSubAgentPending || hasBackgroundPending, + hasBackgroundPending + }; }, async tryResumeWaitingNoticeTask() { @@ -239,6 +299,30 @@ export const taskPollingMethods = { debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop'); keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop'); this.stopWaitingTaskProbe(); + return; + } + + const state = await this.inspectWaitingCompletionState(); + if (state.hasPending) { + this._waitingProbeStableEmptyCount = 0; + this.waitingForBackgroundCommand = !!state.hasBackgroundPending; + return; + } + + this._waitingProbeStableEmptyCount = (this._waitingProbeStableEmptyCount || 0) + 1; + if (this._waitingProbeStableEmptyCount >= 2) { + debugLog('[TaskPolling] 等待态兜底清理:未发现运行中的后台子智能体/后台指令'); + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + if (typeof this.clearPendingTools === 'function') { + this.clearPendingTools('waiting_probe_auto_clear'); + } + this.clearTaskState(); + this.stopWaitingTaskProbe(); + this.$forceUpdate(); } }, 1000); }, @@ -2052,11 +2136,27 @@ export const taskPollingMethods = { (task: any) => task?.run_in_background && !terminalStatuses.has(task?.status) ); const pendingNotice = relevant.filter((task: any) => task?.notice_pending); + let backgroundPending = false; + try { + const bgResp = await fetch('/api/background_commands?limit=200'); + if (bgResp.ok) { + const bgResult = await bgResp.json().catch(() => ({})); + const bgCommands = Array.isArray(bgResult?.data) ? bgResult.data : []; + backgroundPending = bgCommands.some((command: any) => { + if (!command) return false; + const status = String(command?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!command?.notice_pending; + }); + } + } catch (bgErr) { + console.warn('[TaskPolling] 获取后台指令状态失败:', bgErr); + } - if (running.length || pendingNotice.length) { + if (running.length || pendingNotice.length || backgroundPending) { debugLog('[TaskPolling] 恢复子智能体等待状态', { running: running.length, pendingNotice: pendingNotice.length, + backgroundPending, runningTasks: running.map((task: any) => ({ task_id: task?.task_id, status: task?.status @@ -2067,12 +2167,20 @@ export const taskPollingMethods = { })) }); this.waitingForSubAgent = true; - this.waitingForBackgroundCommand = false; + this.waitingForBackgroundCommand = backgroundPending; this.taskInProgress = true; this.streamingMessage = false; this.stopRequested = false; this.startWaitingTaskProbe(); this.$forceUpdate(); + } else { + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + this.stopWaitingTaskProbe(); + if (!this.streamingMessage) { + this.taskInProgress = false; + } + this.$forceUpdate(); } } catch (error) { console.error('[TaskPolling] 恢复子智能体等待状态失败:', error); diff --git a/static/src/components/overlay/BackgroundCommandDialog.vue b/static/src/components/overlay/BackgroundCommandDialog.vue index e7ad277..d6d1605 100644 --- a/static/src/components/overlay/BackgroundCommandDialog.vue +++ b/static/src/components/overlay/BackgroundCommandDialog.vue @@ -20,6 +20,17 @@ {{ activeDetail?.command || activeCommand.command }}
+
+ + {{ stopError }} +
{{ detailError }}
@@ -33,17 +44,46 @@ @@ -503,5 +929,4 @@ onMounted(() => { .image-remove-btn-hover:hover { color: #ef4444; } - diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index 75b1296..cad2e68 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -49,7 +49,8 @@ export const useTaskStore = defineStore('task', { pollingFailThreshold: 8, pollingIntervalMs: 250, taskCreatedAt: null as number | null, - taskUpdatedAt: null as number | null + taskUpdatedAt: null as number | null, + runtimeQueueSnapshotKey: '' }), getters: { @@ -104,6 +105,7 @@ export const useTaskStore = defineStore('task', { this.taskStatus = result.data.status; this.lastEventIndex = 0; this.taskCreatedAt = result.data.created_at; + this.runtimeQueueSnapshotKey = ''; this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; @@ -189,6 +191,30 @@ export const useTaskStore = defineStore('task', { // 更新任务状态 this.taskStatus = data.status; this.taskUpdatedAt = data.updated_at; + const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages) + ? data.runtime_queued_messages + : []; + const runtimeQueueSnapshotKey = JSON.stringify( + runtimeQueueMessages.map((item: any) => [ + String(item?.id || ''), + String(item?.text || '') + ]) + ); + if (runtimeQueueSnapshotKey !== this.runtimeQueueSnapshotKey) { + this.runtimeQueueSnapshotKey = runtimeQueueSnapshotKey; + try { + eventHandler({ + type: 'runtime_queue_sync', + data: { + task_id: data?.task_id || this.currentTaskId, + conversation_id: data?.conversation_id || null, + messages: runtimeQueueMessages + } + }); + } catch (err) { + console.warn('[Task] 处理 runtime_queue_sync 失败:', err); + } + } let sawTaskCompleteEvent = false; let sawTaskStoppedEvent = false; @@ -434,6 +460,7 @@ export const useTaskStore = defineStore('task', { this.lastEventIndex = 0; } this.pollingError = null; + this.runtimeQueueSnapshotKey = ''; this.startPolling(options.eventHandler); }, @@ -453,6 +480,7 @@ export const useTaskStore = defineStore('task', { this.isPolling = false; this.pollingInFlight = false; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; this.currentTaskId = null; // 清除任务 ID }, @@ -515,6 +543,7 @@ export const useTaskStore = defineStore('task', { this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; // 获取任务详情,计算已处理的事件数量 try { @@ -559,6 +588,7 @@ export const useTaskStore = defineStore('task', { this.pollingWarned = false; this.taskCreatedAt = null; this.taskUpdatedAt = null; + this.runtimeQueueSnapshotKey = ''; }, resetForNewConversation() { @@ -570,6 +600,7 @@ export const useTaskStore = defineStore('task', { this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; } } }); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index f11f6e3..75b7146 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1,7 +1,9 @@ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ .chat-container { --chat-surface-color: var(--theme-surface-strong, #ffffff); - --composer-reserved-height: calc(75px + var(--app-bottom-inset, 0px)); + --composer-base-height: calc(80px + var(--app-bottom-inset, 0px)); + --composer-reserved-height: var(--composer-base-height); + --composer-growth-height: 0px; flex: 1; display: flex; flex-direction: column; @@ -75,7 +77,7 @@ overflow-anchor: none; padding: 24px; padding-top: 30px; - padding-bottom: calc(20px + var(--app-bottom-inset, 0px)); + padding-bottom: calc(20px + var(--composer-growth-height, 0px)); min-height: 0; position: relative; } @@ -152,7 +154,8 @@ @media (max-width: 768px) { .chat-container { - --composer-reserved-height: calc(96px + var(--app-bottom-inset, 0px)); + --composer-base-height: calc(80px + var(--app-bottom-inset, 0px)); + --composer-reserved-height: var(--composer-base-height); } .chat-container--mobile { diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index bb8555d..8eedab8 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -24,6 +24,115 @@ pointer-events: auto; } +.runtime-queue-list { + --runtime-queue-bg: var(--theme-surface-soft); + --runtime-queue-divider: rgba(15, 23, 42, 0.12); + position: absolute; + left: 50%; + bottom: calc(100% - 2px); + transform: translateX(-50%); + z-index: 1; + display: flex; + flex-direction: column; + width: 90%; + max-width: 90%; + margin: 0; + overflow: visible; + border: none; + background: transparent; + box-shadow: none; + pointer-events: auto; +} + +.runtime-queue-list--empty { + height: 0; + border: none; + box-shadow: none; + background: transparent; + margin: 0; + overflow: visible; + pointer-events: none; +} + +.runtime-queue-item { + position: relative; + z-index: var(--runtime-queue-z, 1); + min-height: 34px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 6px 10px 6px 12px; + border: none; + border-radius: 0; + background: var(--runtime-queue-bg); +} + +.runtime-queue-item--top { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +.runtime-queue-item__text { + min-width: 0; + font-size: 13px; + line-height: 1.3; + color: var(--claude-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.runtime-queue-item__action { + border: none; + background: transparent; + color: var(--claude-text-secondary); + font-size: 12px; + line-height: 1.2; + padding: 3px 4px; + border-radius: 6px; + cursor: pointer; +} + +.runtime-queue-item__action:hover { + background: var(--theme-chip-bg); + color: var(--claude-text); +} + +.runtime-queue-item__action--danger:hover { + color: #b91c1c; + background: rgba(185, 28, 28, 0.12); +} + +.runtime-queue-item + .runtime-queue-item::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 1px; + background: var(--runtime-queue-divider); + pointer-events: none; +} + +body[data-theme='dark'] { + .runtime-queue-list { + --runtime-queue-bg: #2a2a2a; + --runtime-queue-divider: rgba(255, 255, 255, 0.12); + background: transparent; + } + + .runtime-queue-item { + background: var(--runtime-queue-bg); + } + + .runtime-queue-list.runtime-queue-list--empty { + background: transparent; + border: none; + box-shadow: none; + } +} + .permission-switcher { position: absolute; left: 20px; @@ -222,6 +331,7 @@ .stadium-shell { --stadium-radius: 24px; position: relative; + z-index: 2; width: 100%; min-height: calc(var(--stadium-radius) * 2.1); padding: 12px 18px; @@ -545,8 +655,8 @@ .composer-container { position: relative; - height: var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); - flex: 0 0 var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); + height: var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px))); + flex: 0 0 var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px))); transition: transform 0.3s ease; z-index: 2; } From b4f2c8f143ad57404bc6540f1a1bb02927b53186 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 9 May 2026 17:17:58 +0800 Subject: [PATCH 15/15] fix(prompt): merge disabled-tool notice into leading system prompts --- core/main_terminal.py | 2 +- core/main_terminal_parts/context.py | 15 ++++++++------- modules/personalization_manager.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/core/main_terminal.py b/core/main_terminal.py index 67cc66b..6cfe589 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -140,7 +140,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi ) self.easter_egg_manager = EasterEggManager() self._announced_sub_agent_tasks = set() - self.silent_tool_disable = False # 是否静默工具禁用提示 + self.silent_tool_disable = True # 是否静默工具禁用提示(默认开启) self.current_session_id = 0 # 用于标识不同的任务会话 # 工具类别(可被管理员动态覆盖) self.tool_categories_map = dict(TOOL_CATEGORIES) diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index a2d5688..2980d87 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -292,6 +292,14 @@ class MainTerminalContextMixin: if isinstance(custom_system_prompt, str) and custom_system_prompt.strip(): messages.append({"role": "system", "content": custom_system_prompt.strip()}) + # 禁用工具提示也作为“开头 system prompt”注入,便于后续统一合并。 + disabled_notice = self._format_disabled_tool_notice() + if disabled_notice: + messages.append({ + "role": "system", + "content": disabled_notice + }) + # 添加对话历史(保留完整结构,包括tool_calls和tool消息) conversation = context["conversation"] replaced_tool_count = 0 @@ -377,13 +385,6 @@ class MainTerminalContextMixin: }) # 当前用户输入已经在conversation中了,不需要重复添加 - - disabled_notice = self._format_disabled_tool_notice() - if disabled_notice: - messages.append({ - "role": "system", - "content": disabled_notice - }) if shallow_replace_enabled: print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条") return messages diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index bd16232..337bec2 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -75,7 +75,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "shallow_compress_trigger_tool_calls_interval": None, "shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩(默认3) "deep_compress_trigger_tokens": None, - "silent_tool_disable": False, # 禁用工具时不向模型插入提示 + "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "enhanced_tool_display": True, # 增强工具显示 "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "agents_md_auto_inject": False, # AGENTS.md 自动注入开关