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: