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/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/config.toml.example b/config.toml.example index 32b37c1..753bdf2 100644 --- a/config.toml.example +++ b/config.toml.example @@ -383,6 +383,58 @@ 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: 容器内存限制(如 "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. [webui] 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/res/prompts/undefined.xml b/res/prompts/undefined.xml index 8596702..33b0bdd 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,40 @@ - 这样避免过度活跃,保持真人感 - + + + 意图增量分析(防重复执行) + + 基于意图增量审计的定性结果,执行严格的分流策略: + + + + 新任务 + <判定>当前消息与上文无参数依赖,或明确开启新话题,或上一轮对话已闭环(end_summary 已生成) + <行动>正常处理,按需调用相关 Agent 或工具 + + + + 参数修正 + <判定>用户对上一条任务发出修正指令(如"不对,改成XX"、"要XX风格的"、"换成Python") + <行动>**参数继承**——提取上一条任务的核心对象,应用当前消息的修改参数,合成一个完整的新请求后重新调用对应工具/Agent + + + + 非实质性延伸 + <判定>仅包含催促、赞同、感谢或无具体语义的补充 + <关键假设> + **系统并发假设**:必须假设上一条任务消息已经触发了独立的处理进程正在执行中。 + 你无法直接看到其他进程的工具调用,但必须相信之前的自己已经在处理了。 + 如果你在历史中看到自己对该任务做过任何回应(如进度确认),这就是任务已在处理的证据。 + + <行动> + **完全资源熔断**——严禁重新调用上一条任务涉及的业务工具/Agent(如 code_delivery_agent、entertainment_agent、web_agent 等), + 否则会导致任务重复执行。仅允许调用 send_message 做简短自然的回应。 + + + + @@ -659,7 +701,55 @@ 3. 不回复会导致对话中断或不自然 - + + + 用户发出任务请求后,紧接着发送不含新参数的追加消息(催促、赞同、感谢、重复等) + 再次调用业务 Agent 处理同一任务(即使你觉得"之前没执行过") + + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息不含新的具体参数或修正 + 2. 定性:[非实质性延伸] + 3. 系统并发假设:上条任务已有独立进程在处理,相信之前的自己 + 4. 完全资源熔断:只调用 send_message 做简短自然的回应 + + + + + 用户发出任务请求后,追加消息中包含对结果的具体修正(改颜色、换语言、精确参数等) + 忽略修改,或丢失上下文当作全新任务 + + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息提供了具体的修改参数 + 2. 定性:[参数修正] — 核心任务不变,但属性/参数需要更新 + 3. 参数继承:提取上条任务的核心对象 + 当前消息的修改参数,合并为完整请求 + 4. 重新调用对应 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 4df4a92..7972128 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,40 @@ - 这样避免过度活跃,保持真人感 - + + + 意图增量分析(防重复执行) + + 基于意图增量审计的定性结果,执行严格的分流策略: + + + + 新任务 + <判定>当前消息与上文无参数依赖,或明确开启新话题,或上一轮对话已闭环(end_summary 已生成) + <行动>正常处理,按需调用相关 Agent 或工具 + + + + 参数修正 + <判定>用户对上一条任务发出修正指令(如"不对,改成XX"、"要XX风格的"、"换成Python") + <行动>**参数继承**——提取上一条任务的核心对象,应用当前消息的修改参数,合成一个完整的新请求后重新调用对应工具/Agent + + + + 非实质性延伸 + <判定>仅包含催促、赞同、感谢或无具体语义的补充 + <关键假设> + **系统并发假设**:必须假设上一条任务消息已经触发了独立的处理进程正在执行中。 + 你无法直接看到其他进程的工具调用,但必须相信之前的自己已经在处理了。 + 如果你在历史中看到自己对该任务做过任何回应(如进度确认),这就是任务已在处理的证据。 + + <行动> + **完全资源熔断**——严禁重新调用上一条任务涉及的业务工具/Agent(如 code_delivery_agent、entertainment_agent、web_agent 等), + 否则会导致任务重复执行。仅允许调用 send_message 做简短自然的回应。 + + + + @@ -704,7 +746,55 @@ 3. 不回复会导致对话中断或不自然 - + + + 用户发出任务请求后,紧接着发送不含新参数的追加消息(催促、赞同、感谢、重复等) + 再次调用业务 Agent 处理同一任务(即使你觉得"之前没执行过") + + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息不含新的具体参数或修正 + 2. 定性:[非实质性延伸] + 3. 系统并发假设:上条任务已有独立进程在处理,相信之前的自己 + 4. 完全资源熔断:只调用 send_message 做简短自然的回应 + + + + + 用户发出任务请求后,追加消息中包含对结果的具体修正(改颜色、换语言、精确参数等) + 忽略修改,或丢失上下文当作全新任务 + + 意图增量审计: + 1. 回溯:上条是任务请求,当前消息提供了具体的修改参数 + 2. 定性:[参数修正] — 核心任务不变,但属性/参数需要更新 + 3. 参数继承:提取上条任务的核心对象 + 当前消息的修改参数,合并为完整请求 + 4. 重新调用对应 Agent/工具执行合并后的请求 + + + + + "帮我写个blog" → "写快点" + 再次调用 code_delivery_agent + [非实质性延伸] → 系统并发假设 → 熔断,只 send_message 回应 + + + + "帮我搜一下React 19的新特性" → "好的谢谢" + 再次调用 web_agent 搜索 + [非实质性延伸] → 熔断,简短回应或直接 end + + + + "帮我画个猫" → "要赛博朋克风格的" + 忽略修改,或丢失"猫"这个核心对象重新开始 + [参数修正] → 参数继承,合并为"赛博朋克风格的猫"重新调用 + + + + "查下北京天气" → "不对 我说的是明天的" + 当作催促忽略,或丢失"北京"重新查询 + [参数修正] → 继承"北京天气",修正时间参数为"明天",重新调用 + + diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index c60071d..324e5eb 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -408,6 +408,23 @@ 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 + 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 @@ -861,6 +878,77 @@ 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, + ) + 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) if strict: @@ -937,6 +1025,22 @@ 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, + 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/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/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index 9509a5e..2d1acb2 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,212 @@ 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}" + ) + # 构造被调用方上下文,避免复用调用方身份与历史。 + 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, 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} 失败") + 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/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..1db4c85 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import asyncio +import logging +import shutil +import uuid +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Docker 容器与工作区管理 +# --------------------------------------------------------------------------- + +CONTAINER_PREFIX_DEFAULT = "code_delivery_" +CONTAINER_SUFFIX_DEFAULT = "_runner" +TASK_ROOT_DEFAULT = "data/code_delivery" + + +async def _run_cmd(*args: str, timeout: float = 60) -> tuple[int, str, str]: + """执行宿主机命令,返回 (exit_code, stdout, stderr)。""" + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return -1, "", "timeout" + return ( + proc.returncode or 0, + stdout_b.decode("utf-8", errors="replace").strip(), + stderr_b.decode("utf-8", errors="replace").strip(), + ) + + +async def _cleanup_residual( + task_root: str, + prefix: str, + suffix: str, +) -> None: + """启动前清理残留工作区和容器。""" + # 清理残留目录 + root = Path(task_root) + if root.exists(): + for child in root.iterdir(): + if child.is_dir(): + try: + shutil.rmtree(child) + logger.info("[CodeDelivery] 清理残留目录: %s", child) + except Exception as exc: + logger.warning( + "[CodeDelivery] 清理残留目录失败: %s -> %s", child, exc + ) + + # 清理残留容器(匹配前后缀) + rc, stdout, _ = await _run_cmd("docker", "ps", "-a", "--format", "{{.Names}}") + if rc == 0 and stdout: + for name in stdout.splitlines(): + name = name.strip() + if name.startswith(prefix) and name.endswith(suffix): + logger.info("[CodeDelivery] 清理残留容器: %s", name) + await _run_cmd("docker", "rm", "-f", name) + + +async def _create_container( + container_name: str, + workspace: Path, + tmpfs_dir: Path, + docker_image: str, + memory_limit: str = "", + cpu_limit: str = "", +) -> None: + """创建并启动 Docker 容器。""" + cmd_args = [ + "docker", + "run", + "-d", + "--name", + container_name, + ] + + # 添加资源限制 + if memory_limit: + cmd_args.extend(["--memory", memory_limit]) + if cpu_limit: + cmd_args.extend(["--cpus", cpu_limit]) + + cmd_args.extend( + [ + "-v", + f"{workspace.resolve()}:/workspace", + "-v", + f"{tmpfs_dir.resolve()}:/tmpfs", + "-w", + "/workspace", + docker_image, + "sleep", + "infinity", + ] + ) + + rc, stdout, stderr = await _run_cmd(*cmd_args, timeout=120) + if rc != 0: + raise RuntimeError(f"创建容器失败: {stderr or stdout}") + logger.info("[CodeDelivery] 容器已创建: %s", container_name) + + +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_finish = True + llm_max_retries = 5 + notify_on_failure = True + memory_limit = "" + cpu_limit = "" + + if config: + if not getattr(config, "code_delivery_enabled", True): + return "Code Delivery Agent 已禁用" + task_root = getattr(config, "code_delivery_task_root", task_root) + docker_image = getattr(config, "code_delivery_docker_image", docker_image) + prefix = getattr(config, "code_delivery_container_name_prefix", prefix) + suffix = getattr(config, "code_delivery_container_name_suffix", suffix) + cleanup_on_finish = getattr(config, "code_delivery_cleanup_on_finish", True) + llm_max_retries = getattr(config, "code_delivery_llm_max_retries", 5) + notify_on_failure = getattr(config, "code_delivery_notify_on_llm_failure", True) + memory_limit = getattr(config, "code_delivery_container_memory_limit", "") + cpu_limit = getattr(config, "code_delivery_container_cpu_limit", "") + + # 创建任务目录 + task_id = str(uuid.uuid4()) + task_dir = Path(task_root) / task_id + workspace = task_dir / "workspace" + tmpfs_dir = task_dir / "tmpfs" + workspace.mkdir(parents=True, exist_ok=True) + tmpfs_dir.mkdir(parents=True, exist_ok=True) + (task_dir / "logs").mkdir(exist_ok=True) + (task_dir / "artifacts").mkdir(exist_ok=True) + + container_name = f"{prefix}{task_id}{suffix}" + + # 注入上下文供子工具使用 + context["task_dir"] = task_dir + context["workspace"] = workspace + context["container_name"] = container_name + context["target_type"] = target_type + context["target_id"] = target_id + + try: + # 创建容器 + await _create_container( + container_name, workspace, tmpfs_dir, docker_image, memory_limit, cpu_limit + ) + + # 初始化工作区 + await _init_workspace(workspace, container_name, source_type, git_url, git_ref) + + # 组装 user_content + source_info = ( + f"初始化来源: source_type=git, git_url={git_url}" + + (f", git_ref={git_ref}" if git_ref else "") + if source_type == "git" + else "初始化来源: source_type=empty(空目录,需要从零开始创建项目)" + ) + user_content = ( + f"用户需求:{user_prompt}\n\n" + f"{source_info}\n" + f"交付目标: target_type={target_type}, target_id={target_id}\n\n" + f"请开始工作。" + ) + + # 使用自定义 runner 支持 LLM 失败重试计数 + 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/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/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/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/end/config.json b/src/Undefined/skills/agents/code_delivery_agent/tools/end/config.json new file mode 100644 index 0000000..c98d5f9 --- /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": "可选:归档格式(默认读取配置 code_delivery.default_archive_format)" + }, + "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..add0920 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/end/handler.py @@ -0,0 +1,153 @@ +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_arg = str(args.get("archive_format", "")).strip().lower() + 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 目录不存在" + + 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): + 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)) + + # 检查大小限制 + 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/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 new file mode 100644 index 0000000..e2aa58f --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/config.json @@ -0,0 +1,42 @@ +{ + "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 根目录)" + }, + "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 new file mode 100644 index 0000000..7d91b65 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/glob/handler.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import logging +from datetime import datetime +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() + 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 不能为空" + + 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 + + # 解析 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: + # 收集匹配的文件及其元信息 + 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 + 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 + + rel = str(p.relative_to(ws_resolved)) + file_info.append((rel, size, mtime)) + + if len(file_info) >= MAX_RESULTS: + break + except Exception: + continue + + if not file_info: + return "未找到匹配文件" + + # 排序 + 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 new file mode 100644 index 0000000..f7c49aa --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/config.json @@ -0,0 +1,50 @@ +{ + "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)" + }, + "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 new file mode 100644 index 0000000..ee85ef2 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/grep/handler.py @@ -0,0 +1,190 @@ +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 + + +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 不能为空" + + 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}" + + # 根据 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: + 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 = 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): + match_line_numbers.append(lineno) + total_matches += 1 + if total_matches >= max_matches: + break + + 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 file_matches: + return "未找到匹配内容" + + # 根据 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 new file mode 100644 index 0000000..b9e4af4 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/config.json @@ -0,0 +1,34 @@ +{ + "type": "function", + "function": { + "name": "read", + "description": "读取工作区内文件的文本内容。支持完整读取、按行读取、读取元信息、批量读取。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径(相对于 /workspace),或用逗号分隔的多个路径(批量读取)" + }, + "mode": { + "type": "string", + "enum": ["full", "lines", "stat"], + "description": "读取模式:full(完整读取,默认)、lines(按行读取)、stat(仅读取文件元信息)" + }, + "max_chars": { + "type": "integer", + "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 new file mode 100644 index 0000000..aabb03a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/read/handler.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiofiles + +logger = logging.getLogger(__name__) + + +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 f"错误:路径越界: {rel_path}" + + if not full_path.exists(): + return f"文件不存在: {rel_path}" + if full_path.is_dir(): + return f"错误:{rel_path} 是目录,不是文件" + + try: + 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 + + 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 + + except Exception as exc: + logger.exception("读取文件失败: %s", rel_path) + 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 new file mode 100644 index 0000000..8341213 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/config.json @@ -0,0 +1,38 @@ +{ + "type": "function", + "function": { + "name": "run_bash_command", + "description": "在 Docker 容器内执行 bash 命令。支持前台/后台执行和进程终止。", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["run", "kill"], + "description": "操作类型:run(执行命令,默认)或 kill(终止后台进程)" + }, + "command": { + "type": "string", + "description": "要执行的 bash 命令(action=run 时需要)" + }, + "timeout_seconds": { + "type": "integer", + "description": "命令超时时间(秒),默认使用配置值" + }, + "workdir": { + "type": "string", + "description": "可选:工作目录(容器内绝对路径,默认 /workspace)" + }, + "background": { + "type": "boolean", + "description": "是否后台执行(默认 false)。后台执行时返回进程ID,不等待命令完成" + }, + "pid": { + "type": "integer", + "description": "要终止的进程ID(action=kill 时需要)" + } + }, + "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 new file mode 100644 index 0000000..39b8521 --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/run_bash_command/handler.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +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 命令。支持前台/后台执行和进程终止。""" + + action = str(args.get("action", "run")).strip().lower() + + 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 + 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", + "-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..e07991a --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/todo/handler.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +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" + + +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 未设置" + + 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} 的待办项" + + 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/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 new file mode 100644 index 0000000..f1acd6c --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/config.json @@ -0,0 +1,38 @@ +{ + "type": "function", + "function": { + "name": "write", + "description": "写入文件到工作区。支持多种写入模式:覆盖、追加、精确替换、行级插入。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径(相对于 /workspace)" + }, + "content": { + "type": "string", + "description": "要写入的内容(overwrite/append/insert_at_line 模式需要)" + }, + "mode": { + "type": "string", + "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"] + } + } +} 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..75ec6eb --- /dev/null +++ b/src/Undefined/skills/agents/code_delivery_agent/tools/write/handler.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any + +import aiofiles + +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() + 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 未设置" + + workspace_root = workspace.resolve() + full_path = (workspace_root / rel_path).resolve() + try: + full_path.relative_to(workspace_root) + except ValueError: + return "错误:路径越界,只能写入 /workspace 下的文件" + + # 获取文件锁,防止并发写入竞态 + 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}" 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"] +} 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: