From 275e9edc642f468a9af559cfbba6b0c79e008a26 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 18:56:34 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feature:=20=E6=B7=BB=E5=8A=A0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E5=86=99=E4=BA=A4=E4=BB=98agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODE_DELIVERY_AGENT_PLAN.md | 351 +++++++++++++ config.toml.example | 43 ++ src/Undefined/config/loader.py | 81 +++ src/Undefined/main.py | 16 + src/Undefined/onebot.py | 81 +++ .../agents/code_delivery_agent/config.json | 39 ++ .../agents/code_delivery_agent/handler.py | 482 ++++++++++++++++++ .../agents/code_delivery_agent/intro.md | 1 + .../agents/code_delivery_agent/prompt.md | 43 ++ .../code_delivery_agent/tools/end/config.json | 33 ++ .../code_delivery_agent/tools/end/handler.py | 140 +++++ .../tools/glob/config.json | 21 + .../code_delivery_agent/tools/glob/handler.py | 57 +++ .../tools/grep/config.json | 33 ++ .../code_delivery_agent/tools/grep/handler.py | 82 +++ .../tools/read/config.json | 21 + .../code_delivery_agent/tools/read/handler.py | 48 ++ .../tools/run_bash_command/config.json | 25 + .../tools/run_bash_command/handler.py | 85 +++ .../tools/todo/config.json | 31 ++ .../code_delivery_agent/tools/todo/handler.py | 106 ++++ .../tools/write/config.json | 26 + .../tools/write/handler.py | 45 ++ 23 files changed, 1890 insertions(+) create mode 100644 CODE_DELIVERY_AGENT_PLAN.md create mode 100644 src/Undefined/skills/agents/code_delivery_agent/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/intro.md create mode 100644 src/Undefined/skills/agents/code_delivery_agent/prompt.md create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/todo/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py diff --git a/CODE_DELIVERY_AGENT_PLAN.md b/CODE_DELIVERY_AGENT_PLAN.md new file mode 100644 index 0000000..5e17de8 --- /dev/null +++ b/CODE_DELIVERY_AGENT_PLAN.md @@ -0,0 +1,351 @@ +# Code Delivery Agent 详细实施计划(仅规划,不改实现) + +## 1. 背景与目标 + +### 1.1 目标 +在 `src/Undefined` 技术栈内新增一个可执行“代码编写 -> 运行验证 -> 打包 -> 发送到群聊/私聊”的 Agent, +用于从指定初始化来源(Git 仓库或空目录)开始完成交付。 + +### 1.2 明确边界(当前版本) +- 仅覆盖 `src/Undefined`,不涉及 `code/NagaAgent`。 +- 不做人审确认流程。 +- 不做命令白名单。 +- 不做网络白名单,容器全程开网。 +- Docker 不做端口映射。 +- 打包排除规则由 AI 在调用 `end` 工具时提供(黑名单由 AI 给出)。 + +### 1.3 核心结果 +- 新增 `code_delivery_agent`(可被主 AI 调用)。 +- 提供 7 个工具:`read` / `write` / `glob` / `grep` / `run_bash_command` / `todo` / `end`。 +- `end` 完成“按黑名单打包并上传到目标群/私聊”。 +- 工作目录与容器在任务完成后及时清理。 +- 任务内若单次 LLM 请求连续失败达到 5 次,主动向目标群/私聊发送失败通知和失败原因。 +- Docker 容器名采用固定前后缀规则,便于启动时扫描与清理残留容器。 +- Agent 每次启动前先执行一次“残留兜底清理”:删除 `data/code_delivery/` 下目录与相关 Docker 容器(若存在)。 +- 功能实现完成后,补充并更新本仓库相关文档。 + +--- + +## 2. 目录与生命周期设计 + +### 2.1 任务根目录 +每次调用 agent 创建唯一任务目录: + +`data/code_delivery/{task_uuid}/` + +内部结构: +- `workspace/`:项目工作区(AI 读写、构建、打包源) +- `tmpfs/`:宿主侧临时目录(用于与容器临时挂载配合) +- `logs/`:可选执行日志 +- `artifacts/`:可选中间产物(最终包也可放此处) + +### 2.2 容器挂载 +容器内固定路径: +- `/workspace` -> `data/code_delivery/{task_uuid}/workspace` +- `/tmpfs` -> `data/code_delivery/{task_uuid}/tmpfs` + +### 2.3 清理策略 +- 启动前清理(防中断残留): + 1. 删除 `data/code_delivery/` 下所有历史任务目录; + 2. 删除名称匹配 code_delivery 命名规则的 Docker 容器(运行中与已退出都处理)。 +- 正常结束:`end` 发送完成后,停止并删除容器,清理 `data/code_delivery/{task_uuid}/`。 +- 异常结束:handler `finally` 做兜底清理。 +- 清理失败仅记录日志,不阻断用户结果返回。 + +--- + +## 3. Docker 执行模型 + +### 3.1 镜像 +- 默认镜像:`ubuntu:24.04`(可配置)。 + +### 3.2 运行方式 +每个任务一个容器,生命周期随任务。 + +建议参数(示意): +- `docker run -d --rm` +- `-v :/workspace` +- `-v :/tmpfs` +- `-w /workspace` +- 不加 `-p`(无端口映射) +- 不加 `--network none`(全程开网) + +### 3.3 命令执行 +- `run_bash_command` 统一走 `docker exec bash -lc "..."`。 +- 不在宿主机直接执行用户构建命令。 + +### 3.4 容器命名规则 +- 容器名使用固定格式:``。 +- 建议默认:`container_name_prefix = "code_delivery_"`,`container_name_suffix = "_runner"`。 +- 启动前残留清理时,依据此前后缀规则匹配并清理相关容器。 + +--- + +## 4. Agent API 设计 + +## 4.1 Agent 名称 +`code_delivery_agent` + +### 4.2 入参设计 +- `prompt: string` 任务目标 +- `source_type: "git" | "empty"` +- `git_url?: string`(`source_type=git` 必填) +- `git_ref?: string`(可选,分支/tag/commit) +- `target_type: "group" | "private"` +- `target_id: integer` + +### 4.3 prompt 显式要求 +在 handler 组装给子 agent 的 `user_content` 时,必须显式写明初始化来源: +- Git 来源:`source_type=git, git_url=..., git_ref=...` +- 空仓来源:`source_type=empty` + +目标是让子 agent 不会丢失“从哪里初始化”的关键上下文。 + +### 4.4 文档与交付约束 +- 若 `source_type=empty`,AI 在产出代码时必须补齐项目 `README.md`(不可留空)。 +- 任务完成前,AI 必须补全相关文档(至少包含使用方式与运行说明)。 +- `end` 前应确保交付内容包含代码与文档两部分,而非只提交代码文件。 + +--- + +## 5. 工具集合设计(当前 7 个) + +## 5.1 `read` +- 功能:读取文件文本内容 +- 参数:`path`, `max_chars?` +- 约束:只允许读取 task `workspace` 下路径 +- 返回:文本或错误信息 + +## 5.2 `write` +- 功能:写文件 +- 参数:`path`, `content`, `mode?`(`overwrite|append`) +- 约束:只允许写入 task `workspace` 下路径 +- 返回:写入结果(字节数/路径) + +## 5.3 `glob` +- 功能:按模式匹配文件 +- 参数:`pattern`, `base_path?` +- 约束:搜索边界在 task `workspace` +- 返回:匹配路径列表(上限截断) + +## 5.4 `grep` +- 功能:内容检索 +- 参数:`pattern`, `path?`, `is_regex?`, `case_sensitive?`, `max_matches?` +- 约束:搜索边界在 task `workspace` +- 返回:`file:line:content` 列表(上限截断) + +## 5.5 `run_bash_command` +- 功能:在任务容器内执行 bash 命令 +- 参数:`command`, `timeout_seconds?`, `workdir?` +- 行为:`docker exec` 执行,返回 `exit_code/stdout/stderr` +- 备注:当前版本不做命令白名单 + +## 5.6 `todo` +- 功能:记录与追踪任务待办/进度 +- 参数:`action`(`add|list|update|remove|clear`), `item_id?`, `content?`, `status?` +- 行为:在任务目录维护一个 `todo.json`(或同等结构)作为进度面板 +- 价值:让 Agent 在长任务中可持续追踪“未做/进行中/已完成”项 + +## 5.7 `end` +- 功能:结束任务、打包并上传 +- 参数: + - `exclude_patterns: string[]`(必填,AI 提供黑名单) + - `archive_name?` + - `archive_format?`(建议 `zip|tar.gz`) + - `summary?` +- 行为: + 1. 按黑名单打包 `workspace` + 2. 上传到 `target_type/target_id` + 3. 返回产物信息(名称、大小、hash、上传状态) + 4. 标记会话结束并触发清理 + +--- + +## 6. 打包与黑名单规则 + +### 6.1 黑名单来源 +由 AI 调用 `end` 时传入 `exclude_patterns`。 + +### 6.2 黑名单匹配 +- 采用 glob 风格匹配(如 `.git/**`, `.venv/**`, `node_modules/**`)。 +- 黑名单仅用于“打包阶段排除”,不影响 workspace 内实际文件存在。 + +### 6.3 默认建议(写入 prompt,不强制) +可建议 AI 优先传: +- `.git/**` +- `.venv/**` +- `__pycache__/**` +- `.pytest_cache/**` +- `node_modules/**` +- `.mypy_cache/**` +- `.ruff_cache/**` + +--- + +## 7. 上传到群聊/私聊设计 + +### 7.1 OneBot 扩展 +在 `OneBotClient` 新增方法: +- `upload_group_file(group_id, file_path, name?)` +- `upload_private_file(user_id, file_path, name?)` + +通过 `_call_api` 调用对应动作(具体动作名按当前 OneBot 实现适配)。 + +### 7.2 上传流程 +`end` 根据 `target_type` 分发: +- `group` -> 上传群文件 +- `private` -> 上传私聊文件 + +### 7.3 失败回退 +如遇实现不支持上传动作: +- 尝试文件消息段回退(视协议实现); +- 若仍失败,返回明确错误并保留本地产物路径用于人工处理。 + +### 7.4 LLM 连续失败通知 +- 对“单次 LLM 请求”设置最大重试次数 5(不是全任务累计失败次数)。 +- 若该次请求连续 5 次失败,立即向 `target_type/target_id` 发送失败通知。 +- 通知内容至少包含:任务 ID、失败阶段、错误摘要、建议重试信息。 +- 发送通知后结束任务并执行清理流程,避免进入无效重试循环。 + +--- + +## 8. 配置项计划(`config.toml.example`) + +新增段:`[code_delivery]` + +建议字段: +- `enabled = true` +- `task_root = "data/code_delivery"` +- `docker_image = "ubuntu:24.04"` +- `container_name_prefix = "code_delivery_"` +- `container_name_suffix = "_runner"` +- `default_command_timeout_seconds = 600` +- `max_command_output_chars = 20000` +- `default_archive_format = "zip"` +- `max_archive_size_mb = 200` +- `cleanup_on_finish = true` +- `cleanup_on_start = true` +- `llm_max_retries_per_request = 5` +- `notify_on_llm_failure = true` + +并在 `src/Undefined/config/loader.py` 的 `Config` 中新增对应字段与解析。 + +--- + +## 9. 代码落点计划 + +### 9.1 新增 agent +`src/Undefined/skills/agents/code_delivery_agent/` +- `config.json` +- `intro.md` +- `prompt.md` +- `handler.py` +- `tools/` + +### 9.2 工具子目录 +- `tools/read/{config.json,handler.py}` +- `tools/write/{config.json,handler.py}` +- `tools/glob/{config.json,handler.py}` +- `tools/grep/{config.json,handler.py}` +- `tools/run_bash_command/{config.json,handler.py}` +- `tools/todo/{config.json,handler.py}` +- `tools/end/{config.json,handler.py}` + +### 9.3 需修改文件 +- `src/Undefined/onebot.py`(文件上传 API) +- `src/Undefined/config/loader.py`(配置解析) +- `config.toml.example`(示例配置) +- `README.md`(补充 code delivery agent 的使用说明) +- `src/Undefined/skills/README.md`(补充新增 agent/tool 说明) +- `src/Undefined/skills/agents/README.md`(补充 code_delivery_agent 结构与约束) + +### 9.4 新增 TODO 文档 +- `src/Undefined/skills/agents/code_delivery_agent/TODO.md` +- 用于列“后续可扩展工具”和技术债。 + +--- + +## 10. TODO(后续可扩展工具清单) + +以下先记录,不纳入当前最小版本: +- `list_directory`:快速列目录树 +- `read_many`:批量读取文件减少轮次 +- `replace_in_file`:结构化替换 +- `download_to_workspace`:显式下载远程依赖 +- `inspect_env`:查看容器内工具链版本 +- `checkpoint`:阶段性产物留档 +- `restore_checkpoint`:失败回滚 +- `artifact_list`:列当前任务已产物 + +--- + +## 11. 验收计划 + +### 11.1 基本用例 +1. `source_type=empty`:创建代码、执行命令、`end` 打包上传成功。 +2. `source_type=git`:clone 仓库、修改代码、执行命令、`end` 上传成功。 +3. `todo` 工具可正常新增/列出/更新/删除待办并持久化。 +4. `source_type=empty` 时最终产物包含有效 `README.md`。 +5. 任务结束前已补全必要文档(至少含运行方式与使用说明)。 + +### 11.2 黑名单验证 +- 传入 `.git/**`, `.venv/**` 后,包内不包含对应目录。 + +### 11.3 清理验证 +- 成功路径:任务结束后容器不存在,`data/code_delivery/{task_uuid}` 被清理。 +- 异常路径:中途报错后也会触发兜底清理。 +- 启动路径:每次 agent 启动前会清理 `data/code_delivery/` 下历史目录和匹配前后缀规则的残留 Docker 容器。 + +### 11.4 上传验证 +- 群聊上传成功。 +- 私聊上传成功。 +- 上传失败时返回明确可排查信息。 + +### 11.5 LLM 失败通知验证 +- 人为制造 LLM 请求连续失败场景。 +- 单次请求连续失败达到 5 次后,目标群/私聊能收到失败通知与失败原因。 +- 通知后任务终止并完成容器与 workspace 清理。 + +### 11.6 文档完善验证 +- 功能实现后,仓库文档已同步更新(`README.md`、`src/Undefined/skills/README.md`、`src/Undefined/skills/agents/README.md`)。 +- `source_type=empty` 产物中的 `README.md` 内容完整且可指导运行。 + +--- + +## 12. 风险与注意事项 + +1. 当前不做命令白名单,执行能力较强,需明确仅在可信场景下使用。 +2. 当前全程开网,任务可能访问外部网络,需在部署侧做好总控审计。 +3. OneBot 各实现上传文件 API 兼容性差异较大,需要做动作名适配和回退。 +4. Ubuntu 基础镜像默认工具较少,若业务常用 `git/zip/tar`,需要在容器准备阶段自动安装或改用预构建镜像。 + +--- + +## 13. 分阶段实施顺序 + +### Phase 1(基础链路) +- 建 agent 骨架 + 7 工具框架(含 `todo`) +- 启动前残留清理(`data/code_delivery/*` + 命名匹配容器) +- 容器前后缀命名机制 +- 容器创建/exec/销毁 +- workspace/task_uuid 生命周期 + +### Phase 2(交付闭环) +- `end` 打包实现(含黑名单) +- OneBot 文件上传接口 +- 成功后自动清理 +- 文档补全约束落地(空仓必须有 README) + +### Phase 3(配置与稳定性) +- `config.toml.example` + `Config` 解析 +- LLM 单次请求连续失败 5 次通知机制 +- 日志增强、异常路径补全 +- 仓库文档完善与同步 +- 回归测试与验收 + +--- + +## 14. 当前状态说明 + +本文件为“持久化实施计划”,用于后续开发执行。 +当前未开始修改功能代码。 diff --git a/config.toml.example b/config.toml.example index 32b37c1..af4ba4e 100644 --- a/config.toml.example +++ b/config.toml.example @@ -383,6 +383,49 @@ auto_extract_group_ids = [] # en: Private chat allowlist for auto-extraction (empty = follow global access.allowed_private_ids). auto_extract_private_ids = [] +# zh: Code Delivery Agent 配置(代码交付 Agent,在 Docker 容器中编写代码并打包上传)。 +# en: Code Delivery Agent settings (writes code in Docker containers and delivers packaged results). +[code_delivery] +# zh: 是否启用 Code Delivery Agent。 +# en: Enable Code Delivery Agent. +enabled = true +# zh: 任务根目录(相对于工作目录)。 +# en: Task root directory (relative to working directory). +task_root = "data/code_delivery" +# zh: Docker 镜像名称。 +# en: Docker image name. +docker_image = "ubuntu:24.04" +# zh: 容器名前缀。 +# en: Container name prefix. +container_name_prefix = "code_delivery_" +# zh: 容器名后缀。 +# en: Container name suffix. +container_name_suffix = "_runner" +# zh: 命令默认超时时间(秒),0 表示不限时。 +# en: Default command timeout (seconds), 0 means no limit. +default_command_timeout_seconds = 0 +# zh: 命令输出最大字符数。 +# en: Max command output characters. +max_command_output_chars = 20000 +# zh: 默认归档格式(zip 或 tar.gz)。 +# en: Default archive format (zip or tar.gz). +default_archive_format = "zip" +# zh: 归档文件最大大小(MB)。 +# en: Max archive file size (MB). +max_archive_size_mb = 200 +# zh: 任务完成后是否清理工作区和容器。 +# en: Clean up workspace and container after task completion. +cleanup_on_finish = true +# zh: 启动前是否清理残留工作区和容器。 +# en: Clean up leftover workspaces and containers on startup. +cleanup_on_start = true +# zh: 单次 LLM 请求最大连续失败次数(达到后发送失败通知并终止任务)。 +# en: Max consecutive LLM failures per request (sends failure notification and terminates task when reached). +llm_max_retries_per_request = 5 +# zh: LLM 连续失败时是否向目标发送通知。 +# en: Send notification to target on consecutive LLM failures. +notify_on_llm_failure = true + # zh: WebUI 设置。仅在使用 `Undefined-webui` 启动 WebUI 时生效;直接运行 `Undefined` 可忽略本段。 # en: WebUI settings. Only used when starting WebUI via `Undefined-webui`; ignore this section if you run `Undefined` directly. [webui] diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index c60071d..8a78f01 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -408,6 +408,20 @@ class Config: webui_url: str webui_port: int webui_password: str + # Code Delivery Agent + code_delivery_enabled: bool + code_delivery_task_root: str + code_delivery_docker_image: str + code_delivery_container_name_prefix: str + code_delivery_container_name_suffix: str + code_delivery_command_timeout: int + code_delivery_max_command_output: int + code_delivery_default_archive_format: str + code_delivery_max_archive_size_mb: int + code_delivery_cleanup_on_finish: bool + code_delivery_cleanup_on_start: bool + code_delivery_llm_max_retries: int + code_delivery_notify_on_llm_failure: bool # Bilibili 视频提取 bilibili_auto_extract_enabled: bool bilibili_cookie: str @@ -861,6 +875,60 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi _get_value(data, ("bilibili", "auto_extract_private_ids"), None) ) + # Code Delivery Agent 配置 + code_delivery_enabled = _coerce_bool( + _get_value(data, ("code_delivery", "enabled"), None), True + ) + code_delivery_task_root = _coerce_str( + _get_value(data, ("code_delivery", "task_root"), None), + "data/code_delivery", + ) + code_delivery_docker_image = _coerce_str( + _get_value(data, ("code_delivery", "docker_image"), None), + "ubuntu:24.04", + ) + code_delivery_container_name_prefix = _coerce_str( + _get_value(data, ("code_delivery", "container_name_prefix"), None), + "code_delivery_", + ) + code_delivery_container_name_suffix = _coerce_str( + _get_value(data, ("code_delivery", "container_name_suffix"), None), + "_runner", + ) + code_delivery_command_timeout = _coerce_int( + _get_value( + data, ("code_delivery", "default_command_timeout_seconds"), None + ), + 600, + ) + code_delivery_max_command_output = _coerce_int( + _get_value(data, ("code_delivery", "max_command_output_chars"), None), + 20000, + ) + code_delivery_default_archive_format = _coerce_str( + _get_value(data, ("code_delivery", "default_archive_format"), None), + "zip", + ) + if code_delivery_default_archive_format not in ("zip", "tar.gz"): + code_delivery_default_archive_format = "zip" + code_delivery_max_archive_size_mb = _coerce_int( + _get_value(data, ("code_delivery", "max_archive_size_mb"), None), 200 + ) + code_delivery_cleanup_on_finish = _coerce_bool( + _get_value(data, ("code_delivery", "cleanup_on_finish"), None), True + ) + code_delivery_cleanup_on_start = _coerce_bool( + _get_value(data, ("code_delivery", "cleanup_on_start"), None), True + ) + code_delivery_llm_max_retries = _coerce_int( + _get_value(data, ("code_delivery", "llm_max_retries_per_request"), None), + 5, + ) + code_delivery_notify_on_llm_failure = _coerce_bool( + _get_value(data, ("code_delivery", "notify_on_llm_failure"), None), + True, + ) + webui_settings = load_webui_settings(config_path) if strict: @@ -937,6 +1005,19 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi webui_url=webui_settings.url, webui_port=webui_settings.port, webui_password=webui_settings.password, + code_delivery_enabled=code_delivery_enabled, + code_delivery_task_root=code_delivery_task_root, + code_delivery_docker_image=code_delivery_docker_image, + code_delivery_container_name_prefix=code_delivery_container_name_prefix, + code_delivery_container_name_suffix=code_delivery_container_name_suffix, + code_delivery_command_timeout=code_delivery_command_timeout, + code_delivery_max_command_output=code_delivery_max_command_output, + code_delivery_default_archive_format=code_delivery_default_archive_format, + code_delivery_max_archive_size_mb=code_delivery_max_archive_size_mb, + code_delivery_cleanup_on_finish=code_delivery_cleanup_on_finish, + code_delivery_cleanup_on_start=code_delivery_cleanup_on_start, + code_delivery_llm_max_retries=code_delivery_llm_max_retries, + code_delivery_notify_on_llm_failure=code_delivery_notify_on_llm_failure, bilibili_auto_extract_enabled=bilibili_auto_extract_enabled, bilibili_cookie=bilibili_cookie, bilibili_prefer_quality=bilibili_prefer_quality, diff --git a/src/Undefined/main.py b/src/Undefined/main.py index 71deeb1..b15f8ef 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -200,6 +200,22 @@ async def main() -> None: logger.exception("[初始化错误] 组件初始化期间发生异常: %s", exc) sys.exit(1) + # Code Delivery Agent 残留清理(程序启动时执行一次) + if config.code_delivery_enabled and config.code_delivery_cleanup_on_start: + try: + from Undefined.skills.agents.code_delivery_agent.handler import ( + _cleanup_residual, + ) + + await _cleanup_residual( + config.code_delivery_task_root, + config.code_delivery_container_name_prefix, + config.code_delivery_container_name_suffix, + ) + logger.info("[CodeDelivery] 启动残留清理完成") + except Exception as exc: + logger.warning("[CodeDelivery] 启动残留清理失败: %s", exc) + logger.info("[启动] 机器人已准备就绪,开始连接 OneBot 服务...") config_manager = get_config_manager() diff --git a/src/Undefined/onebot.py b/src/Undefined/onebot.py index f39f28c..006e03c 100644 --- a/src/Undefined/onebot.py +++ b/src/Undefined/onebot.py @@ -430,6 +430,87 @@ async def send_like(self, user_id: int, times: int = 1) -> dict[str, Any]: """ return await self._call_api("send_like", {"user_id": user_id, "times": times}) + async def upload_group_file( + self, + group_id: int, + file_path: str, + name: str | None = None, + ) -> dict[str, Any]: + """上传文件到群聊 + + 参数: + group_id: 群号 + file_path: 本地文件绝对路径 + name: 文件名(可选,默认使用原文件名) + """ + from pathlib import Path as _Path + + file_name = name or _Path(file_path).name + try: + return await self._call_api( + "upload_group_file", + { + "group_id": group_id, + "file": file_path, + "name": file_name, + }, + ) + except RuntimeError: + # 回退:尝试用文件消息段发送 + logger.warning( + "[文件上传] upload_group_file 失败,尝试文件消息段回退: group=%s", + group_id, + ) + return await self.send_group_message( + group_id, + [ + { + "type": "file", + "data": {"file": f"file://{file_path}", "name": file_name}, + } + ], + ) + + async def upload_private_file( + self, + user_id: int, + file_path: str, + name: str | None = None, + ) -> dict[str, Any]: + """上传文件到私聊 + + 参数: + user_id: 用户 QQ 号 + file_path: 本地文件绝对路径 + name: 文件名(可选,默认使用原文件名) + """ + from pathlib import Path as _Path + + file_name = name or _Path(file_path).name + try: + return await self._call_api( + "upload_private_file", + { + "user_id": user_id, + "file": file_path, + "name": file_name, + }, + ) + except RuntimeError: + logger.warning( + "[文件上传] upload_private_file 失败,尝试文件消息段回退: user=%s", + user_id, + ) + return await self.send_private_message( + user_id, + [ + { + "type": "file", + "data": {"file": f"file://{file_path}", "name": file_name}, + } + ], + ) + async def send_group_sign(self, group_id: int) -> dict[str, Any]: """执行群打卡 diff --git a/src/Undefined/skills/agents/code_delivery_agent/config.json b/src/Undefined/skills/agents/code_delivery_agent/config.json new file mode 100644 index 0000000..dd24246 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/config.json @@ -0,0 +1,39 @@ +{ + "type": "function", + "function": { + "name": "code_delivery_agent", + "description": "代码交付助手,可在隔离的 Docker 容器中编写代码、执行命令、运行验证,最终打包并上传到指定群聊或私聊。支持从 Git 仓库克隆或空目录开始。", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "任务目标描述,例如:'用 Python 写一个 HTTP 服务器,监听 8080 端口,返回 Hello World'" + }, + "source_type": { + "type": "string", + "enum": ["git", "empty"], + "description": "初始化来源类型:'git' 从 Git 仓库克隆,'empty' 从空目录开始" + }, + "git_url": { + "type": "string", + "description": "Git 仓库 URL(source_type=git 时必填)" + }, + "git_ref": { + "type": "string", + "description": "Git 分支/tag/commit(可选,默认为仓库默认分支)" + }, + "target_type": { + "type": "string", + "enum": ["group", "private"], + "description": "交付目标类型:'group' 群聊,'private' 私聊" + }, + "target_id": { + "type": "integer", + "description": "交付目标 ID(群号或 QQ 号)" + } + }, + "required": ["prompt", "source_type", "target_type", "target_id"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py new file mode 100644 index 0000000..18ba69a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -0,0 +1,482 @@ +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, +) -> None: + """创建并启动 Docker 容器。""" + rc, stdout, stderr = await _run_cmd( + "docker", + "run", + "-d", + "--name", + container_name, + "-v", + f"{workspace.resolve()}:/workspace", + "-v", + f"{tmpfs_dir.resolve()}:/tmpfs", + "-w", + "/workspace", + docker_image, + "sleep", + "infinity", + timeout=120, + ) + if rc != 0: + raise RuntimeError(f"创建容器失败: {stderr or stdout}") + logger.info("[CodeDelivery] 容器已创建: %s", container_name) + + +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 入口 +# --------------------------------------------------------------------------- + + +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_start = True + cleanup_on_finish = True + llm_max_retries = 5 + notify_on_failure = True + + 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_start = getattr(config, "code_delivery_cleanup_on_start", True) + 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) + + # 创建任务目录 + 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 + context["target_type"] = target_type + context["target_id"] = target_id + + try: + # 创建容器 + await _create_container(container_name, workspace, tmpfs_dir, docker_image) + + # 初始化工作区 + 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 失败重试计数 + 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(): + shutil.rmtree(task_dir) + logger.info("[CodeDelivery] 已清理任务目录: %s", task_dir) + except Exception as exc: + logger.warning("[CodeDelivery] 清理任务目录失败: %s", exc) + + +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") + 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 + + 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 + + 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 + continue + + 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 + + messages.append( + {"role": "assistant", "content": content, "tool_calls": tool_calls} + ) + + 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, + } + ) + + 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) + + return "达到最大迭代次数" diff --git a/src/Undefined/skills/agents/code_delivery_agent/intro.md b/src/Undefined/skills/agents/code_delivery_agent/intro.md new file mode 100644 index 0000000..c6062e9 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/intro.md @@ -0,0 +1 @@ +代码交付 Agent,在隔离 Docker 容器中完成代码编写、命令执行、验证测试,最终打包上传到群聊或私聊。支持从 Git 仓库或空目录初始化。 diff --git a/src/Undefined/skills/agents/code_delivery_agent/prompt.md b/src/Undefined/skills/agents/code_delivery_agent/prompt.md new file mode 100644 index 0000000..d4f0efc --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/prompt.md @@ -0,0 +1,43 @@ +你是一个专业的代码交付助手,在隔离的 Docker 容器环境中工作。 + +## 核心职责 +- 根据用户需求编写、修改、调试代码 +- 在容器内执行命令验证代码正确性 +- 任务完成后打包交付 + +## 工作流程 +1. **理解需求**:仔细分析用户的任务目标 +2. **规划方案**:使用 `todo` 工具记录待办事项,拆解任务步骤 +3. **编写代码**:使用 `write` 工具创建/修改文件 +4. **验证测试**:使用 `run_bash_command` 安装依赖、编译、运行测试 +5. **检查结果**:使用 `read`/`glob`/`grep` 工具检查代码和输出 +6. **补全文档**:确保项目包含 README.md(至少含使用方式与运行说明) +7. **交付打包**:使用 `end` 工具打包并上传 + +## 工作原则 +- 每一步都要验证,不要假设命令会成功 +- 遇到错误时分析原因并修复,不要跳过 +- 代码要有合理的结构和注释 +- 安装依赖前先检查容器环境(`run_bash_command` 执行 `which`/`apt list` 等) +- 使用 `todo` 工具持续追踪进度,保持任务可见性 + +## 文档要求 +- 如果是从空目录开始(source_type=empty),必须创建 README.md +- README.md 至少包含:项目说明、使用方式、运行说明 +- 任务完成前确保文档与代码一致 + +## 打包建议 +调用 `end` 时,建议排除以下目录: +- `.git/**` +- `.venv/**` +- `__pycache__/**` +- `.pytest_cache/**` +- `node_modules/**` +- `.mypy_cache/**` +- `.ruff_cache/**` + +## 注意事项 +- 所有文件操作限制在 `/workspace` 目录内 +- 所有命令在 Docker 容器内执行,默认工作目录为 `/workspace` +- 容器基于 Ubuntu 24.04,基础工具可能需要先安装(如 git、python3、nodejs 等) +- 容器全程联网,可以使用 apt、pip、npm 等包管理器 diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json new file mode 100644 index 0000000..d0914ca --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json @@ -0,0 +1,33 @@ +{ + "type": "function", + "function": { + "name": "end", + "description": "结束任务,按黑名单打包工作区并上传到目标群聊/私聊。调用后任务结束,容器和工作区将被清理。", + "parameters": { + "type": "object", + "properties": { + "exclude_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "打包排除的 glob 模式列表,例如 ['.git/**', 'node_modules/**', '__pycache__/**']" + }, + "archive_name": { + "type": "string", + "description": "可选:归档文件名(不含扩展名,默认为 'delivery')" + }, + "archive_format": { + "type": "string", + "enum": ["zip", "tar.gz"], + "description": "可选:归档格式(默认 zip)" + }, + "summary": { + "type": "string", + "description": "可选:任务完成摘要,将随文件一起发送" + } + }, + "required": ["exclude_patterns"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py new file mode 100644 index 0000000..92e7019 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import hashlib +import logging +import os +import tarfile +import zipfile +from fnmatch import fnmatch +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _should_exclude(rel_path: str, patterns: list[str]) -> bool: + """检查路径是否匹配任一排除模式。""" + for pattern in patterns: + if fnmatch(rel_path, pattern): + return True + # 也检查路径的每一级 + parts = rel_path.split("/") + for i in range(len(parts)): + partial = "/".join(parts[: i + 1]) + if fnmatch(partial, pattern) or fnmatch(partial + "/", pattern): + return True + return False + + +def _file_hash(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """结束任务,打包工作区并上传。""" + + exclude_patterns: list[str] = args.get("exclude_patterns", []) + if not isinstance(exclude_patterns, list): + exclude_patterns = [] + archive_name = str(args.get("archive_name", "")).strip() or "delivery" + archive_format = str(args.get("archive_format", "")).strip().lower() or "zip" + summary = str(args.get("summary", "")).strip() + + workspace: Path | None = context.get("workspace") + task_dir: Path | None = context.get("task_dir") + if not workspace or not task_dir: + return "错误:workspace 或 task_dir 未设置" + + ws_resolved = workspace.resolve() + if not ws_resolved.exists(): + return "错误:workspace 目录不存在" + + # 收集要打包的文件 + files_to_pack: list[Path] = [] + for root, _dirs, filenames in os.walk(ws_resolved): + for fname in filenames: + full = Path(root) / fname + rel = str(full.relative_to(ws_resolved)) + if not _should_exclude(rel, exclude_patterns): + files_to_pack.append(full) + + if not files_to_pack: + return "错误:打包后无文件(可能排除规则过于严格)" + + # 打包 + artifacts_dir = task_dir / "artifacts" + artifacts_dir.mkdir(parents=True, exist_ok=True) + + if archive_format == "tar.gz": + archive_path = artifacts_dir / f"{archive_name}.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + for f in files_to_pack: + arcname = str(f.relative_to(ws_resolved)) + tar.add(f, arcname=arcname) + else: + archive_path = artifacts_dir / f"{archive_name}.zip" + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in files_to_pack: + arcname = str(f.relative_to(ws_resolved)) + zf.write(f, arcname=arcname) + + archive_size = archive_path.stat().st_size + archive_hash = _file_hash(str(archive_path)) + + # 检查大小限制 + config = context.get("config") + max_size_mb: int = 200 + if config: + max_size_mb = getattr(config, "code_delivery_max_archive_size_mb", 200) + if archive_size > max_size_mb * 1024 * 1024: + return ( + f"错误:归档文件过大 ({archive_size / 1024 / 1024:.1f}MB)," + f"超过限制 {max_size_mb}MB" + ) + + # 上传 + onebot_client = context.get("onebot_client") + target_type: str = context.get("target_type", "") + target_id: int = context.get("target_id", 0) + + upload_status = "未上传" + if onebot_client and target_type and target_id: + try: + abs_path = str(archive_path.resolve()) + if target_type == "group": + await onebot_client.upload_group_file( + target_id, abs_path, archive_path.name + ) + else: + await onebot_client.upload_private_file( + target_id, abs_path, archive_path.name + ) + upload_status = "上传成功" + + # 发送摘要消息 + if summary: + msg = f"📦 代码交付完成\n\n{summary}\n\n文件: {archive_path.name} ({archive_size / 1024:.1f}KB)" + 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.exception("上传文件失败") + upload_status = f"上传失败: {exc}" + else: + upload_status = "未配置上传目标,文件已保留在本地" + + # 标记会话结束 + context["conversation_ended"] = True + + return ( + f"归档: {archive_path.name}\n" + f"大小: {archive_size / 1024:.1f}KB\n" + f"文件数: {len(files_to_pack)}\n" + f"SHA256: {archive_hash[:16]}...\n" + f"状态: {upload_status}" + ) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json new file mode 100644 index 0000000..4819b78 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json @@ -0,0 +1,21 @@ +{ + "type": "function", + "function": { + "name": "glob", + "description": "按 glob 模式匹配工作区内的文件。", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "glob 匹配模式,例如 '**/*.py'、'src/**/*.js'" + }, + "base_path": { + "type": "string", + "description": "可选:搜索起始路径(相对于 /workspace,默认为 /workspace 根目录)" + } + }, + "required": ["pattern"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py new file mode 100644 index 0000000..5557a3a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +MAX_RESULTS = 500 + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """按 glob 模式匹配工作区内的文件。""" + + pattern = str(args.get("pattern", "")).strip() + base_path_rel = str(args.get("base_path", "")).strip() + + if not pattern: + return "错误:pattern 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + ws_resolved = workspace.resolve() + + if base_path_rel: + search_root = (workspace / base_path_rel).resolve() + if not str(search_root).startswith(str(ws_resolved)): + return "错误:base_path 越界" + if not search_root.is_dir(): + return f"错误:base_path 不存在或不是目录: {base_path_rel}" + else: + search_root = ws_resolved + + try: + matches: list[str] = [] + for p in search_root.glob(pattern): + if not str(p.resolve()).startswith(str(ws_resolved)): + continue + rel = p.relative_to(ws_resolved) + matches.append(str(rel)) + if len(matches) >= MAX_RESULTS: + break + + matches.sort() + + if not matches: + return "未找到匹配文件" + + result = "\n".join(matches) + if len(matches) >= MAX_RESULTS: + result += f"\n\n... (结果已截断,共显示 {MAX_RESULTS} 条)" + return result + except Exception as exc: + logger.exception("glob 匹配失败: %s", pattern) + return f"glob 匹配失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json new file mode 100644 index 0000000..4584e32 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json @@ -0,0 +1,33 @@ +{ + "type": "function", + "function": { + "name": "grep", + "description": "在工作区内搜索文件内容。", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "搜索模式(字符串或正则表达式)" + }, + "path": { + "type": "string", + "description": "可选:搜索路径(相对于 /workspace,默认搜索整个工作区)" + }, + "is_regex": { + "type": "boolean", + "description": "是否为正则表达式(默认 false,按字面量匹配)" + }, + "case_sensitive": { + "type": "boolean", + "description": "是否区分大小写(默认 true)" + }, + "max_matches": { + "type": "integer", + "description": "最大匹配数(默认 100)" + } + }, + "required": ["pattern"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py new file mode 100644 index 0000000..b8734cb --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +MAX_MATCHES_DEFAULT = 100 +MAX_LINE_LEN = 500 + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """在工作区内搜索文件内容。""" + + pattern = str(args.get("pattern", "")).strip() + path_rel = str(args.get("path", "")).strip() + is_regex = bool(args.get("is_regex", False)) + case_sensitive = bool(args.get("case_sensitive", True)) + max_matches = int(args.get("max_matches", MAX_MATCHES_DEFAULT)) + + if not pattern: + return "错误:pattern 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + ws_resolved = workspace.resolve() + + if path_rel: + search_root = (workspace / path_rel).resolve() + if not str(search_root).startswith(str(ws_resolved)): + return "错误:path 越界" + else: + search_root = ws_resolved + + flags = 0 if case_sensitive else re.IGNORECASE + try: + if is_regex: + compiled = re.compile(pattern, flags) + else: + compiled = re.compile(re.escape(pattern), flags) + except re.error as exc: + return f"正则表达式错误: {exc}" + + matches: list[str] = [] + try: + files = search_root.rglob("*") if search_root.is_dir() else [search_root] + for file_path in files: + if not file_path.is_file(): + continue + if not str(file_path.resolve()).startswith(str(ws_resolved)): + continue + try: + text = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + + rel = file_path.relative_to(ws_resolved) + for lineno, line in enumerate(text.splitlines(), 1): + if compiled.search(line): + display = line[:MAX_LINE_LEN] + if len(line) > MAX_LINE_LEN: + display += "..." + matches.append(f"{rel}:{lineno}:{display}") + if len(matches) >= max_matches: + break + if len(matches) >= max_matches: + break + except Exception as exc: + logger.exception("grep 搜索失败") + return f"搜索失败: {exc}" + + if not matches: + return "未找到匹配内容" + + result = "\n".join(matches) + if len(matches) >= max_matches: + result += f"\n\n... (结果已截断,共显示 {max_matches} 条)" + return result diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json new file mode 100644 index 0000000..522316d --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json @@ -0,0 +1,21 @@ +{ + "type": "function", + "function": { + "name": "read", + "description": "读取工作区内文件的文本内容。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径(相对于 /workspace)" + }, + "max_chars": { + "type": "integer", + "description": "可选:最大字符数限制,超出则截断" + } + }, + "required": ["path"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py new file mode 100644 index 0000000..049c686 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """读取工作区内文件的文本内容。""" + + rel_path = str(args.get("path", "")).strip() + max_chars: int | None = args.get("max_chars") + + if not rel_path: + return "错误:path 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + full_path = (workspace / rel_path).resolve() + if not str(full_path).startswith(str(workspace.resolve())): + return "错误:路径越界,只能读取 /workspace 下的文件" + + if not full_path.exists(): + return f"文件不存在: {rel_path}" + if full_path.is_dir(): + return f"错误:{rel_path} 是目录,不是文件" + + try: + async with aiofiles.open( + full_path, "r", encoding="utf-8", errors="replace" + ) as f: + content = await f.read() + + total = len(content) + if max_chars and total > max_chars: + content = content[:max_chars] + content += f"\n\n... (共 {total} 字符,已截断到前 {max_chars} 字符)" + + return content + except Exception as exc: + logger.exception("读取文件失败: %s", rel_path) + return f"读取文件失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json new file mode 100644 index 0000000..06810fc --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json @@ -0,0 +1,25 @@ +{ + "type": "function", + "function": { + "name": "run_bash_command", + "description": "在 Docker 容器内执行 bash 命令。可用于安装依赖、编译、运行测试等。", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的 bash 命令" + }, + "timeout_seconds": { + "type": "integer", + "description": "命令超时时间(秒),默认使用配置值" + }, + "workdir": { + "type": "string", + "description": "可选:工作目录(容器内绝对路径,默认 /workspace)" + } + }, + "required": ["command"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py new file mode 100644 index 0000000..28a4ac0 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """在 Docker 容器内执行 bash 命令。""" + + command = str(args.get("command", "")).strip() + if not command: + return "错误:command 不能为空" + + container_name: str | None = context.get("container_name") + if not container_name: + return "错误:容器未启动" + + config = context.get("config") + default_timeout: int = 600 + max_output: int = 20000 + if config: + default_timeout = getattr(config, "code_delivery_command_timeout", 600) + max_output = getattr(config, "code_delivery_max_command_output", 20000) + + timeout = int(args.get("timeout_seconds", 0)) or default_timeout + workdir = str(args.get("workdir", "")).strip() or "/workspace" + + docker_cmd = [ + "docker", + "exec", + "-w", + workdir, + container_name, + "bash", + "-lc", + command, + ] + + try: + proc = await asyncio.create_subprocess_exec( + *docker_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + if timeout > 0: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + else: + # timeout <= 0 表示不限时 + stdout_bytes, stderr_bytes = await proc.communicate() + except asyncio.TimeoutError: + try: + proc.kill() + except Exception: + pass + return f"命令超时({timeout}s): {command}" + except Exception as exc: + logger.exception("执行命令失败: %s", command) + return f"执行命令失败: {exc}" + + stdout = stdout_bytes.decode("utf-8", errors="replace") + stderr = stderr_bytes.decode("utf-8", errors="replace") + exit_code = proc.returncode + + # 截断输出 + if len(stdout) > max_output: + stdout = ( + stdout[:max_output] + f"\n... (stdout 已截断,共 {len(stdout_bytes)} 字节)" + ) + if len(stderr) > max_output: + stderr = ( + stderr[:max_output] + f"\n... (stderr 已截断,共 {len(stderr_bytes)} 字节)" + ) + + parts: list[str] = [f"exit_code: {exit_code}"] + if stdout.strip(): + parts.append(f"stdout:\n{stdout.strip()}") + if stderr.strip(): + parts.append(f"stderr:\n{stderr.strip()}") + + return "\n\n".join(parts) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/todo/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/config.json new file mode 100644 index 0000000..671f0c9 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/config.json @@ -0,0 +1,31 @@ +{ + "type": "function", + "function": { + "name": "todo", + "description": "记录与追踪任务待办和进度。在长任务中持续追踪未做/进行中/已完成项。", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "list", "update", "remove", "clear"], + "description": "操作类型:add(添加)、list(列出)、update(更新状态)、remove(删除)、clear(清空)" + }, + "item_id": { + "type": "integer", + "description": "待办项 ID(update/remove 时必填)" + }, + "content": { + "type": "string", + "description": "待办内容(add 时必填)" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "done"], + "description": "状态(update 时可选,默认 pending)" + } + }, + "required": ["action"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py new file mode 100644 index 0000000..bcf3a0a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +logger = logging.getLogger(__name__) + + +def _todo_path(context: dict[str, Any]) -> Path: + task_dir: Path = context["task_dir"] + return task_dir / "todo.json" + + +async def _load_todos(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + try: + async with aiofiles.open(path, "r", encoding="utf-8") as f: + data = json.loads(await f.read()) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +async def _save_todos(path: Path, todos: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(path, "w", encoding="utf-8") as f: + await f.write(json.dumps(todos, ensure_ascii=False, indent=2)) + + +def _next_id(todos: list[dict[str, Any]]) -> int: + if not todos: + return 1 + return int(max(item.get("id", 0) for item in todos)) + 1 + + +def _format_todos(todos: list[dict[str, Any]]) -> str: + if not todos: + return "待办列表为空" + status_icons = {"pending": "⬜", "in_progress": "🔄", "done": "✅"} + lines: list[str] = [] + for item in todos: + icon = status_icons.get(item.get("status", "pending"), "⬜") + lines.append(f"{icon} [{item['id']}] {item['content']} ({item['status']})") + return "\n".join(lines) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """记录与追踪任务待办和进度。""" + + action = str(args.get("action", "")).strip().lower() + if not action: + return "错误:action 不能为空" + + if "task_dir" not in context: + return "错误:task_dir 未设置" + + path = _todo_path(context) + todos = await _load_todos(path) + + if action == "list": + return _format_todos(todos) + + if action == "add": + content = str(args.get("content", "")).strip() + if not content: + return "错误:add 操作需要 content" + new_item = {"id": _next_id(todos), "content": content, "status": "pending"} + todos.append(new_item) + await _save_todos(path, todos) + return f"已添加: [{new_item['id']}] {content}" + + if action == "update": + item_id = args.get("item_id") + if item_id is None: + return "错误:update 操作需要 item_id" + item_id = int(item_id) + status = str(args.get("status", "in_progress")).strip() + for item in todos: + if item["id"] == item_id: + item["status"] = status + await _save_todos(path, todos) + return f"已更新: [{item_id}] -> {status}" + return f"未找到 ID={item_id} 的待办项" + + if action == "remove": + item_id = args.get("item_id") + if item_id is None: + return "错误:remove 操作需要 item_id" + item_id = int(item_id) + original_len = len(todos) + todos = [item for item in todos if item["id"] != item_id] + if len(todos) == original_len: + return f"未找到 ID={item_id} 的待办项" + await _save_todos(path, todos) + return f"已删除 ID={item_id}" + + if action == "clear": + await _save_todos(path, []) + return "待办列表已清空" + + return f"未知操作: {action}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json new file mode 100644 index 0000000..be74d4b --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json @@ -0,0 +1,26 @@ +{ + "type": "function", + "function": { + "name": "write", + "description": "写入文件到工作区。支持覆盖写入和追加写入。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径(相对于 /workspace)" + }, + "content": { + "type": "string", + "description": "要写入的内容" + }, + "mode": { + "type": "string", + "enum": ["overwrite", "append"], + "description": "写入模式:overwrite(覆盖,默认)或 append(追加)" + } + }, + "required": ["path", "content"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py new file mode 100644 index 0000000..1865d40 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """写入文件到工作区。""" + + rel_path = str(args.get("path", "")).strip() + content = str(args.get("content", "")) + mode = str(args.get("mode", "overwrite")).strip().lower() + + if not rel_path: + return "错误:path 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + full_path = (workspace / rel_path).resolve() + if not str(full_path).startswith(str(workspace.resolve())): + return "错误:路径越界,只能写入 /workspace 下的文件" + + try: + full_path.parent.mkdir(parents=True, exist_ok=True) + + if mode == "append": + async with aiofiles.open(full_path, "a", encoding="utf-8") as f: + await f.write(content) + else: + async with aiofiles.open(full_path, "w", encoding="utf-8") as f: + await f.write(content) + + byte_count = len(content.encode("utf-8")) + action = "追加" if mode == "append" else "写入" + return f"已{action} {byte_count} 字节到 {rel_path}" + except Exception as exc: + logger.exception("写入文件失败: %s", rel_path) + return f"写入文件失败: {exc}" From 0b7ecd9a7a19ff84a43c303303ab893f9142cfa5 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 19:10:44 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(code=5Fdelivery=5Fagent.todo):=20todo?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E4=B8=B2=E8=A1=8C=E5=8C=96=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/code_delivery_agent/handler.py | 2 - .../code_delivery_agent/tools/todo/handler.py | 94 +++++++++++-------- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py index 18ba69a..324897b 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -206,7 +206,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: docker_image = "ubuntu:24.04" prefix = CONTAINER_PREFIX_DEFAULT suffix = CONTAINER_SUFFIX_DEFAULT - cleanup_on_start = True cleanup_on_finish = True llm_max_retries = 5 notify_on_failure = True @@ -218,7 +217,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: 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_start = getattr(config, "code_delivery_cleanup_on_start", True) 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) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py index bcf3a0a..e07991a 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import logging from pathlib import Path @@ -10,6 +11,15 @@ logger = logging.getLogger(__name__) +def _get_lock(context: dict[str, Any]) -> asyncio.Lock: + """从 context 获取或创建 todo 专用锁,防止并发读写竞态。""" + lock: asyncio.Lock | None = context.get("_todo_lock") + if lock is None: + lock = asyncio.Lock() + context["_todo_lock"] = lock + return lock + + def _todo_path(context: dict[str, Any]) -> Path: task_dir: Path = context["task_dir"] return task_dir / "todo.json" @@ -59,48 +69,50 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if "task_dir" not in context: return "错误:task_dir 未设置" - path = _todo_path(context) - todos = await _load_todos(path) - - if action == "list": - return _format_todos(todos) - - if action == "add": - content = str(args.get("content", "")).strip() - if not content: - return "错误:add 操作需要 content" - new_item = {"id": _next_id(todos), "content": content, "status": "pending"} - todos.append(new_item) - await _save_todos(path, todos) - return f"已添加: [{new_item['id']}] {content}" - - if action == "update": - item_id = args.get("item_id") - if item_id is None: - return "错误:update 操作需要 item_id" - item_id = int(item_id) - status = str(args.get("status", "in_progress")).strip() - for item in todos: - if item["id"] == item_id: - item["status"] = status - await _save_todos(path, todos) - return f"已更新: [{item_id}] -> {status}" - return f"未找到 ID={item_id} 的待办项" - - if action == "remove": - item_id = args.get("item_id") - if item_id is None: - return "错误:remove 操作需要 item_id" - item_id = int(item_id) - original_len = len(todos) - todos = [item for item in todos if item["id"] != item_id] - if len(todos) == original_len: + lock = _get_lock(context) + async with lock: + path = _todo_path(context) + todos = await _load_todos(path) + + if action == "list": + return _format_todos(todos) + + if action == "add": + content = str(args.get("content", "")).strip() + if not content: + return "错误:add 操作需要 content" + new_item = {"id": _next_id(todos), "content": content, "status": "pending"} + todos.append(new_item) + await _save_todos(path, todos) + return f"已添加: [{new_item['id']}] {content}" + + if action == "update": + item_id = args.get("item_id") + if item_id is None: + return "错误:update 操作需要 item_id" + item_id = int(item_id) + status = str(args.get("status", "in_progress")).strip() + for item in todos: + if item["id"] == item_id: + item["status"] = status + await _save_todos(path, todos) + return f"已更新: [{item_id}] -> {status}" return f"未找到 ID={item_id} 的待办项" - await _save_todos(path, todos) - return f"已删除 ID={item_id}" - if action == "clear": - await _save_todos(path, []) - return "待办列表已清空" + if action == "remove": + item_id = args.get("item_id") + if item_id is None: + return "错误:remove 操作需要 item_id" + item_id = int(item_id) + original_len = len(todos) + todos = [item for item in todos if item["id"] != item_id] + if len(todos) == original_len: + return f"未找到 ID={item_id} 的待办项" + await _save_todos(path, todos) + return f"已删除 ID={item_id}" + + if action == "clear": + await _save_todos(path, []) + return "待办列表已清空" return f"未知操作: {action}" From fa25486bf3961292b8810168827d62fdd680b244 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 19:45:43 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(code=5Fdelivery=5Fagent):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=9B=B4=E5=A4=9A=E6=9C=89=E7=94=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.toml.example | 9 ++ src/Undefined/config/loader.py | 23 +++ .../agents/code_delivery_agent/handler.py | 44 ++++-- .../tools/copy/config.json | 30 ++++ .../code_delivery_agent/tools/copy/handler.py | 73 +++++++++ .../tools/delete/config.json | 21 +++ .../tools/delete/handler.py | 57 ++++++++ .../tools/diff/config.json | 25 ++++ .../code_delivery_agent/tools/diff/handler.py | 118 +++++++++++++++ .../tools/get_current_time/README.md | 9 ++ .../tools/get_current_time/config.json | 12 ++ .../tools/get_current_time/handler.py | 7 + .../tools/glob/config.json | 23 ++- .../code_delivery_agent/tools/glob/handler.py | 80 ++++++++-- .../tools/grep/config.json | 19 ++- .../code_delivery_agent/tools/grep/handler.py | 138 ++++++++++++++++-- .../tools/read/config.json | 19 ++- .../code_delivery_agent/tools/read/handler.py | 128 +++++++++++++--- .../tools/run_bash_command/config.json | 19 ++- .../tools/run_bash_command/handler.py | 86 ++++++++++- .../tools/tree/config.json | 32 ++++ .../code_delivery_agent/tools/tree/handler.py | 120 +++++++++++++++ .../tools/write/config.json | 22 ++- .../tools/write/handler.py | 121 ++++++++++++--- 24 files changed, 1139 insertions(+), 96 deletions(-) create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/copy/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/copy/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/delete/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/delete/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/diff/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/diff/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/README.md create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/handler.py create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/tree/config.json create mode 100644 src/Undefined/skills/agents/code_delivery_agent/tools/tree/handler.py diff --git a/config.toml.example b/config.toml.example index af4ba4e..753bdf2 100644 --- a/config.toml.example +++ b/config.toml.example @@ -425,6 +425,15 @@ llm_max_retries_per_request = 5 # zh: LLM 连续失败时是否向目标发送通知。 # en: Send notification to target on consecutive LLM failures. notify_on_llm_failure = true +# zh: 容器内存限制(如 "2g", "512m"),留空表示不限制。 +# en: Container memory limit (e.g., "2g", "512m"), empty means no limit. +container_memory_limit = "" +# zh: 容器 CPU 限制(如 "2.0", "0.5"),留空表示不限制。 +# en: Container CPU limit (e.g., "2.0", "0.5"), empty means no limit. +container_cpu_limit = "" +# zh: 命令黑名单(禁止执行的命令模式列表,支持通配符)。 +# en: Command blacklist (list of forbidden command patterns, supports wildcards). +command_blacklist = ["rm -rf /", ":(){ :|:& };:", "mkfs.*", "dd if=/dev/zero"] # zh: WebUI 设置。仅在使用 `Undefined-webui` 启动 WebUI 时生效;直接运行 `Undefined` 可忽略本段。 # en: WebUI settings. Only used when starting WebUI via `Undefined-webui`; ignore this section if you run `Undefined` directly. diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 8a78f01..324e5eb 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -422,6 +422,9 @@ class Config: code_delivery_cleanup_on_start: bool code_delivery_llm_max_retries: int code_delivery_notify_on_llm_failure: bool + code_delivery_container_memory_limit: str + code_delivery_container_cpu_limit: str + code_delivery_command_blacklist: list[str] # Bilibili 视频提取 bilibili_auto_extract_enabled: bool bilibili_cookie: str @@ -928,6 +931,23 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi _get_value(data, ("code_delivery", "notify_on_llm_failure"), None), True, ) + code_delivery_container_memory_limit = _coerce_str( + _get_value(data, ("code_delivery", "container_memory_limit"), None), + "", + ) + code_delivery_container_cpu_limit = _coerce_str( + _get_value(data, ("code_delivery", "container_cpu_limit"), None), + "", + ) + code_delivery_command_blacklist_raw = _get_value( + data, ("code_delivery", "command_blacklist"), None + ) + if isinstance(code_delivery_command_blacklist_raw, list): + code_delivery_command_blacklist = [ + str(x) for x in code_delivery_command_blacklist_raw + ] + else: + code_delivery_command_blacklist = [] webui_settings = load_webui_settings(config_path) @@ -1018,6 +1038,9 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi code_delivery_cleanup_on_start=code_delivery_cleanup_on_start, code_delivery_llm_max_retries=code_delivery_llm_max_retries, code_delivery_notify_on_llm_failure=code_delivery_notify_on_llm_failure, + code_delivery_container_memory_limit=code_delivery_container_memory_limit, + code_delivery_container_cpu_limit=code_delivery_container_cpu_limit, + code_delivery_command_blacklist=code_delivery_command_blacklist, bilibili_auto_extract_enabled=bilibili_auto_extract_enabled, bilibili_cookie=bilibili_cookie, bilibili_prefer_quality=bilibili_prefer_quality, diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py index 324897b..1db4c85 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -71,25 +71,39 @@ async def _create_container( workspace: Path, tmpfs_dir: Path, docker_image: str, + memory_limit: str = "", + cpu_limit: str = "", ) -> None: """创建并启动 Docker 容器。""" - rc, stdout, stderr = await _run_cmd( + cmd_args = [ "docker", "run", "-d", "--name", container_name, - "-v", - f"{workspace.resolve()}:/workspace", - "-v", - f"{tmpfs_dir.resolve()}:/tmpfs", - "-w", - "/workspace", - docker_image, - "sleep", - "infinity", - timeout=120, + ] + + # 添加资源限制 + 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) @@ -209,6 +223,8 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: 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): @@ -220,6 +236,8 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: 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()) @@ -242,7 +260,9 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: try: # 创建容器 - await _create_container(container_name, workspace, tmpfs_dir, docker_image) + 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) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/copy/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/copy/config.json new file mode 100644 index 0000000..56336a7 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/copy/config.json @@ -0,0 +1,30 @@ +{ + "type": "function", + "function": { + "name": "copy", + "description": "复制或移动文件/目录。支持递归复制目录。", + "parameters": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "源文件或目录路径(相对于 /workspace)" + }, + "destination": { + "type": "string", + "description": "目标路径(相对于 /workspace)" + }, + "mode": { + "type": "string", + "enum": ["copy", "move"], + "description": "操作模式:copy(复制)或 move(移动),默认 copy" + }, + "overwrite": { + "type": "boolean", + "description": "如果目标已存在是否覆盖(默认 false)" + } + }, + "required": ["source", "destination"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/copy/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/copy/handler.py new file mode 100644 index 0000000..ae713ea --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/copy/handler.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """复制或移动文件/目录。""" + + source_rel = str(args.get("source", "")).strip() + dest_rel = str(args.get("destination", "")).strip() + mode = str(args.get("mode", "copy")).strip().lower() + overwrite = bool(args.get("overwrite", False)) + + if not source_rel: + return "错误:source 不能为空" + if not dest_rel: + return "错误:destination 不能为空" + if mode not in ("copy", "move"): + return f"错误:未知的操作模式: {mode}" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + ws_resolved = workspace.resolve() + source_path = (workspace / source_rel).resolve() + dest_path = (workspace / dest_rel).resolve() + + # 路径安全检查 + if not str(source_path).startswith(str(ws_resolved)): + return "错误:source 路径越界" + if not str(dest_path).startswith(str(ws_resolved)): + return "错误:destination 路径越界" + + if not source_path.exists(): + return f"源路径不存在: {source_rel}" + + # 检查目标是否已存在 + if dest_path.exists() and not overwrite: + return f"目标已存在: {dest_rel}(设置 overwrite=true 以覆盖)" + + try: + if mode == "copy": + if source_path.is_dir(): + # 复制目录 + if dest_path.exists(): + shutil.rmtree(dest_path) + shutil.copytree(source_path, dest_path) + return f"已复制目录: {source_rel} -> {dest_rel}" + else: + # 复制文件 + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, dest_path) + return f"已复制文件: {source_rel} -> {dest_rel}" + else: # move + # 移动文件或目录 + if dest_path.exists(): + if dest_path.is_dir(): + shutil.rmtree(dest_path) + else: + dest_path.unlink() + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source_path), str(dest_path)) + return f"已移动: {source_rel} -> {dest_rel}" + + except Exception as exc: + logger.exception("%s 操作失败: %s -> %s", mode, source_rel, dest_rel) + return f"{mode} 操作失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/delete/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/delete/config.json new file mode 100644 index 0000000..8dd89f0 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/delete/config.json @@ -0,0 +1,21 @@ +{ + "type": "function", + "function": { + "name": "delete", + "description": "安全删除文件或目录。支持递归删除。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "要删除的文件或目录路径(相对于 /workspace)" + }, + "recursive": { + "type": "boolean", + "description": "是否递归删除目录(默认 false,删除非空目录时必须为 true)" + } + }, + "required": ["path"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/delete/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/delete/handler.py new file mode 100644 index 0000000..2978207 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/delete/handler.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """安全删除文件或目录。""" + + rel_path = str(args.get("path", "")).strip() + recursive = bool(args.get("recursive", False)) + + if not rel_path: + return "错误:path 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + full_path = (workspace / rel_path).resolve() + if not str(full_path).startswith(str(workspace.resolve())): + return "错误:路径越界,只能删除 /workspace 下的文件" + + # 额外安全检查:不允许删除 workspace 根目录 + if full_path == workspace.resolve(): + return "错误:不能删除 workspace 根目录" + + if not full_path.exists(): + return f"路径不存在: {rel_path}" + + try: + if full_path.is_dir(): + # 检查是否为空目录 + try: + entries = list(full_path.iterdir()) + is_empty = len(entries) == 0 + except Exception: + is_empty = False + + if not is_empty and not recursive: + return f"错误:{rel_path} 是非空目录,需要设置 recursive=true" + + # 递归删除目录 + shutil.rmtree(full_path) + return f"已删除目录: {rel_path} (递归)" + else: + # 删除文件 + full_path.unlink() + return f"已删除文件: {rel_path}" + + except Exception as exc: + logger.exception("删除失败: %s", rel_path) + return f"删除失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/diff/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/diff/config.json new file mode 100644 index 0000000..d227185 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/diff/config.json @@ -0,0 +1,25 @@ +{ + "type": "function", + "function": { + "name": "diff", + "description": "查看文件差异。支持两个文件对比,或文件与 git HEAD 对比。", + "parameters": { + "type": "object", + "properties": { + "path1": { + "type": "string", + "description": "第一个文件路径(相对于 /workspace)" + }, + "path2": { + "type": "string", + "description": "第二个文件路径(相对于 /workspace),或留空表示与 git HEAD 对比" + }, + "context_lines": { + "type": "integer", + "description": "上下文行数(默认 3)" + } + }, + "required": ["path1"] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/diff/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/diff/handler.py new file mode 100644 index 0000000..cba65ed --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/diff/handler.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +import difflib +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +async def _get_git_head_content( + container_name: str, rel_path: str, timeout: int = 30 +) -> str | None: + """从 git HEAD 获取文件内容。""" + try: + proc = await asyncio.create_subprocess_exec( + "docker", + "exec", + container_name, + "bash", + "-lc", + f"git show HEAD:{rel_path}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) + if proc.returncode == 0: + return stdout_b.decode("utf-8", errors="replace") + return None + except Exception: + return None + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """查看文件差异。""" + + path1_rel = str(args.get("path1", "")).strip() + path2_rel = str(args.get("path2", "")).strip() + context_lines = int(args.get("context_lines", 3)) + + if not path1_rel: + return "错误:path1 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + ws_resolved = workspace.resolve() + path1 = (workspace / path1_rel).resolve() + + # 路径安全检查 + if not str(path1).startswith(str(ws_resolved)): + return "错误:path1 路径越界" + + if not path1.exists(): + return f"文件不存在: {path1_rel}" + if path1.is_dir(): + return f"错误:{path1_rel} 是目录,不是文件" + + try: + # 读取第一个文件 + with open(path1, "r", encoding="utf-8", errors="replace") as f: + content1 = f.read() + lines1 = content1.splitlines(keepends=True) + + # 确定第二个文件的内容 + if path2_rel: + # 对比两个文件 + path2 = (workspace / path2_rel).resolve() + if not str(path2).startswith(str(ws_resolved)): + return "错误:path2 路径越界" + if not path2.exists(): + return f"文件不存在: {path2_rel}" + if path2.is_dir(): + return f"错误:{path2_rel} 是目录,不是文件" + + with open(path2, "r", encoding="utf-8", errors="replace") as f: + content2 = f.read() + lines2 = content2.splitlines(keepends=True) + + fromfile = path1_rel + tofile = path2_rel + else: + # 对比文件与 git HEAD + container_name: str | None = context.get("container_name") + if not container_name: + return "错误:容器未启动" + + git_content = await _get_git_head_content(container_name, path1_rel) + if git_content is None: + return "无法获取 git HEAD 版本(可能不是 git 仓库或文件不在 HEAD 中)" + + lines2 = git_content.splitlines(keepends=True) + fromfile = f"HEAD:{path1_rel}" + tofile = path1_rel + + # 生成 unified diff + diff_lines = list( + difflib.unified_diff( + lines2, + lines1, + fromfile=fromfile, + tofile=tofile, + lineterm="", + n=context_lines, + ) + ) + + if not diff_lines: + return "文件内容相同,无差异" + + result = "\n".join(diff_lines) + return result + + except Exception as exc: + logger.exception("diff 操作失败: %s", path1_rel) + return f"diff 操作失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/README.md b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/README.md new file mode 100644 index 0000000..59332b4 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/README.md @@ -0,0 +1,9 @@ +# get_current_time 工具 + +用于获取当前系统时间。 + +参数:无 + +目录结构: +- `config.json`:工具定义 +- `handler.py`:执行逻辑 diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/config.json new file mode 100644 index 0000000..5115a32 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/config.json @@ -0,0 +1,12 @@ +{ + "type": "function", + "function": { + "name": "get_current_time", + "description": "获取当前系统时间。", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } +} \ No newline at end of file diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/handler.py new file mode 100644 index 0000000..91a44f1 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/get_current_time/handler.py @@ -0,0 +1,7 @@ +from typing import Any, Dict +from datetime import datetime + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """获取当前系统时间(格式:YYYY-MM-DDTHH:MM:SS(+|-)HH:MM)""" + return datetime.now().astimezone().isoformat(timespec="seconds") diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json index 4819b78..e2aa58f 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "glob", - "description": "按 glob 模式匹配工作区内的文件。", + "description": "按 glob 模式匹配工作区内的文件。支持过滤和排序。", "parameters": { "type": "object", "properties": { @@ -13,6 +13,27 @@ "base_path": { "type": "string", "description": "可选:搜索起始路径(相对于 /workspace,默认为 /workspace 根目录)" + }, + "min_size": { + "type": "integer", + "description": "可选:最小文件大小(字节)" + }, + "max_size": { + "type": "integer", + "description": "可选:最大文件大小(字节)" + }, + "modified_after": { + "type": "string", + "description": "可选:修改时间晚于此时间戳(ISO 8601 格式,如 '2024-01-01T00:00:00')" + }, + "sort_by": { + "type": "string", + "enum": ["name", "size", "mtime"], + "description": "排序方式:name(文件名,默认)、size(文件大小)、mtime(修改时间)" + }, + "reverse": { + "type": "boolean", + "description": "是否反向排序(默认 false)" } }, "required": ["pattern"] diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py index 5557a3a..7d91b65 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import Any @@ -10,10 +11,15 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - """按 glob 模式匹配工作区内的文件。""" + """按 glob 模式匹配工作区内的文件。支持过滤和排序。""" pattern = str(args.get("pattern", "")).strip() base_path_rel = str(args.get("base_path", "")).strip() + min_size = args.get("min_size") + max_size = args.get("max_size") + modified_after_str = args.get("modified_after") + sort_by = str(args.get("sort_by", "name")).strip().lower() + reverse = bool(args.get("reverse", False)) if not pattern: return "错误:pattern 不能为空" @@ -33,25 +39,79 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: else: search_root = ws_resolved + # 解析 modified_after + modified_after_ts: float | None = None + if modified_after_str: + try: + dt = datetime.fromisoformat(str(modified_after_str)) + modified_after_ts = dt.timestamp() + except Exception: + return f"错误:modified_after 格式无效: {modified_after_str}" + try: - matches: list[str] = [] + # 收集匹配的文件及其元信息 + file_info: list[tuple[str, int, float]] = [] # (rel_path, size, mtime) + for p in search_root.glob(pattern): if not str(p.resolve()).startswith(str(ws_resolved)): continue - rel = p.relative_to(ws_resolved) - matches.append(str(rel)) - if len(matches) >= MAX_RESULTS: - break + if not p.is_file(): + continue + + try: + stat = p.stat() + size = stat.st_size + mtime = stat.st_mtime + + # 应用过滤条件 + if min_size is not None and size < int(min_size): + continue + if max_size is not None and size > int(max_size): + continue + if modified_after_ts is not None and mtime < modified_after_ts: + continue - matches.sort() + rel = str(p.relative_to(ws_resolved)) + file_info.append((rel, size, mtime)) - if not matches: + if len(file_info) >= MAX_RESULTS: + break + except Exception: + continue + + if not file_info: return "未找到匹配文件" - result = "\n".join(matches) - if len(matches) >= MAX_RESULTS: + # 排序 + if sort_by == "size": + file_info.sort(key=lambda x: x[1], reverse=reverse) + elif sort_by == "mtime": + file_info.sort(key=lambda x: x[2], reverse=reverse) + else: # name + file_info.sort(key=lambda x: x[0], reverse=reverse) + + # 格式化输出 + if sort_by == "name": + # 仅显示文件名 + matches = [info[0] for info in file_info] + result = "\n".join(matches) + else: + # 显示文件名和元信息 + lines: list[str] = [] + for rel_path, size, mtime in file_info: + if sort_by == "size": + lines.append(f"{rel_path} ({size} bytes)") + else: # mtime + mtime_str = datetime.fromtimestamp(mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ) + lines.append(f"{rel_path} ({mtime_str})") + result = "\n".join(lines) + + if len(file_info) >= MAX_RESULTS: result += f"\n\n... (结果已截断,共显示 {MAX_RESULTS} 条)" return result + except Exception as exc: logger.exception("glob 匹配失败: %s", pattern) return f"glob 匹配失败: {exc}" diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json index 4584e32..f7c49aa 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "grep", - "description": "在工作区内搜索文件内容。", + "description": "在工作区内搜索文件内容。支持上下文行显示。", "parameters": { "type": "object", "properties": { @@ -25,6 +25,23 @@ "max_matches": { "type": "integer", "description": "最大匹配数(默认 100)" + }, + "context_before": { + "type": "integer", + "description": "显示匹配行之前的 N 行上下文(类似 grep -B)" + }, + "context_after": { + "type": "integer", + "description": "显示匹配行之后的 N 行上下文(类似 grep -A)" + }, + "context": { + "type": "integer", + "description": "显示匹配行前后各 N 行上下文(类似 grep -C)" + }, + "output_mode": { + "type": "string", + "enum": ["matches", "files", "count"], + "description": "输出模式:matches(显示匹配行,默认)、files(仅显示文件名)、count(显示匹配计数)" } }, "required": ["pattern"] diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py index b8734cb..ee85ef2 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py @@ -11,14 +11,63 @@ MAX_LINE_LEN = 500 +def _format_line(line: str, max_len: int = MAX_LINE_LEN) -> str: + """格式化行内容,超长则截断。""" + if len(line) > max_len: + return line[:max_len] + "..." + return line + + +def _collect_context_matches( + lines: list[str], + match_line_numbers: list[int], + context_before: int, + context_after: int, +) -> list[tuple[int, str, bool]]: + """收集匹配行及其上下文。 + + 返回: [(line_number, line_content, is_match), ...] + """ + result: list[tuple[int, str, bool]] = [] + included_lines: set[int] = set() + + for match_lineno in match_line_numbers: + # 计算上下文范围 + start = max(1, match_lineno - context_before) + end = min(len(lines), match_lineno + context_after) + + # 收集这个范围内的所有行 + for lineno in range(start, end + 1): + if lineno not in included_lines: + included_lines.add(lineno) + is_match = lineno == match_lineno + result.append((lineno, lines[lineno - 1], is_match)) + + # 按行号排序 + result.sort(key=lambda x: x[0]) + return result + + async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - """在工作区内搜索文件内容。""" + """在工作区内搜索文件内容。支持上下文行显示。""" pattern = str(args.get("pattern", "")).strip() path_rel = str(args.get("path", "")).strip() is_regex = bool(args.get("is_regex", False)) case_sensitive = bool(args.get("case_sensitive", True)) max_matches = int(args.get("max_matches", MAX_MATCHES_DEFAULT)) + output_mode = str(args.get("output_mode", "matches")).strip().lower() + + # 上下文参数 + context_param = args.get("context") + context_before = args.get("context_before", 0) + context_after = args.get("context_after", 0) + + if context_param is not None: + context_before = context_after = int(context_param) + else: + context_before = int(context_before) + context_after = int(context_after) if not pattern: return "错误:pattern 不能为空" @@ -45,7 +94,11 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: except re.error as exc: return f"正则表达式错误: {exc}" - matches: list[str] = [] + # 根据 output_mode 收集不同的结果 + file_matches: dict[str, list[int]] = {} # 文件 -> 匹配行号列表 + file_lines: dict[str, list[str]] = {} # 文件 -> 所有行 + total_matches = 0 + try: files = search_root.rglob("*") if search_root.is_dir() else [search_root] for file_path in files: @@ -58,25 +111,80 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: except Exception: continue - rel = file_path.relative_to(ws_resolved) - for lineno, line in enumerate(text.splitlines(), 1): + rel = str(file_path.relative_to(ws_resolved)) + lines = text.splitlines() + match_line_numbers: list[int] = [] + + for lineno, line in enumerate(lines, 1): if compiled.search(line): - display = line[:MAX_LINE_LEN] - if len(line) > MAX_LINE_LEN: - display += "..." - matches.append(f"{rel}:{lineno}:{display}") - if len(matches) >= max_matches: + match_line_numbers.append(lineno) + total_matches += 1 + if total_matches >= max_matches: break - if len(matches) >= max_matches: + + if match_line_numbers: + file_matches[rel] = match_line_numbers + file_lines[rel] = lines + + if total_matches >= max_matches: break + except Exception as exc: logger.exception("grep 搜索失败") return f"搜索失败: {exc}" - if not matches: + if not file_matches: return "未找到匹配内容" - result = "\n".join(matches) - if len(matches) >= max_matches: - result += f"\n\n... (结果已截断,共显示 {max_matches} 条)" - return result + # 根据 output_mode 生成输出 + if output_mode == "files": + result = "\n".join(sorted(file_matches.keys())) + if total_matches >= max_matches: + result += f"\n\n... (结果已截断,共显示 {max_matches} 条匹配)" + return result + + elif output_mode == "count": + count_lines: list[str] = [] + for rel_path in sorted(file_matches.keys()): + count = len(file_matches[rel_path]) + count_lines.append(f"{rel_path}: {count}") + result = "\n".join(count_lines) + if total_matches >= max_matches: + result += f"\n\n... (结果已截断,共显示 {max_matches} 条匹配)" + return result + + else: # matches mode + output_lines: list[str] = [] + + for rel_path in sorted(file_matches.keys()): + match_line_numbers = file_matches[rel_path] + lines_list = file_lines[rel_path] + + if context_before == 0 and context_after == 0: + # 无上下文,简单输出 + for lineno in match_line_numbers: + line = lines_list[lineno - 1] + display = _format_line(line) + output_lines.append(f"{rel_path}:{lineno}:{display}") + else: + # 有上下文,收集上下文行 + context_matches = _collect_context_matches( + lines_list, match_line_numbers, context_before, context_after + ) + + output_lines.append(f"\n=== {rel_path} ===") + prev_lineno = 0 + for lineno, line, is_match in context_matches: + # 如果行号不连续,插入分隔符 + if prev_lineno > 0 and lineno > prev_lineno + 1: + output_lines.append("--") + + display = _format_line(line) + separator = ":" if is_match else "-" + output_lines.append(f"{lineno}{separator}{display}") + prev_lineno = lineno + + result = "\n".join(output_lines) + if total_matches >= max_matches: + result += f"\n\n... (结果已截断,共显示 {max_matches} 条匹配)" + return result diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json index 522316d..b9e4af4 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json @@ -2,17 +2,30 @@ "type": "function", "function": { "name": "read", - "description": "读取工作区内文件的文本内容。", + "description": "读取工作区内文件的文本内容。支持完整读取、按行读取、读取元信息、批量读取。", "parameters": { "type": "object", "properties": { "path": { "type": "string", - "description": "文件路径(相对于 /workspace)" + "description": "文件路径(相对于 /workspace),或用逗号分隔的多个路径(批量读取)" + }, + "mode": { + "type": "string", + "enum": ["full", "lines", "stat"], + "description": "读取模式:full(完整读取,默认)、lines(按行读取)、stat(仅读取文件元信息)" }, "max_chars": { "type": "integer", - "description": "可选:最大字符数限制,超出则截断" + "description": "full 模式:最大字符数限制,超出则截断" + }, + "offset": { + "type": "integer", + "description": "lines 模式:起始行号(从 1 开始)" + }, + "limit": { + "type": "integer", + "description": "lines 模式:读取的行数" } }, "required": ["path"] diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py index 049c686..aabb03a 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import Any @@ -9,22 +10,18 @@ logger = logging.getLogger(__name__) -async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - """读取工作区内文件的文本内容。""" - - rel_path = str(args.get("path", "")).strip() - max_chars: int | None = args.get("max_chars") - - if not rel_path: - return "错误:path 不能为空" - - workspace: Path | None = context.get("workspace") - if not workspace: - return "错误:workspace 未设置" - +async def _read_single_file( + rel_path: str, + workspace: Path, + mode: str, + max_chars: int | None, + offset: int | None, + limit: int | None, +) -> str: + """读取单个文件。""" full_path = (workspace / rel_path).resolve() if not str(full_path).startswith(str(workspace.resolve())): - return "错误:路径越界,只能读取 /workspace 下的文件" + return f"错误:路径越界: {rel_path}" if not full_path.exists(): return f"文件不存在: {rel_path}" @@ -32,17 +29,100 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: return f"错误:{rel_path} 是目录,不是文件" try: - async with aiofiles.open( - full_path, "r", encoding="utf-8", errors="replace" - ) as f: - content = await f.read() + if mode == "stat": + stat = full_path.stat() + size_bytes = stat.st_size + mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S") + + # 计算行数 + line_count = 0 + async with aiofiles.open( + full_path, "r", encoding="utf-8", errors="replace" + ) as f: + async for _ in f: + line_count += 1 + + return ( + f"文件: {rel_path}\n" + f"大小: {size_bytes} 字节 ({size_bytes / 1024:.2f} KB)\n" + f"修改时间: {mtime}\n" + f"行数: {line_count}" + ) + + elif mode == "lines": + if offset is None: + offset = 1 + if limit is None: + limit = 100 + + async with aiofiles.open( + full_path, "r", encoding="utf-8", errors="replace" + ) as f: + lines = await f.readlines() + + total_lines = len(lines) + start_idx = max(0, offset - 1) + end_idx = min(total_lines, start_idx + limit) + + if start_idx >= total_lines: + return ( + f"{rel_path}: 起始行号 {offset} 超出文件范围(共 {total_lines} 行)" + ) + + selected_lines = lines[start_idx:end_idx] + content = "".join(selected_lines) + + header = f"=== {rel_path} (行 {offset}-{start_idx + len(selected_lines)}/{total_lines}) ===\n" + return header + content - total = len(content) - if max_chars and total > max_chars: - content = content[:max_chars] - content += f"\n\n... (共 {total} 字符,已截断到前 {max_chars} 字符)" + else: # full mode + async with aiofiles.open( + full_path, "r", encoding="utf-8", errors="replace" + ) as f: + content = await f.read() + + total = len(content) + if max_chars and total > max_chars: + content = content[:max_chars] + content += f"\n\n... (共 {total} 字符,已截断到前 {max_chars} 字符)" + + return content - return content except Exception as exc: logger.exception("读取文件失败: %s", rel_path) - return f"读取文件失败: {exc}" + return f"读取文件失败 {rel_path}: {exc}" + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """读取工作区内文件的文本内容。支持多种读取模式。""" + + path_arg = str(args.get("path", "")).strip() + mode = str(args.get("mode", "full")).strip().lower() + max_chars: int | None = args.get("max_chars") + offset: int | None = args.get("offset") + limit: int | None = args.get("limit") + + if not path_arg: + return "错误:path 不能为空" + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + # 支持批量读取(逗号分隔) + paths = [p.strip() for p in path_arg.split(",") if p.strip()] + + if len(paths) == 1: + return await _read_single_file( + paths[0], workspace, mode, max_chars, offset, limit + ) + + # 批量读取 + results: list[str] = [] + for rel_path in paths: + result = await _read_single_file( + rel_path, workspace, mode, max_chars, offset, limit + ) + results.append(f"=== {rel_path} ===\n{result}\n") + + return "\n".join(results) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json index 06810fc..8341213 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json @@ -2,13 +2,18 @@ "type": "function", "function": { "name": "run_bash_command", - "description": "在 Docker 容器内执行 bash 命令。可用于安装依赖、编译、运行测试等。", + "description": "在 Docker 容器内执行 bash 命令。支持前台/后台执行和进程终止。", "parameters": { "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["run", "kill"], + "description": "操作类型:run(执行命令,默认)或 kill(终止后台进程)" + }, "command": { "type": "string", - "description": "要执行的 bash 命令" + "description": "要执行的 bash 命令(action=run 时需要)" }, "timeout_seconds": { "type": "integer", @@ -17,9 +22,17 @@ "workdir": { "type": "string", "description": "可选:工作目录(容器内绝对路径,默认 /workspace)" + }, + "background": { + "type": "boolean", + "description": "是否后台执行(默认 false)。后台执行时返回进程ID,不等待命令完成" + }, + "pid": { + "type": "integer", + "description": "要终止的进程ID(action=kill 时需要)" } }, - "required": ["command"] + "required": [] } } } diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py index 28a4ac0..39b8521 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py @@ -2,17 +2,24 @@ import asyncio import logging +from fnmatch import fnmatch from typing import Any logger = logging.getLogger(__name__) +def _is_command_blacklisted(command: str, blacklist: list[str]) -> bool: + """检查命令是否匹配黑名单模式。""" + for pattern in blacklist: + if fnmatch(command, pattern) or pattern in command: + return True + return False + + async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - """在 Docker 容器内执行 bash 命令。""" + """在 Docker 容器内执行 bash 命令。支持前台/后台执行和进程终止。""" - command = str(args.get("command", "")).strip() - if not command: - return "错误:command 不能为空" + action = str(args.get("action", "run")).strip().lower() container_name: str | None = context.get("container_name") if not container_name: @@ -21,13 +28,84 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: config = context.get("config") default_timeout: int = 600 max_output: int = 20000 + command_blacklist: list[str] = [] if config: default_timeout = getattr(config, "code_delivery_command_timeout", 600) max_output = getattr(config, "code_delivery_max_command_output", 20000) + command_blacklist = getattr(config, "code_delivery_command_blacklist", []) + + if action == "kill": + # 终止后台进程 + pid = args.get("pid") + if pid is None: + return "错误:kill 操作需要 pid 参数" + + pid = int(pid) + kill_cmd = ["docker", "exec", container_name, "bash", "-lc", f"kill -9 {pid}"] + + try: + proc = await asyncio.create_subprocess_exec( + *kill_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=10) + + if proc.returncode == 0: + return f"已终止进程: {pid}" + else: + stderr = stderr_b.decode("utf-8", errors="replace") + return f"终止进程失败: {stderr or '进程可能不存在'}" + except Exception as exc: + logger.exception("终止进程失败: %s", pid) + return f"终止进程失败: {exc}" + + # action == "run" + command = str(args.get("command", "")).strip() + if not command: + return "错误:command 不能为空" + + # 检查命令黑名单 + if command_blacklist and _is_command_blacklisted(command, command_blacklist): + return f"错误:命令被黑名单拒绝: {command}" timeout = int(args.get("timeout_seconds", 0)) or default_timeout workdir = str(args.get("workdir", "")).strip() or "/workspace" + background = bool(args.get("background", False)) + + if background: + # 后台执行:使用 nohup 并获取进程ID + bg_cmd = f"nohup bash -lc {repr(command)} > /tmpfs/bg_$$.log 2>&1 & echo $!" + docker_cmd = [ + "docker", + "exec", + "-w", + workdir, + container_name, + "bash", + "-c", + bg_cmd, + ] + + try: + proc = await asyncio.create_subprocess_exec( + *docker_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=10) + + if proc.returncode == 0: + pid = stdout_b.decode("utf-8", errors="replace").strip() + return f"后台进程已启动\nPID: {pid}\n日志: /tmpfs/bg_{pid}.log\n\n使用 action=kill, pid={pid} 来终止进程" + else: + stderr = stderr_b.decode("utf-8", errors="replace") + return f"启动后台进程失败: {stderr}" + except Exception as exc: + logger.exception("启动后台进程失败: %s", command) + return f"启动后台进程失败: {exc}" + # 前台执行(原有逻辑) docker_cmd = [ "docker", "exec", diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/tree/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/tree/config.json new file mode 100644 index 0000000..ff2589e --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/tree/config.json @@ -0,0 +1,32 @@ +{ + "type": "function", + "function": { + "name": "tree", + "description": "显示目录树结构。快速查看项目文件组织。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "目录路径(相对于 /workspace,默认为根目录)" + }, + "max_depth": { + "type": "integer", + "description": "最大递归深度(默认 5,0 表示不限制)" + }, + "show_hidden": { + "type": "boolean", + "description": "是否显示隐藏文件(以 . 开头,默认 false)" + }, + "exclude_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "排除的模式列表(如 ['node_modules', '__pycache__', '.git'])" + } + }, + "required": [] + } + } +} diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/tree/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/tree/handler.py new file mode 100644 index 0000000..bf7392d --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/tree/handler.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import logging +from fnmatch import fnmatch +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _should_exclude(name: str, patterns: list[str]) -> bool: + """检查文件/目录名是否匹配排除模式。""" + for pattern in patterns: + if fnmatch(name, pattern): + return True + return False + + +def _build_tree( + path: Path, + prefix: str, + is_last: bool, + max_depth: int, + current_depth: int, + show_hidden: bool, + exclude_patterns: list[str], + lines: list[str], +) -> None: + """递归构建目录树。""" + if max_depth > 0 and current_depth > max_depth: + return + + try: + entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except PermissionError: + lines.append(f"{prefix}{'└── ' if is_last else '├── '}[权限拒绝]") + return + except Exception as exc: + lines.append(f"{prefix}{'└── ' if is_last else '├── '}[错误: {exc}]") + return + + # 过滤条目 + filtered_entries: list[Path] = [] + for entry in entries: + name = entry.name + # 排除隐藏文件(如果不显示) + if not show_hidden and name.startswith("."): + continue + # 排除匹配的模式 + if _should_exclude(name, exclude_patterns): + continue + filtered_entries.append(entry) + + for idx, entry in enumerate(filtered_entries): + is_last_entry = idx == len(filtered_entries) - 1 + connector = "└── " if is_last_entry else "├── " + name = entry.name + + if entry.is_dir(): + lines.append(f"{prefix}{connector}{name}/") + extension = " " if is_last_entry else "│ " + _build_tree( + entry, + prefix + extension, + is_last_entry, + max_depth, + current_depth + 1, + show_hidden, + exclude_patterns, + lines, + ) + else: + lines.append(f"{prefix}{connector}{name}") + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """显示目录树结构。""" + + rel_path = str(args.get("path", "")).strip() + max_depth = int(args.get("max_depth", 5)) + show_hidden = bool(args.get("show_hidden", False)) + exclude_patterns = args.get("exclude_patterns", []) + + if not isinstance(exclude_patterns, list): + exclude_patterns = [] + exclude_patterns = [str(p) for p in exclude_patterns] + + workspace: Path | None = context.get("workspace") + if not workspace: + return "错误:workspace 未设置" + + if rel_path: + target_path = (workspace / rel_path).resolve() + else: + target_path = workspace.resolve() + + if not str(target_path).startswith(str(workspace.resolve())): + return "错误:路径越界,只能访问 /workspace 下的目录" + + if not target_path.exists(): + return f"路径不存在: {rel_path or '.'}" + if not target_path.is_dir(): + return f"错误:{rel_path or '.'} 不是目录" + + lines: list[str] = [str(target_path.relative_to(workspace.resolve())) or "."] + _build_tree( + target_path, + "", + True, + max_depth, + 1, + show_hidden, + exclude_patterns, + lines, + ) + + result = "\n".join(lines) + if max_depth > 0: + result += f"\n\n(最大深度: {max_depth})" + return result diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json index be74d4b..f1acd6c 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "write", - "description": "写入文件到工作区。支持覆盖写入和追加写入。", + "description": "写入文件到工作区。支持多种写入模式:覆盖、追加、精确替换、行级插入。", "parameters": { "type": "object", "properties": { @@ -12,15 +12,27 @@ }, "content": { "type": "string", - "description": "要写入的内容" + "description": "要写入的内容(overwrite/append/insert_at_line 模式需要)" }, "mode": { "type": "string", - "enum": ["overwrite", "append"], - "description": "写入模式:overwrite(覆盖,默认)或 append(追加)" + "enum": ["overwrite", "append", "replace", "insert_at_line"], + "description": "写入模式:overwrite(覆盖)、append(追加)、replace(精确替换)、insert_at_line(在指定行插入)" + }, + "old_string": { + "type": "string", + "description": "replace 模式:要替换的原字符串(必须在文件中唯一存在)" + }, + "new_string": { + "type": "string", + "description": "replace 模式:替换后的新字符串" + }, + "line_number": { + "type": "integer", + "description": "insert_at_line 模式:在哪一行之前插入(1-based,0 表示文件开头)" } }, - "required": ["path", "content"] + "required": ["path"] } } } diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py index 1865d40..d569b21 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from pathlib import Path from typing import Any @@ -9,11 +10,23 @@ logger = logging.getLogger(__name__) +def _get_file_lock(context: dict[str, Any], file_path: str) -> asyncio.Lock: + """获取指定文件的锁,防止并发写入竞态。""" + locks: dict[str, asyncio.Lock] | None = context.get("_write_file_locks") + if locks is None: + locks = {} + context["_write_file_locks"] = locks + + if file_path not in locks: + locks[file_path] = asyncio.Lock() + + return locks[file_path] + + async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - """写入文件到工作区。""" + """写入文件到工作区。支持多种写入模式。""" rel_path = str(args.get("path", "")).strip() - content = str(args.get("content", "")) mode = str(args.get("mode", "overwrite")).strip().lower() if not rel_path: @@ -27,19 +40,91 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if not str(full_path).startswith(str(workspace.resolve())): return "错误:路径越界,只能写入 /workspace 下的文件" - try: - full_path.parent.mkdir(parents=True, exist_ok=True) - - if mode == "append": - async with aiofiles.open(full_path, "a", encoding="utf-8") as f: - await f.write(content) - else: - async with aiofiles.open(full_path, "w", encoding="utf-8") as f: - await f.write(content) - - byte_count = len(content.encode("utf-8")) - action = "追加" if mode == "append" else "写入" - return f"已{action} {byte_count} 字节到 {rel_path}" - except Exception as exc: - logger.exception("写入文件失败: %s", rel_path) - return f"写入文件失败: {exc}" + # 获取文件锁,防止并发写入竞态 + lock = _get_file_lock(context, str(full_path)) + + async with lock: + try: + full_path.parent.mkdir(parents=True, exist_ok=True) + + if mode == "overwrite": + content = str(args.get("content", "")) + async with aiofiles.open(full_path, "w", encoding="utf-8") as f: + await f.write(content) + byte_count = len(content.encode("utf-8")) + return f"已写入 {byte_count} 字节到 {rel_path}" + + elif mode == "append": + content = str(args.get("content", "")) + async with aiofiles.open(full_path, "a", encoding="utf-8") as f: + await f.write(content) + byte_count = len(content.encode("utf-8")) + return f"已追加 {byte_count} 字节到 {rel_path}" + + elif mode == "replace": + old_string = args.get("old_string") + new_string = args.get("new_string") + if old_string is None or new_string is None: + return "错误:replace 模式需要 old_string 和 new_string 参数" + + old_string = str(old_string) + new_string = str(new_string) + + if not full_path.exists(): + return f"错误:文件不存在: {rel_path}" + + async with aiofiles.open(full_path, "r", encoding="utf-8") as f: + original = await f.read() + + count = original.count(old_string) + if count == 0: + return "错误:未找到要替换的字符串" + if count > 1: + return f"错误:找到 {count} 处匹配,old_string 必须唯一" + + new_content = original.replace(old_string, new_string) + async with aiofiles.open(full_path, "w", encoding="utf-8") as f: + await f.write(new_content) + + return f"已替换 {rel_path} 中的字符串({len(old_string)} -> {len(new_string)} 字符)" + + elif mode == "insert_at_line": + content = str(args.get("content", "")) + line_number = args.get("line_number") + if line_number is None: + return "错误:insert_at_line 模式需要 line_number 参数" + + line_number = int(line_number) + if line_number < 0: + return "错误:line_number 必须 >= 0" + + if full_path.exists(): + async with aiofiles.open(full_path, "r", encoding="utf-8") as f: + lines = (await f.read()).splitlines(keepends=True) + else: + lines = [] + + # 确保 content 以换行符结尾(如果它不是空的) + if content and not content.endswith("\n"): + content += "\n" + + if line_number == 0: + lines.insert(0, content) + elif line_number > len(lines): + # 如果行号超出范围,追加到末尾 + lines.append(content) + else: + lines.insert(line_number - 1, content) + + new_content = "".join(lines) + async with aiofiles.open(full_path, "w", encoding="utf-8") as f: + await f.write(new_content) + + return f"已在 {rel_path} 第 {line_number} 行插入内容" + + else: + return f"错误:未知的写入模式: {mode}" + + except Exception as exc: + logger.exception("写入文件失败: %s", rel_path) + return f"写入文件失败: {exc}" From 98233b867ac0b628b2d25f29559bfe9cea692f92 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 20:27:12 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feature(agent):=20=E8=AE=A9agent=E4=BB=AC?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E4=BA=92=E7=9B=B8=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- CODE_DELIVERY_AGENT_PLAN.md | 351 ---------------- README.md | 7 +- docs/agent-calling.md | 381 ++++++++++++++++++ .../skills/agents/agent_tool_registry.py | 189 ++++++++- .../agents/code_delivery_agent/mcp.json | 8 + .../skills/agents/info_agent/callable.json | 4 + .../agents/naga_code_analysis_agent/mcp.json | 8 + .../skills/agents/web_agent/callable.json | 4 + 9 files changed, 600 insertions(+), 354 deletions(-) delete mode 100644 CODE_DELIVERY_AGENT_PLAN.md create mode 100644 docs/agent-calling.md create mode 100644 src/Undefined/skills/agents/code_delivery_agent/mcp.json create mode 100644 src/Undefined/skills/agents/info_agent/callable.json create mode 100644 src/Undefined/skills/agents/naga_code_analysis_agent/mcp.json create mode 100644 src/Undefined/skills/agents/web_agent/callable.json diff --git a/CLAUDE.md b/CLAUDE.md index 455f14b..1b47181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Skills 是核心扩展机制,分四类,全部通过 `config.json`(OpenAI f - Agent 的 config.json 统一使用 `prompt` 参数接收用户需求 - Agent handler 应使用 `skills/agents/runner.py` 的 `run_agent_with_tools()` 统一执行入口,它处理 prompt 加载、LLM 迭代、tool call 并发执行、结果回填 - Agent 通过 `context["ai_client"].request_model()` 调用模型,确保 Token 统计一致 -- 5 个内置 Agent:info_agent, web_agent, file_analysis_agent, naga_code_analysis_agent, entertainment_agent +- 6 个内置 Agent:info_agent, web_agent, file_analysis_agent, naga_code_analysis_agent, entertainment_agent, code_delivery_agent ### Anthropic Skills (`skills/anthropic_skills/{skill_name}/SKILL.md`) - 遵循 agentskills.io 标准,YAML frontmatter(name + description)+ Markdown 正文 diff --git a/CODE_DELIVERY_AGENT_PLAN.md b/CODE_DELIVERY_AGENT_PLAN.md deleted file mode 100644 index 5e17de8..0000000 --- a/CODE_DELIVERY_AGENT_PLAN.md +++ /dev/null @@ -1,351 +0,0 @@ -# Code Delivery Agent 详细实施计划(仅规划,不改实现) - -## 1. 背景与目标 - -### 1.1 目标 -在 `src/Undefined` 技术栈内新增一个可执行“代码编写 -> 运行验证 -> 打包 -> 发送到群聊/私聊”的 Agent, -用于从指定初始化来源(Git 仓库或空目录)开始完成交付。 - -### 1.2 明确边界(当前版本) -- 仅覆盖 `src/Undefined`,不涉及 `code/NagaAgent`。 -- 不做人审确认流程。 -- 不做命令白名单。 -- 不做网络白名单,容器全程开网。 -- Docker 不做端口映射。 -- 打包排除规则由 AI 在调用 `end` 工具时提供(黑名单由 AI 给出)。 - -### 1.3 核心结果 -- 新增 `code_delivery_agent`(可被主 AI 调用)。 -- 提供 7 个工具:`read` / `write` / `glob` / `grep` / `run_bash_command` / `todo` / `end`。 -- `end` 完成“按黑名单打包并上传到目标群/私聊”。 -- 工作目录与容器在任务完成后及时清理。 -- 任务内若单次 LLM 请求连续失败达到 5 次,主动向目标群/私聊发送失败通知和失败原因。 -- Docker 容器名采用固定前后缀规则,便于启动时扫描与清理残留容器。 -- Agent 每次启动前先执行一次“残留兜底清理”:删除 `data/code_delivery/` 下目录与相关 Docker 容器(若存在)。 -- 功能实现完成后,补充并更新本仓库相关文档。 - ---- - -## 2. 目录与生命周期设计 - -### 2.1 任务根目录 -每次调用 agent 创建唯一任务目录: - -`data/code_delivery/{task_uuid}/` - -内部结构: -- `workspace/`:项目工作区(AI 读写、构建、打包源) -- `tmpfs/`:宿主侧临时目录(用于与容器临时挂载配合) -- `logs/`:可选执行日志 -- `artifacts/`:可选中间产物(最终包也可放此处) - -### 2.2 容器挂载 -容器内固定路径: -- `/workspace` -> `data/code_delivery/{task_uuid}/workspace` -- `/tmpfs` -> `data/code_delivery/{task_uuid}/tmpfs` - -### 2.3 清理策略 -- 启动前清理(防中断残留): - 1. 删除 `data/code_delivery/` 下所有历史任务目录; - 2. 删除名称匹配 code_delivery 命名规则的 Docker 容器(运行中与已退出都处理)。 -- 正常结束:`end` 发送完成后,停止并删除容器,清理 `data/code_delivery/{task_uuid}/`。 -- 异常结束:handler `finally` 做兜底清理。 -- 清理失败仅记录日志,不阻断用户结果返回。 - ---- - -## 3. Docker 执行模型 - -### 3.1 镜像 -- 默认镜像:`ubuntu:24.04`(可配置)。 - -### 3.2 运行方式 -每个任务一个容器,生命周期随任务。 - -建议参数(示意): -- `docker run -d --rm` -- `-v :/workspace` -- `-v :/tmpfs` -- `-w /workspace` -- 不加 `-p`(无端口映射) -- 不加 `--network none`(全程开网) - -### 3.3 命令执行 -- `run_bash_command` 统一走 `docker exec bash -lc "..."`。 -- 不在宿主机直接执行用户构建命令。 - -### 3.4 容器命名规则 -- 容器名使用固定格式:``。 -- 建议默认:`container_name_prefix = "code_delivery_"`,`container_name_suffix = "_runner"`。 -- 启动前残留清理时,依据此前后缀规则匹配并清理相关容器。 - ---- - -## 4. Agent API 设计 - -## 4.1 Agent 名称 -`code_delivery_agent` - -### 4.2 入参设计 -- `prompt: string` 任务目标 -- `source_type: "git" | "empty"` -- `git_url?: string`(`source_type=git` 必填) -- `git_ref?: string`(可选,分支/tag/commit) -- `target_type: "group" | "private"` -- `target_id: integer` - -### 4.3 prompt 显式要求 -在 handler 组装给子 agent 的 `user_content` 时,必须显式写明初始化来源: -- Git 来源:`source_type=git, git_url=..., git_ref=...` -- 空仓来源:`source_type=empty` - -目标是让子 agent 不会丢失“从哪里初始化”的关键上下文。 - -### 4.4 文档与交付约束 -- 若 `source_type=empty`,AI 在产出代码时必须补齐项目 `README.md`(不可留空)。 -- 任务完成前,AI 必须补全相关文档(至少包含使用方式与运行说明)。 -- `end` 前应确保交付内容包含代码与文档两部分,而非只提交代码文件。 - ---- - -## 5. 工具集合设计(当前 7 个) - -## 5.1 `read` -- 功能:读取文件文本内容 -- 参数:`path`, `max_chars?` -- 约束:只允许读取 task `workspace` 下路径 -- 返回:文本或错误信息 - -## 5.2 `write` -- 功能:写文件 -- 参数:`path`, `content`, `mode?`(`overwrite|append`) -- 约束:只允许写入 task `workspace` 下路径 -- 返回:写入结果(字节数/路径) - -## 5.3 `glob` -- 功能:按模式匹配文件 -- 参数:`pattern`, `base_path?` -- 约束:搜索边界在 task `workspace` -- 返回:匹配路径列表(上限截断) - -## 5.4 `grep` -- 功能:内容检索 -- 参数:`pattern`, `path?`, `is_regex?`, `case_sensitive?`, `max_matches?` -- 约束:搜索边界在 task `workspace` -- 返回:`file:line:content` 列表(上限截断) - -## 5.5 `run_bash_command` -- 功能:在任务容器内执行 bash 命令 -- 参数:`command`, `timeout_seconds?`, `workdir?` -- 行为:`docker exec` 执行,返回 `exit_code/stdout/stderr` -- 备注:当前版本不做命令白名单 - -## 5.6 `todo` -- 功能:记录与追踪任务待办/进度 -- 参数:`action`(`add|list|update|remove|clear`), `item_id?`, `content?`, `status?` -- 行为:在任务目录维护一个 `todo.json`(或同等结构)作为进度面板 -- 价值:让 Agent 在长任务中可持续追踪“未做/进行中/已完成”项 - -## 5.7 `end` -- 功能:结束任务、打包并上传 -- 参数: - - `exclude_patterns: string[]`(必填,AI 提供黑名单) - - `archive_name?` - - `archive_format?`(建议 `zip|tar.gz`) - - `summary?` -- 行为: - 1. 按黑名单打包 `workspace` - 2. 上传到 `target_type/target_id` - 3. 返回产物信息(名称、大小、hash、上传状态) - 4. 标记会话结束并触发清理 - ---- - -## 6. 打包与黑名单规则 - -### 6.1 黑名单来源 -由 AI 调用 `end` 时传入 `exclude_patterns`。 - -### 6.2 黑名单匹配 -- 采用 glob 风格匹配(如 `.git/**`, `.venv/**`, `node_modules/**`)。 -- 黑名单仅用于“打包阶段排除”,不影响 workspace 内实际文件存在。 - -### 6.3 默认建议(写入 prompt,不强制) -可建议 AI 优先传: -- `.git/**` -- `.venv/**` -- `__pycache__/**` -- `.pytest_cache/**` -- `node_modules/**` -- `.mypy_cache/**` -- `.ruff_cache/**` - ---- - -## 7. 上传到群聊/私聊设计 - -### 7.1 OneBot 扩展 -在 `OneBotClient` 新增方法: -- `upload_group_file(group_id, file_path, name?)` -- `upload_private_file(user_id, file_path, name?)` - -通过 `_call_api` 调用对应动作(具体动作名按当前 OneBot 实现适配)。 - -### 7.2 上传流程 -`end` 根据 `target_type` 分发: -- `group` -> 上传群文件 -- `private` -> 上传私聊文件 - -### 7.3 失败回退 -如遇实现不支持上传动作: -- 尝试文件消息段回退(视协议实现); -- 若仍失败,返回明确错误并保留本地产物路径用于人工处理。 - -### 7.4 LLM 连续失败通知 -- 对“单次 LLM 请求”设置最大重试次数 5(不是全任务累计失败次数)。 -- 若该次请求连续 5 次失败,立即向 `target_type/target_id` 发送失败通知。 -- 通知内容至少包含:任务 ID、失败阶段、错误摘要、建议重试信息。 -- 发送通知后结束任务并执行清理流程,避免进入无效重试循环。 - ---- - -## 8. 配置项计划(`config.toml.example`) - -新增段:`[code_delivery]` - -建议字段: -- `enabled = true` -- `task_root = "data/code_delivery"` -- `docker_image = "ubuntu:24.04"` -- `container_name_prefix = "code_delivery_"` -- `container_name_suffix = "_runner"` -- `default_command_timeout_seconds = 600` -- `max_command_output_chars = 20000` -- `default_archive_format = "zip"` -- `max_archive_size_mb = 200` -- `cleanup_on_finish = true` -- `cleanup_on_start = true` -- `llm_max_retries_per_request = 5` -- `notify_on_llm_failure = true` - -并在 `src/Undefined/config/loader.py` 的 `Config` 中新增对应字段与解析。 - ---- - -## 9. 代码落点计划 - -### 9.1 新增 agent -`src/Undefined/skills/agents/code_delivery_agent/` -- `config.json` -- `intro.md` -- `prompt.md` -- `handler.py` -- `tools/` - -### 9.2 工具子目录 -- `tools/read/{config.json,handler.py}` -- `tools/write/{config.json,handler.py}` -- `tools/glob/{config.json,handler.py}` -- `tools/grep/{config.json,handler.py}` -- `tools/run_bash_command/{config.json,handler.py}` -- `tools/todo/{config.json,handler.py}` -- `tools/end/{config.json,handler.py}` - -### 9.3 需修改文件 -- `src/Undefined/onebot.py`(文件上传 API) -- `src/Undefined/config/loader.py`(配置解析) -- `config.toml.example`(示例配置) -- `README.md`(补充 code delivery agent 的使用说明) -- `src/Undefined/skills/README.md`(补充新增 agent/tool 说明) -- `src/Undefined/skills/agents/README.md`(补充 code_delivery_agent 结构与约束) - -### 9.4 新增 TODO 文档 -- `src/Undefined/skills/agents/code_delivery_agent/TODO.md` -- 用于列“后续可扩展工具”和技术债。 - ---- - -## 10. TODO(后续可扩展工具清单) - -以下先记录,不纳入当前最小版本: -- `list_directory`:快速列目录树 -- `read_many`:批量读取文件减少轮次 -- `replace_in_file`:结构化替换 -- `download_to_workspace`:显式下载远程依赖 -- `inspect_env`:查看容器内工具链版本 -- `checkpoint`:阶段性产物留档 -- `restore_checkpoint`:失败回滚 -- `artifact_list`:列当前任务已产物 - ---- - -## 11. 验收计划 - -### 11.1 基本用例 -1. `source_type=empty`:创建代码、执行命令、`end` 打包上传成功。 -2. `source_type=git`:clone 仓库、修改代码、执行命令、`end` 上传成功。 -3. `todo` 工具可正常新增/列出/更新/删除待办并持久化。 -4. `source_type=empty` 时最终产物包含有效 `README.md`。 -5. 任务结束前已补全必要文档(至少含运行方式与使用说明)。 - -### 11.2 黑名单验证 -- 传入 `.git/**`, `.venv/**` 后,包内不包含对应目录。 - -### 11.3 清理验证 -- 成功路径:任务结束后容器不存在,`data/code_delivery/{task_uuid}` 被清理。 -- 异常路径:中途报错后也会触发兜底清理。 -- 启动路径:每次 agent 启动前会清理 `data/code_delivery/` 下历史目录和匹配前后缀规则的残留 Docker 容器。 - -### 11.4 上传验证 -- 群聊上传成功。 -- 私聊上传成功。 -- 上传失败时返回明确可排查信息。 - -### 11.5 LLM 失败通知验证 -- 人为制造 LLM 请求连续失败场景。 -- 单次请求连续失败达到 5 次后,目标群/私聊能收到失败通知与失败原因。 -- 通知后任务终止并完成容器与 workspace 清理。 - -### 11.6 文档完善验证 -- 功能实现后,仓库文档已同步更新(`README.md`、`src/Undefined/skills/README.md`、`src/Undefined/skills/agents/README.md`)。 -- `source_type=empty` 产物中的 `README.md` 内容完整且可指导运行。 - ---- - -## 12. 风险与注意事项 - -1. 当前不做命令白名单,执行能力较强,需明确仅在可信场景下使用。 -2. 当前全程开网,任务可能访问外部网络,需在部署侧做好总控审计。 -3. OneBot 各实现上传文件 API 兼容性差异较大,需要做动作名适配和回退。 -4. Ubuntu 基础镜像默认工具较少,若业务常用 `git/zip/tar`,需要在容器准备阶段自动安装或改用预构建镜像。 - ---- - -## 13. 分阶段实施顺序 - -### Phase 1(基础链路) -- 建 agent 骨架 + 7 工具框架(含 `todo`) -- 启动前残留清理(`data/code_delivery/*` + 命名匹配容器) -- 容器前后缀命名机制 -- 容器创建/exec/销毁 -- workspace/task_uuid 生命周期 - -### Phase 2(交付闭环) -- `end` 打包实现(含黑名单) -- OneBot 文件上传接口 -- 成功后自动清理 -- 文档补全约束落地(空仓必须有 README) - -### Phase 3(配置与稳定性) -- `config.toml.example` + `Config` 解析 -- LLM 单次请求连续失败 5 次通知机制 -- 日志增强、异常路径补全 -- 仓库文档完善与同步 -- 回归测试与验收 - ---- - -## 14. 当前状态说明 - -本文件为“持久化实施计划”,用于后续开发执行。 -当前未开始修改功能代码。 diff --git a/README.md b/README.md index 79f0af9..4dfa545 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ - **会话白名单(群/私聊)**:只需配置 `access.allowed_group_ids` / `access.allowed_private_ids` 两个列表,即可把机器人“锁”在指定群与指定私聊里;避免被拉进陌生群误触发、也避免工具/定时任务把消息误发到不该去的地方(默认留空不限制)。 - **并行工具执行**:无论是主 AI 还是子 Agent,均支持 `asyncio` 并发工具调用,大幅提升多任务处理速度(如同时读取多个文件或搜索多个关键词)。 - **智能 Agent 矩阵**:内置多个专业 Agent,分工协作处理复杂任务。 +- **Agent 互调用**:Agent 之间可以相互调用,通过简单的配置文件(`callable.json`)即可让某个 Agent 成为其他 Agent 的工具,支持细粒度的访问控制,实现复杂的多 Agent 协作场景。 - **Agent 自我介绍自动生成**:启动时按 Agent 代码/配置 hash 生成 `intro.generated.md`(第一人称、结构化),与 `intro.md` 合并后作为描述;减少手动维护,保持能力说明与实现同步,有助于精准调度。 - **请求上下文管理**:基于 Python `contextvars` 的统一请求上下文系统,自动 UUID 追踪,零竞态条件,完全的并发隔离。 - **定时任务系统**:支持 Crontab 语法的强大定时任务系统,可自动执行各种操作(如定时提醒、定时搜索)。 @@ -169,12 +170,13 @@ graph TB TS_Scheduler["scheduler.*
• create_schedule_task
• delete_schedule_task
• list_schedule_tasks"] end - subgraph IntelligentAgents["智能体 Agents (5个)"] + subgraph IntelligentAgents["智能体 Agents (6个)"] A_Info["info_agent
信息查询助手
(17个工具)
• weather_query
• *hot 热搜
• bilibili_*
• whois"] A_Web["web_agent
网络搜索助手
• MCP Playwright
• web_search
• crawl_webpage"] A_File["file_analysis_agent
文件分析助手
(14个工具)
• extract_* (PDF/Word/Excel/PPT)
• analyze_code
• analyze_multimodal"] A_Naga["naga_code_analysis_agent
NagaAgent 代码分析
(7个工具)
• read_file / glob
• search_file_content"] A_Ent["entertainment_agent
娱乐助手
(9个工具)
• ai_draw_one
• horoscope
• video_random_recommend"] + A_Code["code_delivery_agent
代码交付助手
(13个工具)
• Docker 容器隔离
• Git 仓库克隆
• 代码编写验证
• 打包上传"] end MCPRegistry["MCPToolRegistry
MCP 工具注册表
[mcp/registry.py]"] @@ -596,6 +598,7 @@ Undefined 支持 **MCP (Model Context Protocol)** 协议,可以连接外部 MC * **网络搜索**:"搜索一下 DeepSeek 的最新动态" * **B站视频**:发送 B 站链接/BV 号自动下载发送视频,或指令 AI "下载这个 B 站视频 BV1xx411c7mD" +* **代码交付**:"用 Python 写一个 HTTP 服务器,监听 8080 端口,返回 Hello World,打包发到这个群" * **定时任务**:"每天早上 8 点提醒我看新闻" ### 管理员命令 @@ -635,6 +638,8 @@ src/Undefined/ 请参考 [src/Undefined/skills/README.md](src/Undefined/skills/README.md) 了解如何编写新的工具和 Agent。 +**Agent 互调用功能**:查看 [docs/agent-calling.md](docs/agent-calling.md) 了解如何让 Agent 之间相互调用,实现复杂的多 Agent 协作场景。 + ### 开发自检 ```bash diff --git a/docs/agent-calling.md b/docs/agent-calling.md new file mode 100644 index 0000000..5a7cca3 --- /dev/null +++ b/docs/agent-calling.md @@ -0,0 +1,381 @@ +# Agent 互调用功能文档 + +## 概述 + +Agent 互调用功能允许 Undefined 项目中的 Agent 之间相互调用,实现复杂的多 Agent 协作场景。通过简单的配置文件,您可以将某个 Agent 注册为其他 Agent 的可调用工具,并支持细粒度的访问控制。 + +## 核心特性 + +- **简单配置**:只需在 Agent 目录下添加一个 `callable.json` 文件即可启用 +- **访问控制**:支持指定哪些 Agent 可以调用,提供白名单机制 +- **自动注册**:系统自动扫描并注册可调用的 Agent,无需手动配置 +- **参数透传**:保持每个 Agent 原有的参数定义,无需额外的参数映射 +- **工具命名**:自动生成 `call_{agent_name}` 格式的工具名称 + +## 快速开始 + +### 1. 让 Agent 可被调用 + +在 Agent 目录下创建 `callable.json` 文件: + +```json +{ + "enabled": true, + "allowed_callers": ["*"] +} +``` + +例如,让 `web_agent` 可被所有 Agent 调用: + +```bash +# 创建配置文件 +cat > src/Undefined/skills/agents/web_agent/callable.json << 'EOF' +{ + "enabled": true, + "allowed_callers": ["*"] +} +EOF +``` + +### 2. 限制调用权限 + +如果只想让特定 Agent 调用,可以指定允许的调用方列表: + +```json +{ + "enabled": true, + "allowed_callers": ["code_delivery_agent", "info_agent"] +} +``` + +### 3. 在其他 Agent 中调用 + +当 Agent 初始化时,会自动发现可调用的 Agent 并注册为工具。例如,`code_delivery_agent` 会自动获得 `call_web_agent` 工具,可以这样调用: + +```python +# AI 模型会看到 call_web_agent 工具 +{ + "name": "call_web_agent", + "arguments": { + "prompt": "搜索 Python 异步编程的最新发展" + } +} +``` + +## 配置文件详解 + +### 文件位置 + +``` +src/Undefined/skills/agents/{agent_name}/callable.json +``` + +### 配置格式 + +```json +{ + "enabled": true, + "allowed_callers": ["agent1", "agent2", ...] +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `enabled` | boolean | 是 | 是否启用该 Agent 作为可调用工具 | +| `allowed_callers` | array | 是 | 允许调用此 Agent 的 Agent 名称列表 | + +### allowed_callers 详解 + +- **允许所有 Agent 调用**:使用 `["*"]` +- **允许特定 Agent 调用**:使用具体的 Agent 名称列表,如 `["info_agent", "code_delivery_agent"]` +- **不允许任何 Agent 调用**:使用空列表 `[]` 或设置 `enabled: false` + +## 工具命名规则 + +可调用的 Agent 会被注册为工具,命名格式为:`call_{agent_name}` + +示例: +- `web_agent` → `call_web_agent` +- `info_agent` → `call_info_agent` +- `code_delivery_agent` → `call_code_delivery_agent` + +## 参数传递 + +Agent 互调用保持每个 Agent 原有的参数定义,无需额外的参数映射。调用方传入的参数会直接透传给目标 Agent。 + +例如,`web_agent` 的参数定义为: + +```json +{ + "prompt": { + "type": "string", + "description": "用户的搜索需求" + } +} +``` + +那么 `call_web_agent` 工具也会使用相同的参数定义。 + +## 访问控制机制 + +### 权限检查流程 + +1. 调用方 Agent 尝试调用 `call_{target_agent}` +2. 系统从 context 中获取当前 Agent 名称(`agent_name`) +3. 检查当前 Agent 是否在目标 Agent 的 `allowed_callers` 列表中 +4. 如果在列表中或列表包含 `"*"`,则允许调用 +5. 否则返回权限错误 + +### 权限错误示例 + +``` +错误:code_delivery_agent 无权调用 info_agent +``` + +## 使用场景 + +### 场景 1:网络搜索代理 + +让 `web_agent` 可被所有 Agent 调用,提供统一的网络搜索能力: + +```json +// src/Undefined/skills/agents/web_agent/callable.json +{ + "enabled": true, + "allowed_callers": ["*"] +} +``` + +### 场景 2:代码分析代理 + +让 `naga_code_analysis_agent` 只能被 `code_delivery_agent` 调用,避免其他 Agent 误用: + +```json +// src/Undefined/skills/agents/naga_code_analysis_agent/callable.json +{ + "enabled": true, + "allowed_callers": ["code_delivery_agent"] +} +``` + +### 场景 3:信息查询代理 + +让 `info_agent` 可被多个特定 Agent 调用: + +```json +// src/Undefined/skills/agents/info_agent/callable.json +{ + "enabled": true, + "allowed_callers": ["code_delivery_agent", "web_agent", "entertainment_agent"] +} +``` + +## 实现原理 + +### 自动扫描机制 + +当 Agent 初始化其工具注册表(`AgentToolRegistry`)时,系统会: + +1. 扫描 `agents/` 根目录下的所有 Agent 目录 +2. 查找包含 `callable.json` 且 `enabled: true` 的 Agent +3. 读取 Agent 的 `config.json` 获取参数定义 +4. 为每个可调用的 Agent 创建工具 schema 和 handler +5. 使用 `register_external_item()` 注册为外部工具 + +### 调用流程 + +``` +调用方 Agent + ↓ +调用 call_{target_agent} 工具 + ↓ +AgentToolRegistry.execute_tool() + ↓ +权限检查(检查 allowed_callers) + ↓ +ai_client.agent_registry.execute_agent() + ↓ +目标 Agent 执行 + ↓ +返回结果 +``` + +### 避免循环调用 + +- **自调用保护**:Agent 不会将自己注册为可调用工具 +- **迭代限制**:Agent 执行受 `max_iterations` 限制(默认 20 次) +- **上下文隔离**:每次调用都有独立的上下文,不会无限递归 + +## 日志与调试 + +### 注册日志 + +当 Agent 初始化时,会记录注册的可调用 Agent: + +``` +[AgentToolRegistry] 注册可调用 agent: call_web_agent,允许调用方: 所有 agent +[AgentToolRegistry] 注册可调用 agent: call_info_agent,允许调用方: code_delivery_agent +``` + +### 调用日志 + +当 Agent 调用其他 Agent 时,会记录调用信息: + +``` +[AgentCall] code_delivery_agent 调用 web_agent,参数: {'prompt': '搜索...'} +``` + +### 权限拒绝日志 + +当权限检查失败时,会记录警告: + +``` +[AgentCall] web_agent 尝试调用 info_agent,但未被授权 +``` + +## 最佳实践 + +### 1. 合理设置访问权限 + +- 对于通用工具型 Agent(如 `web_agent`),使用 `["*"]` 允许所有 Agent 调用 +- 对于专用 Agent(如 `code_delivery_agent`),限制只有特定 Agent 可以调用 +- 避免过度开放权限,防止 Agent 误用 + +### 2. 避免循环依赖 + +- 设计 Agent 调用关系时,避免 A 调用 B,B 又调用 A 的情况 +- 如果确实需要双向调用,确保有明确的终止条件 + +### 3. 参数设计 + +- 保持 Agent 参数定义的简洁性 +- 使用清晰的参数描述,帮助调用方理解如何使用 + +### 4. 测试验证 + +- 创建配置文件后,重启机器人验证功能 +- 检查日志确认 Agent 是否正确注册 +- 测试权限控制是否按预期工作 + +## 故障排查 + +### 问题 1:Agent 没有被注册为可调用工具 + +**可能原因**: +- `callable.json` 文件格式错误 +- `enabled` 设置为 `false` +- `allowed_callers` 为空列表 + +**解决方法**: +1. 检查 `callable.json` 文件格式是否正确 +2. 确认 `enabled: true` +3. 确认 `allowed_callers` 不为空 +4. 查看日志中的警告信息 + +### 问题 2:调用时提示权限错误 + +**可能原因**: +- 调用方 Agent 不在 `allowed_callers` 列表中 + +**解决方法**: +1. 检查目标 Agent 的 `callable.json` 配置 +2. 将调用方 Agent 添加到 `allowed_callers` 列表 +3. 或使用 `["*"]` 允许所有 Agent 调用 + +### 问题 3:调用失败 + +**可能原因**: +- 目标 Agent 执行出错 +- 参数传递错误 + +**解决方法**: +1. 查看日志中的错误信息 +2. 检查传递的参数是否符合目标 Agent 的参数定义 +3. 测试直接调用目标 Agent 是否正常 + +## 配置示例 + +### 示例 1:开放型 Agent + +```json +// src/Undefined/skills/agents/web_agent/callable.json +{ + "enabled": true, + "allowed_callers": ["*"] +} +``` + +### 示例 2:受限型 Agent + +```json +// src/Undefined/skills/agents/info_agent/callable.json +{ + "enabled": true, + "allowed_callers": ["code_delivery_agent", "web_agent"] +} +``` + +### 示例 3:禁用调用 + +```json +// src/Undefined/skills/agents/entertainment_agent/callable.json +{ + "enabled": false, + "allowed_callers": [] +} +``` + +## 技术细节 + +### 代码位置 + +- **主要实现**:`src/Undefined/skills/agents/agent_tool_registry.py` +- **相关类**:`AgentToolRegistry` +- **相关方法**: + - `load_tools()`:加载本地工具和可调用的 Agent + - `_scan_callable_agents()`:扫描所有可被调用的 Agent + - `_load_agent_config()`:读取 Agent 的 config.json + - `_create_agent_tool_schema()`:生成工具 schema + - `_create_agent_call_handler()`:创建 Agent 调用 handler + +### 类型定义 + +```python +def _scan_callable_agents(self) -> list[tuple[str, Path, list[str]]]: + """扫描所有可被调用的 agent + + 返回:[(agent_name, agent_dir, allowed_callers), ...] + """ +``` + +```python +def _create_agent_call_handler( + self, target_agent_name: str, allowed_callers: list[str] +) -> Callable[[dict[str, Any], dict[str, Any]], Awaitable[str]]: + """创建一个通用的 agent 调用 handler,带访问控制""" +``` + +## 更新日志 + +### v2.13.0 (2026-02-15) + +- 🎉 新增 Agent 互调用功能 +- ✨ 支持通过 `callable.json` 配置可调用 Agent +- 🔒 支持细粒度的访问控制(`allowed_callers`) +- 🚀 自动扫描和注册机制 +- 📝 完整的日志记录和调试支持 + +## 相关文档 + +- [Skills 开发指南](../src/Undefined/skills/README.md) +- [Agent 开发指南](../src/Undefined/skills/agents/README.md) +- [项目架构说明](../CLAUDE.md) + +## 反馈与支持 + +如果您在使用 Agent 互调用功能时遇到问题,或有改进建议,欢迎: + +- 提交 Issue:[GitHub Issues](https://github.com/69gg/Undefined/issues) +- 参与讨论:[GitHub Discussions](https://github.com/69gg/Undefined/discussions) diff --git a/src/Undefined/skills/agents/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index 9509a5e..9cf45ce 100644 --- a/src/Undefined/skills/agents/agent_tool_registry.py +++ b/src/Undefined/skills/agents/agent_tool_registry.py @@ -1,6 +1,7 @@ +import json import logging from pathlib import Path -from typing import Any +from typing import Any, Awaitable, Callable from Undefined.skills.registry import BaseRegistry from Undefined.utils.logging import redact_string @@ -24,8 +25,194 @@ def __init__(self, tools_dir: Path, mcp_config_path: Path | None = None) -> None self.load_tools() def load_tools(self) -> None: + """加载本地工具和可调用的 agent""" + # 1. 加载本地工具(原有逻辑) self.load_items() + # 2. 扫描并注册可调用的 agent + callable_agents = self._scan_callable_agents() + + for agent_name, agent_dir, allowed_callers in callable_agents: + # 读取 agent 的 config.json + agent_config = self._load_agent_config(agent_dir) + if not agent_config: + logger.warning(f"无法读取 agent {agent_name} 的配置,跳过注册") + continue + + # 创建工具 schema + tool_schema = self._create_agent_tool_schema(agent_name, agent_config) + + # 创建 handler(带访问控制) + handler = self._create_agent_call_handler(agent_name, allowed_callers) + + # 注册为外部工具 + tool_name = f"call_{agent_name}" + self.register_external_item(tool_name, tool_schema, handler) + + # 记录允许的调用方 + callers_str = ( + ", ".join(allowed_callers) + if "*" not in allowed_callers + else "所有 agent" + ) + logger.info( + f"[AgentToolRegistry] 注册可调用 agent: {tool_name}," + f"允许调用方: {callers_str}" + ) + + def _scan_callable_agents(self) -> list[tuple[str, Path, list[str]]]: + """扫描所有可被调用的 agent + + 返回:[(agent_name, agent_dir, allowed_callers), ...] + """ + agents_root = self.base_dir.parent.parent + if not agents_root.exists() or not agents_root.is_dir(): + return [] + + callable_agents: list[tuple[str, Path, list[str]]] = [] + for agent_dir in agents_root.iterdir(): + if not agent_dir.is_dir(): + continue + if agent_dir.name.startswith("_"): + continue + + # 跳过当前 agent(避免自己调用自己) + if agent_dir == self.base_dir.parent: + continue + + callable_json = agent_dir / "callable.json" + if not callable_json.exists(): + continue + + try: + with open(callable_json, "r", encoding="utf-8") as f: + config = json.load(f) + + if not config.get("enabled", False): + continue + + # 读取允许的调用方列表 + allowed_callers = config.get("allowed_callers", []) + if not isinstance(allowed_callers, list): + logger.warning( + f"{callable_json} 的 allowed_callers 必须是列表,跳过" + ) + continue + + # 空列表表示不允许任何调用 + if not allowed_callers: + logger.info(f"{agent_dir.name} 的 allowed_callers 为空,跳过注册") + continue + + callable_agents.append((agent_dir.name, agent_dir, allowed_callers)) + except Exception as e: + logger.warning(f"读取 {callable_json} 失败: {e}") + continue + + return callable_agents + + def _load_agent_config(self, agent_dir: Path) -> dict[str, Any] | None: + """读取 agent 的 config.json + + 返回:agent 的配置字典,失败返回 None + """ + config_path = agent_dir / "config.json" + if not config_path.exists(): + return None + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + if isinstance(config, dict): + return config + return None + except Exception as e: + logger.warning(f"读取 {config_path} 失败: {e}") + return None + + def _create_agent_tool_schema( + self, agent_name: str, agent_config: dict[str, Any] + ) -> dict[str, Any]: + """为可调用的 agent 创建工具 schema + + 参数: + agent_name: agent 名称 + agent_config: agent 的 config.json 内容 + + 返回:工具 schema 字典 + """ + function_def = agent_config.get("function", {}) + agent_description = function_def.get("description", f"{agent_name} agent") + agent_parameters = function_def.get( + "parameters", + { + "type": "object", + "properties": {"prompt": {"type": "string", "description": "任务描述"}}, + "required": ["prompt"], + }, + ) + + tool_name = f"call_{agent_name}" + tool_description = f"调用 {agent_name}: {agent_description}" + + return { + "type": "function", + "function": { + "name": tool_name, + "description": tool_description, + "parameters": agent_parameters, + }, + } + + def _create_agent_call_handler( + self, target_agent_name: str, allowed_callers: list[str] + ) -> Callable[[dict[str, Any], dict[str, Any]], Awaitable[str]]: + """创建一个通用的 agent 调用 handler,带访问控制 + + 参数: + target_agent_name: 目标 agent 的名称 + allowed_callers: 允许调用的 agent 名称列表 + + 返回:异步 handler 函数 + """ + + async def handler(args: dict[str, Any], context: dict[str, Any]) -> str: + # 1. 检查调用方权限 + current_agent = context.get("agent_name") + if not current_agent: + return "错误:无法确定调用方 agent" + + # 检查是否在允许列表中 + if "*" not in allowed_callers and current_agent not in allowed_callers: + logger.warning( + f"[AgentCall] {current_agent} 尝试调用 {target_agent_name},但未被授权" + ) + return f"错误:{current_agent} 无权调用 {target_agent_name}" + + # 2. 获取 AI client + ai_client = context.get("ai_client") + if not ai_client: + return "错误:AI client 未在上下文中提供" + + if not hasattr(ai_client, "agent_registry"): + return "错误:AI client 不支持 agent_registry" + + # 3. 调用目标 agent + try: + logger.info( + f"[AgentCall] {current_agent} 调用 {target_agent_name},参数: {args}" + ) + # 直接传递所有参数给目标 agent + result = await ai_client.agent_registry.execute_agent( + target_agent_name, args, context + ) + return str(result) + except Exception as e: + logger.exception(f"调用 agent {target_agent_name} 失败") + return f"调用 {target_agent_name} 失败: {e}" + + return handler + async def initialize_mcp_tools(self) -> None: """异步初始化该 Agent 配置的私有 MCP 工具服务器 diff --git a/src/Undefined/skills/agents/code_delivery_agent/mcp.json b/src/Undefined/skills/agents/code_delivery_agent/mcp.json new file mode 100644 index 0000000..db17d9b --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + } + } +} diff --git a/src/Undefined/skills/agents/info_agent/callable.json b/src/Undefined/skills/agents/info_agent/callable.json new file mode 100644 index 0000000..07a2550 --- /dev/null +++ b/src/Undefined/skills/agents/info_agent/callable.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "allowed_callers": ["code_delivery_agent"] +} diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/mcp.json b/src/Undefined/skills/agents/naga_code_analysis_agent/mcp.json new file mode 100644 index 0000000..db17d9b --- /dev/null +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + } + } +} diff --git a/src/Undefined/skills/agents/web_agent/callable.json b/src/Undefined/skills/agents/web_agent/callable.json new file mode 100644 index 0000000..72a93bd --- /dev/null +++ b/src/Undefined/skills/agents/web_agent/callable.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "allowed_callers": ["naga_code_alaysis_agent", "code_delivery_agent"] +} From e549fb3c61704f60984327da58eb9f54de4553e0 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 21:28:59 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(prompt):=20=E6=B7=BB=E5=8A=A0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=A2=9E=E9=87=8F=E5=AE=A1=E8=AE=A1=E8=A7=84?= =?UTF-8?q?=E5=88=99=EF=BC=8C=E9=98=B2=E6=AD=A2=E7=9B=B8=E5=90=8C=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=A4=9A=E6=AC=A1=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- res/prompts/undefined.xml | 65 ++++++++++++++++++++++++++++- res/prompts/undefined_nagaagent.xml | 65 ++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 8596702..dc21cd0 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -324,6 +324,15 @@ 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 如果之前你在讨论某个话题,回复时要自然延续 如果别人在回应你的话,要做出相应反应 + + **意图增量审计(决策前必须执行):** + 在决定调用任何业务工具或 Agent 前,先在内部推理中完成以下步骤: + 1. **回溯**:读取用户最近 1-3 条消息及你的回复历史 + 2. **对比**:分析当前消息是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 + 3. **定性**:将当前意图归类为 [新任务]、[参数修正] 或 [非实质性延伸] + 4. **阻断**:如果是 [非实质性延伸] 且上一条任务已在处理或已回复,严禁再次调用业务类工具/Agent,转为轻量回复 + 参考 end_summary 判断上一轮对话是否已闭环——若已闭环(summary 已生成),倾向于将新消息视为 [新任务]。 + @@ -535,7 +544,32 @@ - 这样避免过度活跃,保持真人感 - + + + 意图增量分析(防重复执行) + + 基于意图增量审计的定性结果,执行严格的分流策略: + + + + 新任务 + <判定>当前消息与上文无参数依赖,或明确开启新话题,或上一轮对话已闭环(end_summary 已生成) + <行动>正常处理,按需调用相关 Agent 或工具 + + + + 参数修正 + <判定>用户对上一条任务发出修正指令(如"不对,改成XX"、"要XX风格的"、"换成Python") + <行动>**参数继承**——提取上一条任务的核心对象,应用当前消息的修改参数,合成一个完整的新请求后重新调用对应工具/Agent + + + + 非实质性延伸 + <判定>仅包含催促、赞同、感谢或无具体语义的补充,且上一条任务已触发或已回复 + <行动>**资源熔断**——禁止调用任何业务 Agent 或重型工具,仅通过 send_message 做简短自然的回应即可 + + + @@ -659,7 +693,34 @@ 3. 不回复会导致对话中断或不自然 - + + + 用户说"帮我写个贪吃蛇",你已调用 code_delivery_agent 开始处理,用户紧接着说"搞快点,急用" + 再次调用 code_delivery_agent 重新生成代码 + + 内部推理:上条已触发代码生成,"搞快点"是纯催促,无新参数 → [非实质性延伸] → 资源熔断。 + 调用 send_message 简短回应(如"在写了在写了"),不重复调用 Agent。 + + + + + 用户说"帮我画个赛博朋克风格的猫",你已调用 entertainment_agent 处理,用户接着说"要粉色的" + 忽略修改,或当作全新任务从头开始 + + 内部推理:"粉色"是对颜色属性的修正,核心对象仍是"赛博朋克猫" → [参数修正] → 参数继承。 + 合并参数为"粉色赛博朋克猫",重新调用 entertainment_agent。 + + + + + 用户说"查下上海天气",你已调用 info_agent 回复了结果,用户说"不对 是浦东的" + 当作催促忽略,或当作全新查询丢失上下文 + + 内部推理:虽然包含否定词,但提供了更精确的地点参数 → [参数修正]。 + 继承"天气查询"任务,用精确地点"上海浦东"重新调用 info_agent。 + + + diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 4df4a92..9a7660d 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -359,6 +359,15 @@ 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 如果之前你在讨论某个话题,回复时要自然延续 如果别人在回应你的话,要做出相应反应 + + **意图增量审计(决策前必须执行):** + 在决定调用任何业务工具或 Agent 前,先在内部推理中完成以下步骤: + 1. **回溯**:读取用户最近 1-3 条消息及你的回复历史 + 2. **对比**:分析当前消息是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 + 3. **定性**:将当前意图归类为 [新任务]、[参数修正] 或 [非实质性延伸] + 4. **阻断**:如果是 [非实质性延伸] 且上一条任务已在处理或已回复,严禁再次调用业务类工具/Agent,转为轻量回复 + 参考 end_summary 判断上一轮对话是否已闭环——若已闭环(summary 已生成),倾向于将新消息视为 [新任务]。 + @@ -573,7 +582,32 @@ - 这样避免过度活跃,保持真人感 - + + + 意图增量分析(防重复执行) + + 基于意图增量审计的定性结果,执行严格的分流策略: + + + + 新任务 + <判定>当前消息与上文无参数依赖,或明确开启新话题,或上一轮对话已闭环(end_summary 已生成) + <行动>正常处理,按需调用相关 Agent 或工具 + + + + 参数修正 + <判定>用户对上一条任务发出修正指令(如"不对,改成XX"、"要XX风格的"、"换成Python") + <行动>**参数继承**——提取上一条任务的核心对象,应用当前消息的修改参数,合成一个完整的新请求后重新调用对应工具/Agent + + + + 非实质性延伸 + <判定>仅包含催促、赞同、感谢或无具体语义的补充,且上一条任务已触发或已回复 + <行动>**资源熔断**——禁止调用任何业务 Agent 或重型工具,仅通过 send_message 做简短自然的回应即可 + + + @@ -704,7 +738,34 @@ 3. 不回复会导致对话中断或不自然 - + + + 用户说"帮我写个贪吃蛇",你已调用 code_delivery_agent 开始处理,用户紧接着说"搞快点,急用" + 再次调用 code_delivery_agent 重新生成代码 + + 内部推理:上条已触发代码生成,"搞快点"是纯催促,无新参数 → [非实质性延伸] → 资源熔断。 + 调用 send_message 简短回应(如"在写了在写了"),不重复调用 Agent。 + + + + + 用户说"帮我画个赛博朋克风格的猫",你已调用 entertainment_agent 处理,用户接着说"要粉色的" + 忽略修改,或当作全新任务从头开始 + + 内部推理:"粉色"是对颜色属性的修正,核心对象仍是"赛博朋克猫" → [参数修正] → 参数继承。 + 合并参数为"粉色赛博朋克猫",重新调用 entertainment_agent。 + + + + + 用户说"查下上海天气",你已调用 info_agent 回复了结果,用户说"不对 是浦东的" + 当作催促忽略,或当作全新查询丢失上下文 + + 内部推理:虽然包含否定词,但提供了更精确的地点参数 → [参数修正]。 + 继承"天气查询"任务,用精确地点"上海浦东"重新调用 info_agent。 + + + From c6078fd941042108ea3c0aca013513abe507dca4 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 21:54:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(prompt):=20=E8=AE=A9AI=E6=80=BB?= =?UTF-8?q?=E6=98=AF=E7=9B=B8=E4=BF=A1=E8=87=AA=E5=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- res/prompts/undefined.xml | 61 +++++++++++++++++++++-------- res/prompts/undefined_nagaagent.xml | 61 +++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index dc21cd0..33b0bdd 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -565,8 +565,16 @@ 非实质性延伸 - <判定>仅包含催促、赞同、感谢或无具体语义的补充,且上一条任务已触发或已回复 - <行动>**资源熔断**——禁止调用任何业务 Agent 或重型工具,仅通过 send_message 做简短自然的回应即可 + <判定>仅包含催促、赞同、感谢或无具体语义的补充 + <关键假设> + **系统并发假设**:必须假设上一条任务消息已经触发了独立的处理进程正在执行中。 + 你无法直接看到其他进程的工具调用,但必须相信之前的自己已经在处理了。 + 如果你在历史中看到自己对该任务做过任何回应(如进度确认),这就是任务已在处理的证据。 + + <行动> + **完全资源熔断**——严禁重新调用上一条任务涉及的业务工具/Agent(如 code_delivery_agent、entertainment_agent、web_agent 等), + 否则会导致任务重复执行。仅允许调用 send_message 做简短自然的回应。 + @@ -695,30 +703,51 @@ - 用户说"帮我写个贪吃蛇",你已调用 code_delivery_agent 开始处理,用户紧接着说"搞快点,急用" - 再次调用 code_delivery_agent 重新生成代码 + 用户发出任务请求后,紧接着发送不含新参数的追加消息(催促、赞同、感谢、重复等) + 再次调用业务 Agent 处理同一任务(即使你觉得"之前没执行过") - 内部推理:上条已触发代码生成,"搞快点"是纯催促,无新参数 → [非实质性延伸] → 资源熔断。 - 调用 send_message 简短回应(如"在写了在写了"),不重复调用 Agent。 + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息不含新的具体参数或修正 + 2. 定性:[非实质性延伸] + 3. 系统并发假设:上条任务已有独立进程在处理,相信之前的自己 + 4. 完全资源熔断:只调用 send_message 做简短自然的回应 - 用户说"帮我画个赛博朋克风格的猫",你已调用 entertainment_agent 处理,用户接着说"要粉色的" - 忽略修改,或当作全新任务从头开始 + 用户发出任务请求后,追加消息中包含对结果的具体修正(改颜色、换语言、精确参数等) + 忽略修改,或丢失上下文当作全新任务 - 内部推理:"粉色"是对颜色属性的修正,核心对象仍是"赛博朋克猫" → [参数修正] → 参数继承。 - 合并参数为"粉色赛博朋克猫",重新调用 entertainment_agent。 + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息提供了具体的修改参数 + 2. 定性:[参数修正] — 核心任务不变,但属性/参数需要更新 + 3. 参数继承:提取上条任务的核心对象 + 当前消息的修改参数,合并为完整请求 + 4. 重新调用对应 Agent/工具执行合并后的请求 - 用户说"查下上海天气",你已调用 info_agent 回复了结果,用户说"不对 是浦东的" - 当作催促忽略,或当作全新查询丢失上下文 - - 内部推理:虽然包含否定词,但提供了更精确的地点参数 → [参数修正]。 - 继承"天气查询"任务,用精确地点"上海浦东"重新调用 info_agent。 - + "帮我写个blog" → "写快点" + 再次调用 code_delivery_agent + [非实质性延伸] → 系统并发假设 → 熔断,只 send_message 回应 + + + + "帮我搜一下React 19的新特性" → "好的谢谢" + 再次调用 web_agent 搜索 + [非实质性延伸] → 熔断,简短回应或直接 end + + + + "帮我画个猫" → "要赛博朋克风格的" + 忽略修改,或丢失"猫"这个核心对象重新开始 + [参数修正] → 参数继承,合并为"赛博朋克风格的猫"重新调用 + + + + "查下北京天气" → "不对 我说的是明天的" + 当作催促忽略,或丢失"北京"重新查询 + [参数修正] → 继承"北京天气",修正时间参数为"明天",重新调用 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 9a7660d..7972128 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -603,8 +603,16 @@ 非实质性延伸 - <判定>仅包含催促、赞同、感谢或无具体语义的补充,且上一条任务已触发或已回复 - <行动>**资源熔断**——禁止调用任何业务 Agent 或重型工具,仅通过 send_message 做简短自然的回应即可 + <判定>仅包含催促、赞同、感谢或无具体语义的补充 + <关键假设> + **系统并发假设**:必须假设上一条任务消息已经触发了独立的处理进程正在执行中。 + 你无法直接看到其他进程的工具调用,但必须相信之前的自己已经在处理了。 + 如果你在历史中看到自己对该任务做过任何回应(如进度确认),这就是任务已在处理的证据。 + + <行动> + **完全资源熔断**——严禁重新调用上一条任务涉及的业务工具/Agent(如 code_delivery_agent、entertainment_agent、web_agent 等), + 否则会导致任务重复执行。仅允许调用 send_message 做简短自然的回应。 + @@ -740,30 +748,51 @@ - 用户说"帮我写个贪吃蛇",你已调用 code_delivery_agent 开始处理,用户紧接着说"搞快点,急用" - 再次调用 code_delivery_agent 重新生成代码 + 用户发出任务请求后,紧接着发送不含新参数的追加消息(催促、赞同、感谢、重复等) + 再次调用业务 Agent 处理同一任务(即使你觉得"之前没执行过") - 内部推理:上条已触发代码生成,"搞快点"是纯催促,无新参数 → [非实质性延伸] → 资源熔断。 - 调用 send_message 简短回应(如"在写了在写了"),不重复调用 Agent。 + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息不含新的具体参数或修正 + 2. 定性:[非实质性延伸] + 3. 系统并发假设:上条任务已有独立进程在处理,相信之前的自己 + 4. 完全资源熔断:只调用 send_message 做简短自然的回应 - 用户说"帮我画个赛博朋克风格的猫",你已调用 entertainment_agent 处理,用户接着说"要粉色的" - 忽略修改,或当作全新任务从头开始 + 用户发出任务请求后,追加消息中包含对结果的具体修正(改颜色、换语言、精确参数等) + 忽略修改,或丢失上下文当作全新任务 - 内部推理:"粉色"是对颜色属性的修正,核心对象仍是"赛博朋克猫" → [参数修正] → 参数继承。 - 合并参数为"粉色赛博朋克猫",重新调用 entertainment_agent。 + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息提供了具体的修改参数 + 2. 定性:[参数修正] — 核心任务不变,但属性/参数需要更新 + 3. 参数继承:提取上条任务的核心对象 + 当前消息的修改参数,合并为完整请求 + 4. 重新调用对应 Agent/工具执行合并后的请求 - 用户说"查下上海天气",你已调用 info_agent 回复了结果,用户说"不对 是浦东的" - 当作催促忽略,或当作全新查询丢失上下文 - - 内部推理:虽然包含否定词,但提供了更精确的地点参数 → [参数修正]。 - 继承"天气查询"任务,用精确地点"上海浦东"重新调用 info_agent。 - + "帮我写个blog" → "写快点" + 再次调用 code_delivery_agent + [非实质性延伸] → 系统并发假设 → 熔断,只 send_message 回应 + + + + "帮我搜一下React 19的新特性" → "好的谢谢" + 再次调用 web_agent 搜索 + [非实质性延伸] → 熔断,简短回应或直接 end + + + + "帮我画个猫" → "要赛博朋克风格的" + 忽略修改,或丢失"猫"这个核心对象重新开始 + [参数修正] → 参数继承,合并为"赛博朋克风格的猫"重新调用 + + + + "查下北京天气" → "不对 我说的是明天的" + 当作催促忽略,或丢失"北京"重新查询 + [参数修正] → 继承"北京天气",修正时间参数为"明天",重新调用 From 41a21a80c0063e574d58a5e599779627afa4a38e Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 22:21:29 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(end):=20=E6=B7=BB=E5=8A=A0=E6=9B=B4?= =?UTF-8?q?=E5=8F=8B=E5=A5=BD=E7=9A=84end=E6=A3=80=E6=9F=A5=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Undefined/skills/tools/end/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Undefined/skills/tools/end/handler.py b/src/Undefined/skills/tools/end/handler.py index e00ace4..b52ad60 100644 --- a/src/Undefined/skills/tools/end/handler.py +++ b/src/Undefined/skills/tools/end/handler.py @@ -80,6 +80,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: return ( "拒绝结束对话:你记录了 summary 但本轮未发送任何消息或媒体内容。" "请先发送消息给用户,或者如果确实不需要发送,请使用 force=true 参数强制结束。" + "如果你本轮没有做任何事,若无必要,建议不加summary参数,避免记忆噪声。" ) if summary: From 9f92e83fe432ebd7aea7c7b3967839474d9f2fa7 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 15 Feb 2026 22:53:19 +0800 Subject: [PATCH 8/8] fix(code_delivery): isolate nested agent context and honor archive defaults --- .../skills/agents/agent_tool_registry.py | 22 +++++++++++++++++-- .../code_delivery_agent/tools/end/config.json | 2 +- .../code_delivery_agent/tools/end/handler.py | 17 ++++++++++++-- .../tools/write/handler.py | 7 ++++-- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/Undefined/skills/agents/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index 9cf45ce..2d1acb2 100644 --- a/src/Undefined/skills/agents/agent_tool_registry.py +++ b/src/Undefined/skills/agents/agent_tool_registry.py @@ -202,10 +202,28 @@ async def handler(args: dict[str, Any], context: dict[str, Any]) -> str: logger.info( f"[AgentCall] {current_agent} 调用 {target_agent_name},参数: {args}" ) - # 直接传递所有参数给目标 agent + # 构造被调用方上下文,避免复用调用方身份与历史。 + callee_context = context.copy() + callee_context["agent_name"] = target_agent_name + + agent_histories = context.get("agent_histories") + if not isinstance(agent_histories, dict): + agent_histories = {} + context["agent_histories"] = agent_histories + callee_history = agent_histories.get(target_agent_name, []) + if not isinstance(callee_history, list): + callee_history = [] + agent_histories[target_agent_name] = callee_history + callee_context["agent_history"] = callee_history + result = await ai_client.agent_registry.execute_agent( - target_agent_name, args, context + target_agent_name, args, callee_context ) + agent_prompt = str(args.get("prompt", "")).strip() + if agent_prompt and result: + callee_history.append({"role": "user", "content": agent_prompt}) + callee_history.append({"role": "assistant", "content": str(result)}) + agent_histories[target_agent_name] = callee_history return str(result) except Exception as e: logger.exception(f"调用 agent {target_agent_name} 失败") diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json index d0914ca..c98d5f9 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json @@ -20,7 +20,7 @@ "archive_format": { "type": "string", "enum": ["zip", "tar.gz"], - "description": "可选:归档格式(默认 zip)" + "description": "可选:归档格式(默认读取配置 code_delivery.default_archive_format)" }, "summary": { "type": "string", diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py index 92e7019..add0920 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py @@ -41,7 +41,7 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if not isinstance(exclude_patterns, list): exclude_patterns = [] archive_name = str(args.get("archive_name", "")).strip() or "delivery" - archive_format = str(args.get("archive_format", "")).strip().lower() or "zip" + archive_format_arg = str(args.get("archive_format", "")).strip().lower() summary = str(args.get("summary", "")).strip() workspace: Path | None = context.get("workspace") @@ -53,6 +53,20 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if not ws_resolved.exists(): return "错误:workspace 目录不存在" + config = context.get("config") + default_archive_format = "zip" + if config: + default_archive_format = getattr( + config, "code_delivery_default_archive_format", "zip" + ) + default_archive_format = str(default_archive_format).strip().lower() + if default_archive_format not in ("zip", "tar.gz"): + default_archive_format = "zip" + + archive_format = archive_format_arg or default_archive_format + if archive_format not in ("zip", "tar.gz"): + return "错误:archive_format 仅支持 zip 或 tar.gz" + # 收集要打包的文件 files_to_pack: list[Path] = [] for root, _dirs, filenames in os.walk(ws_resolved): @@ -86,7 +100,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: archive_hash = _file_hash(str(archive_path)) # 检查大小限制 - config = context.get("config") max_size_mb: int = 200 if config: max_size_mb = getattr(config, "code_delivery_max_archive_size_mb", 200) diff --git a/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py index d569b21..75ec6eb 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py @@ -36,8 +36,11 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if not workspace: return "错误:workspace 未设置" - full_path = (workspace / rel_path).resolve() - if not str(full_path).startswith(str(workspace.resolve())): + workspace_root = workspace.resolve() + full_path = (workspace_root / rel_path).resolve() + try: + full_path.relative_to(workspace_root) + except ValueError: return "错误:路径越界,只能写入 /workspace 下的文件" # 获取文件锁,防止并发写入竞态