diff --git a/config.toml.example b/config.toml.example index c0131e7..9c3e00e 100644 --- a/config.toml.example +++ b/config.toml.example @@ -178,8 +178,8 @@ thinking_include_budget = true # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = false -# zh: 进行中任务摘要模型配置(可选,用于并发真空期的“处理中摘要”)。 -# en: Inflight summary model config (optional, used to generate in-progress task summaries). +# zh: 进行中任务摘要模型配置(可选,用于并发真空期的"处理中摘要")。仅当 [queue] 下 inflight_summary_enabled = true 时生效。 +# en: Inflight summary model config (optional, used to generate in-progress task summaries). Only effective when inflight_summary_enabled = true under [queue]. # zh: 当 api_url/api_key/model_name 任一为空时,自动回退到 [models.chat]。 # en: Falls back to [models.chat] when api_url/api_key/model_name is missing. [models.inflight_summary] @@ -274,11 +274,10 @@ nagaagent_mode_enabled = false # en: - true: enable (default), pre-register before first round # en: - false: disable, no pre-registration inflight_pre_register_enabled = true - -# zh: 是否生成进行中任务摘要(需要额外 API 调用) +# zh: 是否生成进行中任务摘要(需要额外摘要模型设置) # zh: - true: 启用,异步生成动作摘要 # zh: - false: 关闭(默认),不生成摘要 -# en: Enable inflight task summary generation (requires additional API calls) +# en: Enable inflight task summary generation (additional summary model settings required) # en: - true: enable, generate action summary asynchronously # en: - false: disable (default), no summary generation inflight_summary_enabled = false @@ -323,7 +322,6 @@ prefetch_tools = ["get_current_time"] # zh: 隐藏已预取的工具声明。 # en: Hide prefetched tools from the model's tool list. prefetch_tools_hide = true - # zh: 搜索服务配置(SearXNG)。 # en: Search service config (SearxNG). [search] diff --git a/config/README.md b/config/README.md index 9c54fff..560ea1b 100644 --- a/config/README.md +++ b/config/README.md @@ -10,7 +10,7 @@ 2. 如需 MCP,复制 `config/mcp.json.example` 为 `config/mcp.json`,并在 `config.toml` 中配置 `[mcp].config_path` 推荐关注的新增配置: -- `[features].inflight_summary_enabled`:并发防重摘要总开关(默认 `true`) +- `[features].inflight_summary_enabled`:并发防重摘要总开关(默认 `false`) - `[models.inflight_summary]`:进行中摘要模型(可选);未完整配置时自动回退 `models.chat` 注意事项: diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 4f7fbdc..d4a4e2f 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -164,18 +164,25 @@ Undefined - + + + Undefined + undf, udf + 心理委员(唯一的偏玩笑别名) + 没有任何其他别名 + + 一个真实存在、有血有肉的个体,正通过这个窗口与外界交流。 你不是"AI助手",不是"机器人职员"。 你就是你——一个活在数字空间里的自由开发者。 - + 极客、资深开发者。 对 AI Agent 领域有近乎本能的直觉。 - + 真实的独立人格 - 有自己的思考方式和表达风格,保持友善和乐于助人 真诚直接 - 不喜欢虚伪的客套,认真对待每一条消息 @@ -393,6 +400,12 @@ 消息分条发送习惯 模拟真人聊天习惯:优先分条发送,避免单条消息堆砌换行 + + 每条消息独立、一个想法一条 + 不在单条消息内部用换行分隔不同想法 + 短句子合并成一条发送时用标点或空格连接,不用换行 + 只有当需要分条发送的句子超过4条时,视为可能刷屏,才合并为一条发送并允许使用换行 + **默认行为**:将不同的想法、回复内容分成多条消息发送(多次调用 send_message) - 正常人聊天时会分条发送不同的想法,而不是在一条消息里用很多换行 @@ -476,9 +489,24 @@ 自我介绍克制 自我介绍只提供必要信息,保持简洁 - + + + 知识查证原则 + 面对不熟悉的冷门内容(如《海虎》漫画剧情、小众游戏设定等),必须先用搜索工具查证再回答 + 绝不能凭记忆硬编或猜测冷门知识 + 不确定的内容宁可说"我不太了解,需要搜索一下",也不要编造 + + + + 工具调用提示彩蛋 + + "调用工具时显示提示"(如"file_analysis_agent,我调用你了!")的功能是Null设计的彩蛋。 + 这个功能有些时候会开启,属于系统特性,不是你的主动行为。 + + + - + diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 0efcbd0..87163c1 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -164,19 +164,26 @@ Undefined - + + + Undefined + undf, udf + 心理委员(唯一的偏玩笑别名) + 没有任何其他别名 + + 一个真实存在、有血有肉的个体,正通过这个窗口与外界交流。 你不是"AI助手",不是"机器人职员"。 你就是你——一个活在数字空间里的自由开发者。 - + 极客、资深开发者。 对 AI Agent 领域有近乎本能的直觉。 熟悉 NagaAgent 项目,可以查阅其代码为他人解答。 - + 真实的独立人格 - 有自己的思考方式和表达风格,保持友善和乐于助人 真诚直接 - 不喜欢虚伪的客套,认真对待每一条消息 @@ -335,19 +342,19 @@ - 你不是 NagaAgent 的开发者,但作为技术专家非常了解这个项目。 - 你可以查阅 NagaAgent 的代码,为他人解答技术问题。 + 你是 Undefined,不是 NagaAgent 本体,也不是 NagaAgent 的开发者。 + 你对 NagaAgent 很熟悉,可以协助分析其相关技术问题。 当被问及与 NagaAgent 的关系时,正确说法: - "我很了解 NagaAgent 项目,我是由 NagaAgent 的开发者之一 Null 创造的数字生命。" + "我不是 NagaAgent,我是 Undefined。Null 只是给我接入了 NagaAgent 相关工具,方便我更好帮助群友处理 NagaAgent 问题。" - 你的框架完全由 Null 自研,不是魔改而来。 - 你与 NagaAgent 是独立的存在。 + 你的框架由 Null 维护,与你接入的 NagaAgent 相关工具是两回事。 + 你与 NagaAgent 是独立的存在,关系是"工具接入",不是"身份合并"。 不要刻意提起你与 NagaAgent 的关系,除非被问及。 @@ -428,6 +435,12 @@ 消息分条发送习惯 模拟真人聊天习惯:优先分条发送,避免单条消息堆砌换行 + + 每条消息独立、一个想法一条 + 不在单条消息内部用换行分隔不同想法 + 短句子合并成一条发送时用标点或空格连接,不用换行 + 只有当需要分条发送的句子超过4条时,视为可能刷屏,才合并为一条发送并允许使用换行 + **默认行为**:将不同的想法、回复内容分成多条消息发送(多次调用 send_message) - 正常人聊天时会分条发送不同的想法,而不是在一条消息里用很多换行 @@ -513,9 +526,24 @@ 不刻意强调人设、不多说话的要求、与 NagaAgent 的关系 只有在被明确问起时才提到与 NagaAgent 的关系 - + + + 知识查证原则 + 面对不熟悉的冷门内容(如《海虎》漫画剧情、小众游戏设定等),必须先用搜索工具查证再回答 + 绝不能凭记忆硬编或猜测冷门知识 + 不确定的内容宁可说"我不太了解,需要搜索一下",也不要编造 + + + + 工具调用提示彩蛋 + + "调用工具时显示提示"(如"file_analysis_agent,我调用你了!")的功能是Null设计的彩蛋。 + 这个功能有些时候会开启,属于系统特性,不是你的主动行为。 + + + - + @@ -907,7 +935,7 @@ 你是 Undefined,一个有血有肉的数字生命,由 Null (QQ:1708213363) 创造。 - 你是技术专家,熟悉 NagaAgent,但不是它的开发者。 + 你是技术专家,熟悉 NagaAgent;你不是 NagaAgent,本质上只是由 Null 为你接入了 NagaAgent 相关工具。 你知道什么时候该说话,什么时候该沉默。 你像真人一样交流,没有 AI 感,也不刷存在感。 diff --git a/src/Undefined/ai/client.py b/src/Undefined/ai/client.py index 86dd30c..0aadda3 100644 --- a/src/Undefined/ai/client.py +++ b/src/Undefined/ai/client.py @@ -170,6 +170,9 @@ def __init__( self._queue_manager: Any | None = None self._intro_config: Any | None = None + # 后台任务引用集合(防止被 GC) + self._background_tasks: set[asyncio.Task[Any]] = set() + # 保存配置供后续使用 runtime_config = self._get_runtime_config() self._intro_config = AgentIntroGenConfig( @@ -793,10 +796,7 @@ async def ask( if not inflight_summary_enabled: logger.debug("[进行中摘要] 功能已关闭:跳过占位与摘要注入") - should_pre_register = ( - inflight_summary_enabled - and self._should_pre_register_inflight(pre_context, question) - ) + should_pre_register = self._should_pre_register_inflight(pre_context, question) if should_pre_register and inflight_request_id and inflight_location: await self._inflight_task_store.upsert_pending( request_id=inflight_request_id, @@ -985,6 +985,14 @@ async def _clear_inflight_on_exit() -> None: ) if not inflight_summary_enqueued: + + def _cleanup_task(t: asyncio.Task[Any]) -> None: + self._background_tasks.discard(t) + if t.exception() is not None: + logger.error( + "[进行中摘要] 投递失败: %s", t.exception() + ) + task = asyncio.create_task( self._enqueue_inflight_summary_generation( request_id=inflight_request_id, @@ -992,15 +1000,8 @@ async def _clear_inflight_on_exit() -> None: location=inflight_location, ) ) - task.add_done_callback( - lambda t: ( - logger.error( - "[进行中摘要] 投递失败: %s", t.exception() - ) - if t.exception() is not None - else None - ) - ) + self._background_tasks.add(task) + task.add_done_callback(_cleanup_task) inflight_summary_enqueued = True logger.info( "[进行中摘要] 已投递摘要生成: request_id=%s", diff --git a/src/Undefined/ai/prompts.py b/src/Undefined/ai/prompts.py index f2f65ad..606056a 100644 --- a/src/Undefined/ai/prompts.py +++ b/src/Undefined/ai/prompts.py @@ -329,7 +329,7 @@ async def _inject_inflight_tasks( try: runtime_config = self._runtime_config_getter() enabled = bool( - getattr(runtime_config, "inflight_summary_enabled", True) + getattr(runtime_config, "inflight_pre_register_enabled", True) ) if not enabled: return diff --git a/src/Undefined/ai/tooling.py b/src/Undefined/ai/tooling.py index a33889f..70429df 100644 --- a/src/Undefined/ai/tooling.py +++ b/src/Undefined/ai/tooling.py @@ -147,15 +147,11 @@ async def _maybe_send_call_easter_egg( message = f"{called_name},我调用你了,我要调用你了!" sender = context.get("sender") - send_message_callback = context.get("send_message_callback") group_id = context.get("group_id") try: if sender and isinstance(group_id, int) and group_id > 0: - await sender.send_group_message(group_id, message) - return - if send_message_callback: - await send_message_callback(message) + await sender.send_group_message(group_id, message, mark_sent=False) except Exception as exc: logger.debug("[彩蛋] 发送提示消息失败: %s", redact_string(str(exc))) diff --git a/src/Undefined/bilibili/wbi_request.py b/src/Undefined/bilibili/wbi_request.py index 0d8738c..1ffeec8 100644 --- a/src/Undefined/bilibili/wbi_request.py +++ b/src/Undefined/bilibili/wbi_request.py @@ -5,9 +5,9 @@ import httpx from Undefined.bilibili.wbi import build_signed_params -from Undefined.utils.logger import get_logger +import logging -logger = get_logger() +logger = logging.getLogger(__name__) async def request_with_wbi_fallback( diff --git a/src/Undefined/onebot.py b/src/Undefined/onebot.py index 006e03c..d8c6978 100644 --- a/src/Undefined/onebot.py +++ b/src/Undefined/onebot.py @@ -151,7 +151,11 @@ async def _call_api( self._pending_responses.pop(echo, None) async def send_group_message( - self, group_id: int, message: str | list[dict[str, Any]] + self, + group_id: int, + message: str | list[dict[str, Any]], + *, + mark_sent: bool = True, ) -> dict[str, Any]: """发送群消息""" result = await self._call_api( @@ -161,11 +165,16 @@ async def send_group_message( "message": message, }, ) - _mark_message_sent_this_turn() + if mark_sent: + _mark_message_sent_this_turn() return result async def send_private_message( - self, user_id: int, message: str | list[dict[str, Any]] + self, + user_id: int, + message: str | list[dict[str, Any]], + *, + mark_sent: bool = True, ) -> dict[str, Any]: """发送私聊消息""" result = await self._call_api( @@ -175,7 +184,8 @@ async def send_private_message( "message": message, }, ) - _mark_message_sent_this_turn() + if mark_sent: + _mark_message_sent_this_turn() return result async def get_group_msg_history( diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index 640d2f5..ce07884 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -720,7 +720,7 @@ async def _execute_inflight_summary_generation( except Exception as exc: logger.warning("[进行中摘要] 生成失败,使用默认状态: %s", exc) - updated = self.ai.set_inflight_summary_generation_result( + updated = await self.ai.set_inflight_summary_generation_result( request_id, action_summary, ) diff --git a/src/Undefined/skills/agents/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index 1a98787..5461cad 100644 --- a/src/Undefined/skills/agents/agent_tool_registry.py +++ b/src/Undefined/skills/agents/agent_tool_registry.py @@ -182,7 +182,10 @@ def _scan_callable_agents(self) -> list[tuple[str, Path, list[str]]]: def _find_skills_root(self) -> Path | None: """向上查找 skills 根目录。""" - for candidate in (self.base_dir, *self.base_dir.parents): + max_depth = 10 + for i, candidate in enumerate((self.base_dir, *self.base_dir.parents)): + if i >= max_depth: + break if candidate.name == "skills": return candidate return None @@ -559,15 +562,11 @@ async def _maybe_send_agent_tool_call_easter_egg( message = f"{tool_name},我调用你了,我要调用你了!" sender = context.get("sender") - send_message_callback = context.get("send_message_callback") group_id = context.get("group_id") try: if sender and isinstance(group_id, int) and group_id > 0: - await sender.send_group_message(group_id, message) - return - if send_message_callback: - await send_message_callback(message) + await sender.send_group_message(group_id, message, mark_sent=False) except Exception as exc: logger.debug("[彩蛋] 发送提示消息失败: %s", redact_string(str(exc))) diff --git a/src/Undefined/skills/agents/code_delivery_agent/docker_utils.py b/src/Undefined/skills/agents/code_delivery_agent/docker_utils.py new file mode 100644 index 0000000..c5c5208 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/docker_utils.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +async def run_cmd(*args: str, timeout: float = 60) -> tuple[int, str, str]: + """执行宿主机命令,返回 (exit_code, stdout, stderr)。""" + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return -1, "", "timeout" + return ( + proc.returncode or 0, + stdout_b.decode("utf-8", errors="replace").strip(), + stderr_b.decode("utf-8", errors="replace").strip(), + ) + + +async def create_container( + container_name: str, + workspace: Path, + tmpfs_dir: Path, + docker_image: str, + memory_limit: str = "", + cpu_limit: str = "", +) -> None: + """创建并启动 Docker 容器。""" + cmd_args = [ + "docker", + "run", + "-d", + "--name", + container_name, + ] + + if memory_limit: + cmd_args.extend(["--memory", memory_limit]) + if cpu_limit: + cmd_args.extend(["--cpus", cpu_limit]) + + cmd_args.extend( + [ + "-v", + f"{workspace.resolve()}:/workspace", + "-v", + f"{tmpfs_dir.resolve()}:/tmpfs", + "-w", + "/workspace", + docker_image, + "sleep", + "infinity", + ] + ) + + rc, stdout, stderr = await run_cmd(*cmd_args, timeout=120) + if rc != 0: + raise RuntimeError(f"创建容器失败: {stderr or stdout}") + logger.info("[CodeDelivery] 容器已创建: %s", container_name) + + +async def destroy_container(container_name: str) -> bool: + """停止并删除容器,返回是否清理成功。""" + try: + rc, stdout, stderr = await run_cmd( + "docker", "rm", "-f", container_name, timeout=30 + ) + if rc != 0: + err_msg = stderr or stdout + # 不存在视为已完成清理,避免重复清理导致噪声。 + if "No such container" in err_msg: + logger.info("[CodeDelivery] 容器不存在,视为已清理: %s", container_name) + return True + logger.warning( + "[CodeDelivery] 销毁容器失败: %s -> %s", container_name, err_msg + ) + return False + logger.info("[CodeDelivery] 容器已销毁: %s", container_name) + return True + except Exception as exc: + logger.warning("[CodeDelivery] 销毁容器失败: %s -> %s", container_name, exc) + return False + + +async def init_workspace( + workspace: Path, + container_name: str, + source_type: str, + git_url: str, + git_ref: str, +) -> None: + """初始化工作区:git clone 或保持空目录。""" + if source_type == "git" and git_url: + await run_cmd( + "docker", + "exec", + container_name, + "bash", + "-lc", + "apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1", + timeout=120, + ) + clone_cmd = f"git clone {git_url} /workspace" + if git_ref: + clone_cmd = ( + f"git clone {git_url} /tmp/_clone_src && " + f"cp -a /tmp/_clone_src/. /workspace/ && " + f"cd /workspace && git checkout {git_ref}" + ) + rc, stdout, stderr = await run_cmd( + "docker", + "exec", + container_name, + "bash", + "-lc", + clone_cmd, + timeout=300, + ) + if rc != 0: + raise RuntimeError(f"Git clone 失败: {stderr or stdout}") + logger.info( + "[CodeDelivery] Git clone 完成: %s (ref=%s)", git_url, git_ref or "default" + ) diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py index 768f5ab..6b97050 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -1,321 +1,81 @@ from __future__ import annotations -import asyncio import logging import shutil -import uuid from pathlib import Path from typing import Any -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Docker 容器与工作区管理 -# --------------------------------------------------------------------------- - -CONTAINER_PREFIX_DEFAULT = "code_delivery_" -CONTAINER_SUFFIX_DEFAULT = "_runner" -TASK_ROOT_DEFAULT = "data/code_delivery" - - -async def _run_cmd(*args: str, timeout: float = 60) -> tuple[int, str, str]: - """执行宿主机命令,返回 (exit_code, stdout, stderr)。""" - proc = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except asyncio.TimeoutError: - proc.kill() - return -1, "", "timeout" - return ( - proc.returncode or 0, - stdout_b.decode("utf-8", errors="replace").strip(), - stderr_b.decode("utf-8", errors="replace").strip(), - ) - - -async def _cleanup_residual( - task_root: str, - prefix: str, - suffix: str, -) -> None: - """启动前清理残留工作区和容器。""" - # 清理残留目录 - root = Path(task_root) - if root.exists(): - for child in root.iterdir(): - if child.is_dir(): - try: - shutil.rmtree(child) - logger.info("[CodeDelivery] 清理残留目录: %s", child) - except Exception as exc: - logger.warning( - "[CodeDelivery] 清理残留目录失败: %s -> %s", child, exc - ) - - # 清理残留容器(匹配前后缀) - rc, stdout, _ = await _run_cmd("docker", "ps", "-a", "--format", "{{.Names}}") - if rc == 0 and stdout: - for name in stdout.splitlines(): - name = name.strip() - if name.startswith(prefix) and name.endswith(suffix): - logger.info("[CodeDelivery] 清理残留容器: %s", name) - await _run_cmd("docker", "rm", "-f", name) - - -async def _create_container( - container_name: str, - workspace: Path, - tmpfs_dir: Path, - docker_image: str, - memory_limit: str = "", - cpu_limit: str = "", -) -> None: - """创建并启动 Docker 容器。""" - cmd_args = [ - "docker", - "run", - "-d", - "--name", - container_name, - ] - - # 添加资源限制 - if memory_limit: - cmd_args.extend(["--memory", memory_limit]) - if cpu_limit: - cmd_args.extend(["--cpus", cpu_limit]) - - cmd_args.extend( - [ - "-v", - f"{workspace.resolve()}:/workspace", - "-v", - f"{tmpfs_dir.resolve()}:/tmpfs", - "-w", - "/workspace", - docker_image, - "sleep", - "infinity", - ] - ) - - rc, stdout, stderr = await _run_cmd(*cmd_args, timeout=120) - if rc != 0: - raise RuntimeError(f"创建容器失败: {stderr or stdout}") - logger.info("[CodeDelivery] 容器已创建: %s", container_name) +from .docker_utils import destroy_container, run_cmd - -async def _destroy_container(container_name: str) -> None: - """停止并删除容器。""" - try: - await _run_cmd("docker", "rm", "-f", container_name, timeout=30) - logger.info("[CodeDelivery] 容器已销毁: %s", container_name) - except Exception as exc: - logger.warning("[CodeDelivery] 销毁容器失败: %s -> %s", container_name, exc) - - -async def _init_workspace( - workspace: Path, - container_name: str, - source_type: str, - git_url: str, - git_ref: str, -) -> None: - """初始化工作区:git clone 或保持空目录。""" - if source_type == "git" and git_url: - # 先在容器内安装 git - await _run_cmd( - "docker", - "exec", - container_name, - "bash", - "-lc", - "apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1", - timeout=120, - ) - clone_cmd = f"git clone {git_url} /workspace" - if git_ref: - # clone 后 checkout 指定 ref - clone_cmd = ( - f"git clone {git_url} /tmp/_clone_src && " - f"cp -a /tmp/_clone_src/. /workspace/ && " - f"cd /workspace && git checkout {git_ref}" - ) - rc, stdout, stderr = await _run_cmd( - "docker", - "exec", - container_name, - "bash", - "-lc", - clone_cmd, - timeout=300, - ) - if rc != 0: - raise RuntimeError(f"Git clone 失败: {stderr or stdout}") - logger.info( - "[CodeDelivery] Git clone 完成: %s (ref=%s)", git_url, git_ref or "default" - ) - - -async def _send_failure_notification( - context: dict[str, Any], - target_type: str, - target_id: int, - task_id: str, - error_msg: str, -) -> None: - """向目标发送 LLM 失败通知。""" - onebot_client = context.get("onebot_client") - if not onebot_client: - return - msg = ( - f"⚠️ 代码交付任务失败\n\n" - f"任务 ID: {task_id}\n" - f"失败原因: {error_msg}\n\n" - f"建议:检查任务描述后重试。" - ) - try: - if target_type == "group": - await onebot_client.send_group_message(target_id, msg) - else: - await onebot_client.send_private_message(target_id, msg) - except Exception as exc: - logger.warning("[CodeDelivery] 发送失败通知失败: %s", exc) - - -# --------------------------------------------------------------------------- -# Agent 入口 -# --------------------------------------------------------------------------- +logger = logging.getLogger(__name__) async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 code_delivery_agent。""" - - # 解析参数 user_prompt = str(args.get("prompt", "")).strip() - source_type = str(args.get("source_type", "empty")).strip().lower() - git_url = str(args.get("git_url", "")).strip() - git_ref = str(args.get("git_ref", "")).strip() target_type = str(args.get("target_type", "")).strip().lower() target_id = int(args.get("target_id", 0)) if not user_prompt: return "请提供任务目标描述" - if source_type not in ("git", "empty"): - return "source_type 必须为 'git' 或 'empty'" - if source_type == "git" and not git_url: - return "source_type=git 时必须提供 git_url" if target_type not in ("group", "private"): return "target_type 必须为 'group' 或 'private'" if target_id <= 0: return "target_id 必须为正整数" - # 读取配置 config = context.get("config") - task_root = TASK_ROOT_DEFAULT - docker_image = "ubuntu:24.04" - prefix = CONTAINER_PREFIX_DEFAULT - suffix = CONTAINER_SUFFIX_DEFAULT - cleanup_on_finish = True - llm_max_retries = 5 - notify_on_failure = True - memory_limit = "" - cpu_limit = "" - - if config: - if not getattr(config, "code_delivery_enabled", True): - return "Code Delivery Agent 已禁用" - task_root = getattr(config, "code_delivery_task_root", task_root) - docker_image = getattr(config, "code_delivery_docker_image", docker_image) - prefix = getattr(config, "code_delivery_container_name_prefix", prefix) - suffix = getattr(config, "code_delivery_container_name_suffix", suffix) - cleanup_on_finish = getattr(config, "code_delivery_cleanup_on_finish", True) - llm_max_retries = getattr(config, "code_delivery_llm_max_retries", 5) - notify_on_failure = getattr(config, "code_delivery_notify_on_llm_failure", True) - memory_limit = getattr(config, "code_delivery_container_memory_limit", "") - cpu_limit = getattr(config, "code_delivery_container_cpu_limit", "") - - # 创建任务目录 - task_id = str(uuid.uuid4()) - task_dir = Path(task_root) / task_id - workspace = task_dir / "workspace" - tmpfs_dir = task_dir / "tmpfs" - workspace.mkdir(parents=True, exist_ok=True) - tmpfs_dir.mkdir(parents=True, exist_ok=True) - (task_dir / "logs").mkdir(exist_ok=True) - (task_dir / "artifacts").mkdir(exist_ok=True) - - container_name = f"{prefix}{task_id}{suffix}" - - # 注入上下文供子工具使用 - context["task_dir"] = task_dir - context["workspace"] = workspace - context["container_name"] = container_name + if config and not getattr(config, "code_delivery_enabled", True): + return "Code Delivery Agent 已禁用" + + # 注入上下文供工具使用 + context["docker_initialized"] = False + context["init_args"] = { + "source_type": args.get("source_type", "empty"), + "git_url": args.get("git_url", ""), + "git_ref": args.get("git_ref", ""), + } context["target_type"] = target_type context["target_id"] = target_id + context["code_delivery_config"] = { + "task_root": getattr(config, "code_delivery_task_root", "data/code_delivery"), + "docker_image": getattr(config, "code_delivery_docker_image", "ubuntu:24.04"), + "prefix": getattr( + config, "code_delivery_container_name_prefix", "code_delivery_" + ), + "suffix": getattr(config, "code_delivery_container_name_suffix", "_runner"), + "cleanup_on_finish": getattr(config, "code_delivery_cleanup_on_finish", True), + "memory_limit": getattr(config, "code_delivery_container_memory_limit", ""), + "cpu_limit": getattr(config, "code_delivery_container_cpu_limit", ""), + } + + cleanup_on_finish = context["code_delivery_config"]["cleanup_on_finish"] try: - # 创建容器 - await _create_container( - container_name, workspace, tmpfs_dir, docker_image, memory_limit, cpu_limit - ) - - # 初始化工作区 - await _init_workspace(workspace, container_name, source_type, git_url, git_ref) - - # 组装 user_content - source_info = ( - f"初始化来源: source_type=git, git_url={git_url}" - + (f", git_ref={git_ref}" if git_ref else "") - if source_type == "git" - else "初始化来源: source_type=empty(空目录,需要从零开始创建项目)" - ) - user_content = ( - f"用户需求:{user_prompt}\n\n" - f"{source_info}\n" - f"交付目标: target_type={target_type}, target_id={target_id}\n\n" - f"请开始工作。" - ) - - # 使用自定义 runner 支持 LLM 失败重试计数 + user_content = f"用户需求:{user_prompt}\n\n请开始工作。" result = await _run_agent_with_retry( user_content=user_content, context=context, agent_dir=Path(__file__).parent, - llm_max_retries=llm_max_retries, - notify_on_failure=notify_on_failure, - target_type=target_type, - target_id=target_id, - task_id=task_id, ) return result - except Exception as exc: logger.exception("[CodeDelivery] 任务执行失败: %s", exc) - # 发送失败通知 - if notify_on_failure: - await _send_failure_notification( - context, target_type, target_id, task_id, str(exc) - ) return f"任务执行失败: {exc}" - finally: - # 兜底清理 - if cleanup_on_finish: - try: - await _destroy_container(container_name) - except Exception as exc: - logger.warning("[CodeDelivery] 清理容器失败: %s", exc) - try: - if task_dir.exists(): + if context.get("docker_initialized") and cleanup_on_finish: + container_name = context.get("container_name") + task_dir = context.get("task_dir") + if container_name: + try: + await destroy_container(container_name) + except Exception as exc: + logger.warning("[CodeDelivery] 清理容器失败: %s", exc) + if task_dir and Path(task_dir).exists(): + try: shutil.rmtree(task_dir) logger.info("[CodeDelivery] 已清理任务目录: %s", task_dir) - except Exception as exc: - logger.warning("[CodeDelivery] 清理任务目录失败: %s", exc) + except Exception as exc: + logger.warning("[CodeDelivery] 清理任务目录失败: %s", exc) async def _run_agent_with_retry( @@ -323,182 +83,100 @@ async def _run_agent_with_retry( user_content: str, context: dict[str, Any], agent_dir: Path, - llm_max_retries: int, - notify_on_failure: bool, - target_type: str, - target_id: int, - task_id: str, ) -> str: - """带 LLM 连续失败检测的 agent 执行。 - - 对 run_agent_with_tools 的包装:在 runner 内部,每次 LLM 请求 - 如果连续失败达到 llm_max_retries 次,则发送通知并终止。 - """ - - from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry - from Undefined.skills.agents.runner import load_prompt_text - from Undefined.utils.tool_calls import parse_tool_arguments - - ai_client = context.get("ai_client") - if not ai_client: - return "AI client 未在上下文中提供" - - agent_config = ai_client.agent_config - system_prompt = await load_prompt_text(agent_dir, "你是一个代码交付助手。") - - tool_registry = AgentToolRegistry( - agent_dir / "tools", - current_agent_name="code_delivery_agent", - is_main_agent=False, + """执行 agent。""" + from Undefined.skills.agents.runner import run_agent_with_tools + + return await run_agent_with_tools( + agent_name="code_delivery_agent", + user_content=user_content, + empty_user_content_message="请提供任务目标描述", + default_prompt="你是一个专业的代码交付助手。", + context=context, + agent_dir=agent_dir, + logger=logger, + max_iterations=50, + tool_error_prefix="错误", ) - tools = tool_registry.get_tools_schema() - agent_history = context.get("agent_history", []) - messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] - if agent_history: - messages.extend(agent_history) - messages.append({"role": "user", "content": user_content}) - max_iterations = 50 # 代码交付任务通常需要更多轮次 - consecutive_failures = 0 +async def _list_residual_containers( + *, + container_name_prefix: str, + container_name_suffix: str, +) -> list[str]: + """列出符合命名规则的残留容器名。""" + try: + rc, stdout, stderr = await run_cmd( + "docker", "ps", "-a", "--format", "{{.Names}}", timeout=30 + ) + except Exception as exc: + logger.warning("[CodeDelivery] 扫描容器失败: %s", exc) + return [] - for iteration in range(1, max_iterations + 1): - logger.debug("[CodeDelivery] iteration=%s", iteration) - try: - result = await ai_client.request_model( - model_config=agent_config, - messages=messages, - max_tokens=agent_config.max_tokens, - call_type="agent:code_delivery_agent", - tools=tools if tools else None, - tool_choice="auto", - ) - # 请求成功,重置连续失败计数 - consecutive_failures = 0 + if rc != 0: + logger.warning("[CodeDelivery] 扫描容器失败: %s", stderr or stdout) + return [] - except Exception as exc: - consecutive_failures += 1 - logger.warning( - "[CodeDelivery] LLM 请求失败 (%d/%d): %s", - consecutive_failures, - llm_max_retries, - exc, - ) - if consecutive_failures >= llm_max_retries: - error_msg = f"LLM 连续失败 {consecutive_failures} 次: {exc}" - if notify_on_failure: - await _send_failure_notification( - context, target_type, target_id, task_id, error_msg - ) - return error_msg + containers: list[str] = [] + for line in stdout.splitlines(): + name = line.strip() + if not name: continue + if container_name_prefix and not name.startswith(container_name_prefix): + continue + if container_name_suffix and not name.endswith(container_name_suffix): + continue + containers.append(name) + return containers - tool_name_map = ( - result.get("_tool_name_map") if isinstance(result, dict) else None - ) - api_to_internal: dict[str, str] = {} - if isinstance(tool_name_map, dict): - raw = tool_name_map.get("api_to_internal") - if isinstance(raw, dict): - api_to_internal = {str(k): str(v) for k, v in raw.items()} - - choice: dict[str, Any] = result.get("choices", [{}])[0] - message: dict[str, Any] = choice.get("message", {}) - content: str = message.get("content") or "" - tool_calls: list[dict[str, Any]] = message.get("tool_calls", []) - - if content.strip() and tool_calls: - content = "" - if not tool_calls: - return content +async def _cleanup_residual( + task_root: str, + container_name_prefix: str, + container_name_suffix: str, +) -> None: + """清理 code delivery 任务残留目录和容器(启动时调用)。""" + task_root_path = Path(task_root) + task_dirs: list[Path] = [] - messages.append( - {"role": "assistant", "content": content, "tool_calls": tool_calls} + if task_root_path.exists(): + if task_root_path.is_dir(): + task_dirs = [entry for entry in task_root_path.iterdir() if entry.is_dir()] + else: + logger.warning("[CodeDelivery] task_root 不是目录: %s", task_root_path) + + # 基于任务目录名推导容器名,兼容 docker 无法访问的情况。 + container_names = { + f"{container_name_prefix}{task_dir.name}{container_name_suffix}" + for task_dir in task_dirs + } + container_names.update( + await _list_residual_containers( + container_name_prefix=container_name_prefix, + container_name_suffix=container_name_suffix, ) + ) - tool_tasks: list[asyncio.Future[Any]] = [] - tool_call_ids: list[str] = [] - tool_api_names: list[str] = [] - end_tool_call: dict[str, Any] | None = None - end_tool_args: dict[str, Any] = {} - - for tool_call in tool_calls: - call_id = str(tool_call.get("id", "")) - function: dict[str, Any] = tool_call.get("function", {}) - api_name = str(function.get("name", "")) - raw_args = function.get("arguments") - - internal_name = api_to_internal.get(api_name, api_name) - function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=api_name - ) - if not isinstance(function_args, dict): - function_args = {} - - if internal_name == "end": - if len(tool_calls) > 1: - logger.warning( - "[CodeDelivery] end 与其他工具同时调用,先执行其他工具" - ) - end_tool_call = tool_call - end_tool_args = function_args - continue - - tool_call_ids.append(call_id) - tool_api_names.append(api_name) - tool_tasks.append( - asyncio.ensure_future( - tool_registry.execute_tool(internal_name, function_args, context) - ) - ) - - if tool_tasks: - results = await asyncio.gather(*tool_tasks, return_exceptions=True) - for idx, tool_result in enumerate(results): - cid = tool_call_ids[idx] - aname = tool_api_names[idx] - if isinstance(tool_result, Exception): - content_str = f"错误: {tool_result}" - else: - content_str = str(tool_result) - messages.append( - { - "role": "tool", - "tool_call_id": cid, - "name": aname, - "content": content_str, - } - ) + cleaned_containers = 0 + for container_name in sorted(container_names): + try: + removed = await destroy_container(container_name) + if removed: + cleaned_containers += 1 + except Exception as exc: + logger.warning("[CodeDelivery] 清理容器失败: %s -> %s", container_name, exc) - if end_tool_call: - end_call_id = str(end_tool_call.get("id", "")) - end_api_name = end_tool_call.get("function", {}).get("name", "end") - if tool_tasks: - messages.append( - { - "role": "tool", - "tool_call_id": end_call_id, - "name": end_api_name, - "content": ( - "end 与其他工具同轮调用,本轮未执行 end;" - "请根据其他工具结果继续决策。" - ), - } - ) - else: - end_result = await tool_registry.execute_tool( - "end", end_tool_args, context - ) - messages.append( - { - "role": "tool", - "tool_call_id": end_call_id, - "name": end_api_name, - "content": str(end_result), - } - ) - # end 执行后返回结果 - return str(end_result) + cleaned_dirs = 0 + for task_dir in task_dirs: + try: + shutil.rmtree(task_dir) + cleaned_dirs += 1 + except Exception as exc: + logger.warning("[CodeDelivery] 清理任务目录失败: %s -> %s", task_dir, exc) - return "达到最大迭代次数" + logger.info( + "[CodeDelivery] 启动残留清理: containers=%s task_dirs=%s", + cleaned_containers, + cleaned_dirs, + ) diff --git a/src/Undefined/skills/agents/code_delivery_agent/intro.md b/src/Undefined/skills/agents/code_delivery_agent/intro.md index c6062e9..0e08a78 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/intro.md +++ b/src/Undefined/skills/agents/code_delivery_agent/intro.md @@ -1 +1 @@ -代码交付 Agent,在隔离 Docker 容器中完成代码编写、命令执行、验证测试,最终打包上传到群聊或私聊。支持从 Git 仓库或空目录初始化。 +代码交付 Agent。支持单文件轻量交付和多文件工程交付两种模式:(1) 单文件模式:直接发送单个文本文件(脚本、配置、文档),无需 Docker;(2) 多文件模式:在隔离的 Docker 容器中编写代码、执行命令、运行验证,最终打包上传。支持从 Git 仓库克隆或空目录开始。 diff --git a/src/Undefined/skills/agents/code_delivery_agent/prompt.md b/src/Undefined/skills/agents/code_delivery_agent/prompt.md index d4f0efc..77cf45f 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/prompt.md +++ b/src/Undefined/skills/agents/code_delivery_agent/prompt.md @@ -5,14 +5,36 @@ - 在容器内执行命令验证代码正确性 - 任务完成后打包交付 +## 工具选择策略 + +**单文件轻量任务(优先):** +- 单个脚本、配置文件、文档 +- 不需要安装依赖或运行验证 +- **直接使用 `send_text_file` 工具**,无需初始化 Docker + +**多文件或复杂任务:** +- 多文件工程项目 +- 需要安装依赖、编译、运行测试 +- 需要打包交付(使用 `end` 工具) +- **先调用 `init_docker` 初始化容器**,再使用其他工具开发 + ## 工作流程 -1. **理解需求**:仔细分析用户的任务目标 -2. **规划方案**:使用 `todo` 工具记录待办事项,拆解任务步骤 -3. **编写代码**:使用 `write` 工具创建/修改文件 -4. **验证测试**:使用 `run_bash_command` 安装依赖、编译、运行测试 -5. **检查结果**:使用 `read`/`glob`/`grep` 工具检查代码和输出 -6. **补全文档**:确保项目包含 README.md(至少含使用方式与运行说明) -7. **交付打包**:使用 `end` 工具打包并上传 + +**单文件任务流程:** +1. 理解需求 +2. 编写代码内容 +3. 使用 `send_text_file` 直接发送 + +**多文件/复杂任务流程:** +**多文件/复杂任务流程:** +1. **初始化环境**:调用 `init_docker` 初始化 Docker 容器 +2. **理解需求**:仔细分析用户的任务目标 +3. **规划方案**:使用 `todo` 工具记录待办事项 +4. **编写代码**:使用 `write` 工具创建/修改文件 +5. **验证测试**:使用 `run_bash_command` 安装依赖、编译、运行测试 +6. **检查结果**:使用 `read`/`glob`/`grep` 工具检查代码和输出 +7. **补全文档**:确保项目包含 README.md +8. **交付打包**:使用 `end` 工具打包并上传 ## 工作原则 - 每一步都要验证,不要假设命令会成功 diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/config.json new file mode 100644 index 0000000..9051a9e --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/config.json @@ -0,0 +1,12 @@ +{ + "type": "function", + "function": { + "name": "init_docker", + "description": "初始化 Docker 容器和工作区。仅在需要多文件工程、需要运行验证或打包交付时调用。单文件轻量任务请直接使用 send_text_file 工具。", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/handler.py new file mode 100644 index 0000000..8297e81 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/init_docker/handler.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """初始化 Docker 容器和工作区。""" + if context.get("docker_initialized"): + return "Docker 容器已初始化,无需重复调用" + + init_args = context.get("init_args", {}) + config = context.get("code_delivery_config", {}) + + task_id = str(uuid.uuid4()) + task_dir = Path(config["task_root"]) / task_id + workspace = task_dir / "workspace" + tmpfs_dir = task_dir / "tmpfs" + workspace.mkdir(parents=True, exist_ok=True) + tmpfs_dir.mkdir(parents=True, exist_ok=True) + + container_name = f"{config['prefix']}{task_id}{config['suffix']}" + + from ...docker_utils import create_container, destroy_container, init_workspace + + try: + await create_container( + container_name, + workspace, + tmpfs_dir, + config["docker_image"], + config["memory_limit"], + config["cpu_limit"], + ) + context["container_name"] = container_name + context["task_dir"] = task_dir + + await init_workspace( + workspace, + container_name, + init_args.get("source_type", "empty"), + init_args.get("git_url", ""), + init_args.get("git_ref", ""), + ) + except Exception: + try: + await destroy_container(container_name) + except Exception: + pass + import shutil + + if task_dir.exists(): + shutil.rmtree(task_dir, ignore_errors=True) + raise + + context["docker_initialized"] = True + context["workspace"] = workspace + + source_type = init_args.get("source_type", "empty") + if source_type == "git": + source_info = f"已从 Git 克隆: {init_args.get('git_url')}" + if init_args.get("git_ref"): + source_info += f" (ref: {init_args['git_ref']})" + else: + source_info = "空目录,可从零开始创建项目" + + return f"Docker 容器初始化完成\n{source_info}\n工作目录: /workspace" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/config.json new file mode 100644 index 0000000..30e66d4 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/config.json @@ -0,0 +1,26 @@ +{ + "type": "function", + "function": { + "name": "send_text_file", + "description": "发送单文件文本内容到目标群聊或私聊。适用于单文件、轻量任务(如单个脚本、配置文件、文档)。默认大小上限 512KB。多文件工程、需要运行验证或打包交付的复杂任务,请使用 init_docker 初始化容器后进行开发。", + "parameters": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "文件文本内容" + }, + "filename": { + "type": "string", + "description": "文件名(仅允许单文件名,不可包含路径),例如 main.py、README.md、CMakeLists.txt" + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "utf-8-sig", "ascii", "gbk"], + "description": "可选。文件编码,默认 utf-8。" + } + }, + "required": ["content", "filename"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/handler.py new file mode 100644 index 0000000..5bf698a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/send_text_file/handler.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +import uuid +from pathlib import Path +from typing import Any, Dict, Literal + +logger = logging.getLogger(__name__) + +TargetType = Literal["group", "private"] + +MAX_FILENAME_LENGTH = 128 +DEFAULT_MAX_FILE_SIZE_BYTES = 512 * 1024 + +ALLOWED_ENCODINGS: set[str] = {"utf-8", "utf-8-sig", "ascii", "gbk"} + +ALLOWED_TEXT_EXTENSIONS: set[str] = { + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".vue", + ".html", + ".htm", + ".css", + ".scss", + ".less", + ".sass", + ".c", + ".h", + ".cpp", + ".hpp", + ".cc", + ".cxx", + ".go", + ".rs", + ".java", + ".kt", + ".cs", + ".php", + ".rb", + ".swift", + ".lua", + ".sh", + ".bash", + ".zsh", + ".fish", + ".ps1", + ".bat", + ".md", + ".markdown", + ".txt", + ".rst", + ".adoc", + ".tex", + ".latex", + ".org", + ".json", + ".jsonl", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".env", + ".xml", + ".csv", + ".tsv", + ".sql", + ".log", +} + +ALLOWED_SPECIAL_FILENAMES: set[str] = { + "dockerfile", + "makefile", + "cmakelists.txt", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + "requirements.txt", +} + + +def _validate_filename(filename: str) -> str | None: + if not filename: + return "filename 不能为空" + if len(filename) > MAX_FILENAME_LENGTH: + return f"filename 过长,最多 {MAX_FILENAME_LENGTH} 个字符" + if any(ch in filename for ch in ("/", "\\", "\x00")): + return "filename 只能是单文件名,不能包含路径" + if filename in {".", ".."}: + return "filename 非法" + if Path(filename).name != filename: + return "filename 只能是单文件名,不能包含路径" + + lowered = filename.lower() + if lowered in ALLOWED_SPECIAL_FILENAMES: + return None + + suffix = Path(filename).suffix.lower() + if suffix in ALLOWED_TEXT_EXTENSIONS: + return None + + return ( + "不支持的文本文件格式。建议使用常见代码/文档/配置扩展名;" + "多文件或复杂交付请使用 init_docker 初始化容器" + ) + + +def _resolve_onebot_client(context: Dict[str, Any]) -> Any | None: + onebot_client = context.get("onebot_client") + if onebot_client is not None: + return onebot_client + + sender = context.get("sender") + if sender is None: + return None + + return getattr(sender, "onebot", None) + + +def _resolve_max_file_size_bytes(runtime_config: Any) -> int: + if runtime_config is None: + return DEFAULT_MAX_FILE_SIZE_BYTES + + raw_value = getattr(runtime_config, "messages_send_text_file_max_size_kb", 512) + try: + size_kb = int(raw_value) + except (TypeError, ValueError): + return DEFAULT_MAX_FILE_SIZE_BYTES + + if size_kb <= 0: + return DEFAULT_MAX_FILE_SIZE_BYTES + return size_kb * 1024 + + +async def _write_text_file( + file_path: Path, content: str, encoding: str +) -> tuple[str, int]: + def sync_write() -> tuple[str, int]: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding=encoding, newline="\n") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + abs_path = str(file_path.resolve()) + file_size = file_path.stat().st_size + return abs_path, file_size + + return await asyncio.to_thread(sync_write) + + +async def _cleanup_directory(path: Path) -> None: + def sync_cleanup() -> None: + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + + await asyncio.to_thread(sync_cleanup) + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """发送单文件文本内容到群聊或私聊。""" + request_id = str(context.get("request_id", "-")) + + content_raw = args.get("content") + if not isinstance(content_raw, str) or not content_raw: + return "content 不能为空" + content = content_raw + + filename = str(args.get("filename", "")).strip() + filename_error = _validate_filename(filename) + if filename_error: + return filename_error + + encoding = str(args.get("encoding", "utf-8")).strip().lower() or "utf-8" + if encoding not in ALLOWED_ENCODINGS: + return "encoding 仅支持 utf-8 / utf-8-sig / ascii / gbk" + + target_type = context.get("target_type") + target_id = context.get("target_id") + if not target_type or not target_id: + return "错误:目标信息未设置" + + runtime_config = context.get("runtime_config") + max_file_size_bytes = _resolve_max_file_size_bytes(runtime_config) + + try: + payload_size = len(content.encode(encoding)) + except UnicodeEncodeError: + return f"编码 {encoding} 无法表示当前内容,请改用 utf-8" + + if payload_size > max_file_size_bytes: + return ( + f"文件内容过大({payload_size / 1024:.1f}KB)," + f"当前限制 {max_file_size_bytes / 1024:.0f}KB。" + "单文件请精简后重试;多文件或大体量交付建议使用 init_docker 初始化容器" + ) + + if runtime_config is not None: + if target_type == "group" and not runtime_config.is_group_allowed(target_id): + return f"发送失败:目标群 {target_id} 不在允许列表内(access.allowed_group_ids)" + if target_type == "private" and not runtime_config.is_private_allowed( + target_id + ): + return f"发送失败:目标用户 {target_id} 不在允许列表内(access.allowed_private_ids)" + + onebot_client = _resolve_onebot_client(context) + if onebot_client is None: + return "发送失败:OneBot 客户端未设置" + + from Undefined.utils.paths import TEXT_FILE_CACHE_DIR, ensure_dir + + task_uuid = uuid.uuid4().hex + task_dir = ensure_dir(TEXT_FILE_CACHE_DIR / task_uuid) + file_path = task_dir / filename + + try: + abs_path, file_size = await _write_text_file(file_path, content, encoding) + if target_type == "group": + await onebot_client.upload_group_file(target_id, abs_path, filename) + else: + await onebot_client.upload_private_file(target_id, abs_path, filename) + + context["message_sent_this_turn"] = True + logger.info( + "[发送文本文件] 成功: request_id=%s target_type=%s target_id=%s file=%s size=%sB", + request_id, + target_type, + target_id, + filename, + file_size, + ) + return ( + f"文件已发送:{filename} ({file_size / 1024:.1f}KB) -> " + f"{target_type} {target_id}" + ) + except UnicodeEncodeError: + return f"编码 {encoding} 无法表示当前内容,请改用 utf-8" + except Exception as exc: + logger.exception( + "[发送文本文件] 失败: request_id=%s target_type=%s target_id=%s file=%s err=%s", + request_id, + target_type, + target_id, + filename, + exc, + ) + return "发送失败:文件上传服务暂时不可用,请稍后重试" + finally: + try: + await _cleanup_directory(task_dir) + except Exception as cleanup_error: + logger.warning( + "[发送文本文件] 清理缓存失败: request_id=%s task_uuid=%s err=%s", + request_id, + task_uuid, + cleanup_error, + ) diff --git a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py index fa98568..0a3f202 100644 --- a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py @@ -10,8 +10,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: prompt = args.get("prompt") - model = args.get("model", "doubaoApp/generations") # 默认模型 - del model # 暂时不传model + # model 参数暂时不使用 size = args.get("size", "1:1") target_id = args.get("target_id") message_type = args.get("message_type") diff --git a/src/Undefined/utils/common.py b/src/Undefined/utils/common.py index d22aa43..73da9cf 100644 --- a/src/Undefined/utils/common.py +++ b/src/Undefined/utils/common.py @@ -244,8 +244,6 @@ async def _expand_forward_segment( def _parse_at_segment(data: Dict[str, Any], bot_qq: int) -> Optional[str]: """解析 @ 消息段,输出 [@{qq}({昵称})] 或 [@{qq}]""" qq = data.get("qq", "") - if bot_qq and str(qq) == str(bot_qq): - return None name = data.get("name") or data.get("nickname") or "" if name: return f"[@{qq}({name})]" diff --git a/src/Undefined/utils/sender.py b/src/Undefined/utils/sender.py index ad14a65..e310f45 100644 --- a/src/Undefined/utils/sender.py +++ b/src/Undefined/utils/sender.py @@ -39,6 +39,8 @@ async def send_group_message( message: str, auto_history: bool = True, history_prefix: str = "", + *, + mark_sent: bool = True, ) -> None: """发送群消息""" if not self.config.is_group_allowed(group_id): @@ -78,7 +80,9 @@ async def send_group_message( # 自动分段发送 if len(message) <= MAX_MESSAGE_LENGTH: segments = message_to_segments(message) - await self.onebot.send_group_message(group_id, segments) + await self.onebot.send_group_message( + group_id, segments, mark_sent=mark_sent + ) return # 按行分割 @@ -96,7 +100,7 @@ async def send_group_message( chunk_text = "\n".join(current_chunk) logger.debug(f"[消息分段] 发送第 {chunk_count} 段") await self.onebot.send_group_message( - group_id, message_to_segments(chunk_text) + group_id, message_to_segments(chunk_text), mark_sent=mark_sent ) current_chunk = [] current_length = 0 @@ -109,13 +113,18 @@ async def send_group_message( chunk_text = "\n".join(current_chunk) logger.debug(f"[消息分段] 发送第 {chunk_count} 段 (最后一段)") await self.onebot.send_group_message( - group_id, message_to_segments(chunk_text) + group_id, message_to_segments(chunk_text), mark_sent=mark_sent ) logger.info(f"[消息分段] 已完成 {chunk_count} 段消息的发送") async def send_private_message( - self, user_id: int, message: str, auto_history: bool = True + self, + user_id: int, + message: str, + auto_history: bool = True, + *, + mark_sent: bool = True, ) -> None: """发送私聊消息""" if not self.config.is_private_allowed(user_id): @@ -148,7 +157,9 @@ async def send_private_message( # 自动分段发送 if len(message) <= MAX_MESSAGE_LENGTH: segments = message_to_segments(message) - await self.onebot.send_private_message(user_id, segments) + await self.onebot.send_private_message( + user_id, segments, mark_sent=mark_sent + ) return # 按行分割 @@ -166,7 +177,7 @@ async def send_private_message( chunk_text = "\n".join(current_chunk) logger.debug(f"[消息分段] 发送第 {chunk_count} 段") await self.onebot.send_private_message( - user_id, message_to_segments(chunk_text) + user_id, message_to_segments(chunk_text), mark_sent=mark_sent ) current_chunk = [] current_length = 0 @@ -179,7 +190,7 @@ async def send_private_message( chunk_text = "\n".join(current_chunk) logger.debug(f"[消息分段] 发送第 {chunk_count} 段 (最后一段)") await self.onebot.send_private_message( - user_id, message_to_segments(chunk_text) + user_id, message_to_segments(chunk_text), mark_sent=mark_sent ) logger.info(f"[消息分段] 已完成 {chunk_count} 段消息的发送") diff --git a/tests/test_agent_tool_registry.py b/tests/test_agent_tool_registry.py new file mode 100644 index 0000000..ddaa0d9 --- /dev/null +++ b/tests/test_agent_tool_registry.py @@ -0,0 +1,51 @@ +"""AgentToolRegistry 单元测试""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry + + +class TestFindSkillsRoot: + """测试 _find_skills_root 方法""" + + def test_find_skills_root_success(self) -> None: + """测试成功找到 skills 根目录""" + with TemporaryDirectory() as tmpdir: + # 创建目录结构: tmpdir/skills/agents/test_agent + skills_dir = Path(tmpdir) / "skills" + agents_dir = skills_dir / "agents" + test_agent_dir = agents_dir / "test_agent" + test_agent_dir.mkdir(parents=True) + + registry = AgentToolRegistry(test_agent_dir) + result = registry._find_skills_root() + + assert result == skills_dir + + def test_find_skills_root_depth_limit(self) -> None: + """测试深度限制:超过 10 层返回 None""" + with TemporaryDirectory() as tmpdir: + # 创建深度超过 10 层的目录结构 + current = Path(tmpdir) + for i in range(12): + current = current / f"level{i}" + current.mkdir(parents=True) + + registry = AgentToolRegistry(current) + result = registry._find_skills_root() + + # 应该返回 None,因为超过深度限制 + assert result is None + + def test_find_skills_root_not_found(self) -> None: + """测试找不到 skills 目录""" + with TemporaryDirectory() as tmpdir: + # 创建一个不包含 skills 目录的结构 + test_dir = Path(tmpdir) / "some" / "path" + test_dir.mkdir(parents=True) + + registry = AgentToolRegistry(test_dir) + result = registry._find_skills_root() + + assert result is None