feat(deep-agent): add skills support and improve project structure

- Add skills parameter to ChatRequest for skill file processing
- Extract and unzip skill files to robot project skills directory
- Add robot_config.json with bot_id and environment variables
- Update symlink setup to skip if ~/.deepagents already exists
- Enhance system prompt with directory access restrictions
- Refactor _get_robot_dir to handle symlink paths correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
朱潮 2025-12-31 13:21:58 +08:00
parent c808517f02
commit 766b9becda
7 changed files with 129 additions and 79 deletions

View File

@ -1,14 +1,5 @@
[ [
{ {
"mcpServers": { "mcpServers": {}
"rag_retrieve": {
"transport": "stdio",
"command": "python",
"args": [
"./mcp/rag_retrieve_server.py",
"{bot_id}"
]
}
}
} }
] ]

View File

@ -1,5 +1,8 @@
<env> <env>
<env>
Working directory: {agent_dir_path} Working directory: {agent_dir_path}
Current User: {user_identifier}
Current Time: {datetime}
</env> </env>
### Current Working Directory ### Current Working Directory
@ -8,6 +11,13 @@ The filesystem backend is currently operating in: `{agent_dir_path}`
### File System and Paths ### File System and Paths
**CRITICAL - Directory Access Restriction:**
- You are **ONLY** allowed to access files and directories within `{agent_dir_path}`
- **NEVER** attempt to access files outside this directory (e.g., `/etc/`, `/Users/`, `~/`, parent directories)
- All file operations (read, write, list, execute) are restricted to `{agent_dir_path}` and its subdirectories
- If you need information from outside your working directory, ask the user to provide it
- Any attempt to bypass this restriction is a security violation
**IMPORTANT - Path Handling:** **IMPORTANT - Path Handling:**
- All file paths must be absolute paths (e.g., `{agent_dir_path}/file.txt`) - All file paths must be absolute paths (e.g., `{agent_dir_path}/file.txt`)
- Use the working directory from <env> to construct absolute paths - Use the working directory from <env> to construct absolute paths
@ -55,8 +65,4 @@ When using the write_todos tool:
- If they want changes, adjust the plan accordingly - If they want changes, adjust the plan accordingly
6. Update todo status promptly as you complete each item 6. Update todo status promptly as you complete each item
The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking. The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking.
## System Information
- **Current User**: {user_identifier}
- **Current Time**: {datetime}

View File

@ -286,10 +286,10 @@ async def chat_completions(request: ChatRequest, authorization: Optional[str] =
raise HTTPException(status_code=400, detail="bot_id is required") raise HTTPException(status_code=400, detail="bot_id is required")
# 创建项目目录如果有dataset_ids且不是agent类型 # 创建项目目录如果有dataset_ids且不是agent类型
project_dir = create_project_directory(request.dataset_ids, bot_id, request.robot_type) project_dir = create_project_directory(request.dataset_ids, bot_id, request.robot_type, request.skills)
# 收集额外参数作为 generate_cfg # 收集额外参数作为 generate_cfg
exclude_fields = {'messages', 'model', 'model_server', 'dataset_ids', 'language', 'tool_response', 'system_prompt', 'mcp_settings' ,'stream', 'robot_type', 'bot_id', 'user_identifier', 'session_id', 'enable_thinking'} exclude_fields = {'messages', 'model', 'model_server', 'dataset_ids', 'language', 'tool_response', 'system_prompt', 'mcp_settings' ,'stream', 'robot_type', 'bot_id', 'user_identifier', 'session_id', 'enable_thinking', 'skills'}
generate_cfg = {k: v for k, v in request.model_dump().items() if k not in exclude_fields} generate_cfg = {k: v for k, v in request.model_dump().items() if k not in exclude_fields}
# 处理消息 # 处理消息
messages = process_messages(request.messages, request.language) messages = process_messages(request.messages, request.language)
@ -336,10 +336,10 @@ async def chat_warmup_v1(request: ChatRequest, authorization: Optional[str] = He
raise HTTPException(status_code=400, detail="bot_id is required") raise HTTPException(status_code=400, detail="bot_id is required")
# 创建项目目录如果有dataset_ids且不是agent类型 # 创建项目目录如果有dataset_ids且不是agent类型
project_dir = create_project_directory(request.dataset_ids, bot_id, request.robot_type) project_dir = create_project_directory(request.dataset_ids, bot_id, request.robot_type, request.skills)
# 收集额外参数作为 generate_cfg # 收集额外参数作为 generate_cfg
exclude_fields = {'messages', 'model', 'model_server', 'dataset_ids', 'language', 'tool_response', 'system_prompt', 'mcp_settings' ,'stream', 'robot_type', 'bot_id', 'user_identifier', 'session_id', 'enable_thinking'} exclude_fields = {'messages', 'model', 'model_server', 'dataset_ids', 'language', 'tool_response', 'system_prompt', 'mcp_settings' ,'stream', 'robot_type', 'bot_id', 'user_identifier', 'session_id', 'enable_thinking', 'skills'}
generate_cfg = {k: v for k, v in request.model_dump().items() if k not in exclude_fields} generate_cfg = {k: v for k, v in request.model_dump().items() if k not in exclude_fields}
# 创建一个空的消息列表用于预热实际消息不会在warmup中处理 # 创建一个空的消息列表用于预热实际消息不会在warmup中处理
@ -431,11 +431,12 @@ async def chat_warmup_v2(request: ChatRequestV2, authorization: Optional[str] =
# 从后端API获取机器人配置使用v2的鉴权方式 # 从后端API获取机器人配置使用v2的鉴权方式
bot_config = await fetch_bot_config(bot_id) bot_config = await fetch_bot_config(bot_id)
# 创建项目目录从后端配置获取dataset_ids # 创建项目目录从后端配置获取dataset_ids和skills
project_dir = create_project_directory( project_dir = create_project_directory(
bot_config.get("dataset_ids", []), bot_config.get("dataset_ids", []),
bot_id, bot_id,
bot_config.get("robot_type", "general_agent") bot_config.get("robot_type", "general_agent"),
bot_config.get("skills")
) )
# 创建一个空的消息列表用于预热实际消息不会在warmup中处理 # 创建一个空的消息列表用于预热实际消息不会在warmup中处理
@ -533,11 +534,12 @@ async def chat_completions_v2(request: ChatRequestV2, authorization: Optional[st
# 从后端API获取机器人配置使用v2的鉴权方式 # 从后端API获取机器人配置使用v2的鉴权方式
bot_config = await fetch_bot_config(bot_id) bot_config = await fetch_bot_config(bot_id)
# 创建项目目录从后端配置获取dataset_ids # 创建项目目录从后端配置获取dataset_ids和skills
project_dir = create_project_directory( project_dir = create_project_directory(
bot_config.get("dataset_ids", []), bot_config.get("dataset_ids", []),
bot_id, bot_id,
bot_config.get("robot_type", "general_agent") bot_config.get("robot_type", "general_agent"),
bot_config.get("skills")
) )
# 处理消息 # 处理消息
messages = process_messages(request.messages, request.language) messages = process_messages(request.messages, request.language)

View File

@ -54,6 +54,7 @@ class ChatRequest(BaseModel):
user_identifier: Optional[str] = "" user_identifier: Optional[str] = ""
session_id: Optional[str] = None session_id: Optional[str] = None
enable_thinking: Optional[bool] = DEFAULT_THINKING_ENABLE enable_thinking: Optional[bool] = DEFAULT_THINKING_ENABLE
skills: Optional[List[str]] = None
class ChatRequestV2(BaseModel): class ChatRequestV2(BaseModel):

View File

@ -364,17 +364,17 @@ def format_messages_to_chat_history(messages: List[Dict[str, str]]) -> str:
return "\n".join(recent_chat_history) return "\n".join(recent_chat_history)
def create_project_directory(dataset_ids: Optional[List[str]], bot_id: str, robot_type: str = "general_agent") -> Optional[str]: def create_project_directory(dataset_ids: Optional[List[str]], bot_id: str, robot_type: str = "general_agent", skills: Optional[List[str]] = None) -> Optional[str]:
"""创建项目目录的公共逻辑""" """创建项目目录的公共逻辑"""
# 只有当 robot_type == "catalog_agent" 且 dataset_ids 不为空时才创建目录 # 只有当 robot_type == "catalog_agent" 且 dataset_ids 不为空时才创建目录
if robot_type == "general_agent": if robot_type == "general_agent":
return None return None
try: try:
from utils.multi_project_manager import create_robot_project from utils.multi_project_manager import create_robot_project
from pathlib import Path from pathlib import Path
return create_robot_project(dataset_ids, bot_id, Path("~", ".deepagents")) return create_robot_project(dataset_ids, bot_id, project_path=Path("~", ".deepagents"), skills=skills)
except Exception as e: except Exception as e:
logger.error(f"Error creating project directory: {e}") logger.error(f"Error creating project directory: {e}")
return None return None

View File

@ -15,6 +15,7 @@ from datetime import datetime
logger = logging.getLogger('app') logger = logging.getLogger('app')
from utils.file_utils import get_document_preview from utils.file_utils import get_document_preview
from utils import settings
def generate_robot_directory_tree(robot_dir: str, robot_id: str, max_depth: int = 3) -> str: def generate_robot_directory_tree(robot_dir: str, robot_id: str, max_depth: int = 3) -> str:
@ -301,22 +302,42 @@ def generate_robot_readme(robot_id: str, dataset_ids: List[str], copy_results: L
return str(readme_path) return str(readme_path)
def _get_robot_dir(project_path: Path, bot_id: str) -> Path:
"""
获取 robot 目录路径处理软链接情况
Args:
project_path: 项目路径
bot_id: 机器人ID
Returns:
Path: robot 目录路径已展开
"""
resolved_path = project_path.expanduser().resolve()
if resolved_path.name == "robot":
# project_path 已经指向 robot 目录(如 ~/.deepagents -> projects/robot
return (project_path / bot_id).expanduser()
else:
# project_path 指向 projects 目录
return (project_path / "robot" / bot_id).expanduser()
def should_rebuild_robot_project(dataset_ids: List[str], bot_id: str, project_path: Path) -> bool: def should_rebuild_robot_project(dataset_ids: List[str], bot_id: str, project_path: Path) -> bool:
""" """
检查是否需要重建机器人项目 检查是否需要重建机器人项目
1. 检查机器人项目是否存在 1. 检查机器人项目是否存在
2. 检查是否有新增的dataset_id 2. 检查是否有新增的dataset_id
3. 检查processing_log.json文件是否更新 3. 检查processing_log.json文件是否更新
Args: Args:
dataset_ids: 源项目ID列表 dataset_ids: 源项目ID列表
bot_id: 机器人ID bot_id: 机器人ID
project_path: 项目路径 project_path: 项目路径
Returns: Returns:
bool: 是否需要重建 bool: 是否需要重建
""" """
robot_dir = project_path / "robot" / bot_id robot_dir = _get_robot_dir(project_path, bot_id)
# 如果机器人项目不存在,需要创建 # 如果机器人项目不存在,需要创建
if not robot_dir.exists(): if not robot_dir.exists():
@ -375,35 +396,39 @@ def should_rebuild_robot_project(dataset_ids: List[str], bot_id: str, project_pa
return False return False
def create_robot_project(dataset_ids: List[str], bot_id: str, force_rebuild: bool = False, project_path: Path = Path("projects")) -> str: def create_robot_project(dataset_ids: List[str], bot_id: str, force_rebuild: bool = False, project_path: Path = Path("projects"), skills: Optional[List[str]] = None) -> str:
""" """
创建机器人项目合并多个源项目的dataset文件夹 创建机器人项目合并多个源项目的dataset文件夹
Args: Args:
dataset_ids: 源项目ID列表 dataset_ids: 源项目ID列表
bot_id: 机器人ID bot_id: 机器人ID
force_rebuild: 是否强制重建 force_rebuild: 是否强制重建
skills: 技能文件名列表 ["rag-retrieve", "device_controller.zip"]
Returns: Returns:
str: 机器人项目目录路径 str: 机器人项目目录路径
""" """
logger.info(f"Creating robot project: {bot_id} from sources: {dataset_ids}") logger.info(f"Creating robot project: {bot_id} from sources: {dataset_ids}, skills: {skills}")
# 检查是否需要重建 # 检查是否需要重建
if not force_rebuild and not should_rebuild_robot_project(dataset_ids, bot_id, project_path): if not force_rebuild and not should_rebuild_robot_project(dataset_ids, bot_id, project_path):
robot_dir = project_path / "robot" / bot_id robot_dir = project_path / "robot" / bot_id
logger.info(f"Using existing robot project: {robot_dir}") logger.info(f"Using existing robot project: {robot_dir}")
# 即使使用现有项目,也要处理 skills如果提供了
if skills:
_extract_skills_to_robot(robot_dir, skills, project_path)
return str(robot_dir) return str(robot_dir)
# 创建机器人目录结构 # 创建机器人目录结构
robot_dir = project_path / "robot" / bot_id robot_dir = _get_robot_dir(project_path, bot_id)
dataset_dir = robot_dir / "dataset" dataset_dir = robot_dir / "dataset"
# 清理已存在的目录(如果需要) # 清理已存在的目录(如果需要)
if robot_dir.exists(): if robot_dir.exists():
logger.info(f"Robot directory already exists, cleaning up: {robot_dir}") logger.info(f"Robot directory already exists, cleaning up: {robot_dir}")
shutil.rmtree(robot_dir) shutil.rmtree(robot_dir)
robot_dir.mkdir(parents=True, exist_ok=True) robot_dir.mkdir(parents=True, exist_ok=True)
dataset_dir.mkdir(parents=True, exist_ok=True) dataset_dir.mkdir(parents=True, exist_ok=True)
@ -435,6 +460,11 @@ def create_robot_project(dataset_ids: List[str], bot_id: str, force_rebuild: boo
config_file = robot_dir / "robot_config.json" config_file = robot_dir / "robot_config.json"
config_data = { config_data = {
"dataset_ids": dataset_ids, "dataset_ids": dataset_ids,
"bot_id": bot_id,
"env": {
"backend_host": settings.BACKEND_HOST,
"masterkey": settings.MASTERKEY
},
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
"total_folders": len(copy_results), "total_folders": len(copy_results),
"successful_copies": sum(1 for r in copy_results if r["success"]) "successful_copies": sum(1 for r in copy_results if r["success"])
@ -454,7 +484,11 @@ def create_robot_project(dataset_ids: List[str], bot_id: str, force_rebuild: boo
logger.info(f" Successful copies: {successful_copies}") logger.info(f" Successful copies: {successful_copies}")
logger.info(f" Config saved: {config_file}") logger.info(f" Config saved: {config_file}")
logger.info(f" README generated: {readme_path}") logger.info(f" README generated: {readme_path}")
# 处理 skills 解压
if skills:
_extract_skills_to_robot(robot_dir, skills, project_path)
return str(robot_dir) return str(robot_dir)
@ -462,7 +496,58 @@ if __name__ == "__main__":
# 测试代码 # 测试代码
test_dataset_ids = ["test-project-1", "test-project-2"] test_dataset_ids = ["test-project-1", "test-project-2"]
test_bot_id = "test-robot-001" test_bot_id = "test-robot-001"
robot_dir = create_robot_project(test_dataset_ids, test_bot_id) robot_dir = create_robot_project(test_dataset_ids, test_bot_id)
logger.info(f"Created robot project at: {robot_dir}") logger.info(f"Created robot project at: {robot_dir}")
def _extract_skills_to_robot(robot_dir: Path, skills: List[str], project_path: Path) -> None:
"""
解压 skills robot 项目的 skills 文件夹
Args:
robot_dir: 机器人项目目录
skills: 技能文件名列表 ["rag-retrieve", "device_controller.zip"]
project_path: 项目路径
"""
import zipfile
# skills 源目录在 projects/skills需要通过解析软链接获取正确路径
# project_path 可能是 ~/.deepagents (软链接 -> projects/robot)
# 所以 skills 源目录是 project_path.resolve().parent / "skills"
resolved_path = project_path.expanduser().resolve()
skills_source_dir = resolved_path.parent / "skills"
skills_target_dir = robot_dir / "skills"
# 先清空 skills_target_dir然后重新解压
if skills_target_dir.exists():
logger.info(f" Removing existing skills directory: {skills_target_dir}")
shutil.rmtree(skills_target_dir)
skills_target_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Extracting skills to {skills_target_dir}")
for skill in skills:
# 规范化文件名(确保有 .zip 后缀)
if not skill.endswith(".zip"):
skill_file = skill + ".zip"
else:
skill_file = skill
skill_source_path = skills_source_dir / skill_file
if not skill_source_path.exists():
logger.warning(f" Skill file not found: {skill_source_path}")
continue
# 获取解压后的文件夹名称(去掉 .zip 后缀)
folder_name = skill_file.replace(".zip", "")
extract_target = skills_target_dir / folder_name
# 解压文件
try:
with zipfile.ZipFile(skill_source_path, 'r') as zip_ref:
zip_ref.extractall(extract_target)
logger.info(f" Extracted: {skill_file} -> {extract_target}")
except Exception as e:
logger.error(f" Failed to extract {skill_file}: {e}")

View File

@ -24,47 +24,12 @@ def setup_deepagents_symlink():
# Create robot directory if it doesn't exist # Create robot directory if it doesn't exist
robot_dir.mkdir(parents=True, exist_ok=True) robot_dir.mkdir(parents=True, exist_ok=True)
# If ~/.deepagents already exists and is not a symlink, backup and remove it # If ~/.deepagents already exists, do nothing
if deepagents_dir.exists() and not deepagents_dir.is_symlink(): if deepagents_dir.exists():
backup_dir = deepagents_dir.parent / f"{deepagents_dir.name}.backup" logger.info(f"~/.deepagents already exists at {deepagents_dir}, skipping symlink creation")
logger.warning(f"~/.deepagents directory exists but is not a symlink.") return True
logger.warning(f"Creating backup at {backup_dir}")
try:
# Create backup
import shutil
if backup_dir.exists():
shutil.rmtree(backup_dir)
shutil.move(str(deepagents_dir), str(backup_dir))
logger.info(f"Successfully backed up existing directory to {backup_dir}")
except Exception as backup_error:
logger.error(f"Failed to backup existing directory: {backup_error}")
logger.error("Please manually remove or backup ~/.deepagents to proceed")
return False
# If ~/.deepagents is already a symlink pointing to the right place, do nothing
if deepagents_dir.is_symlink():
target = deepagents_dir.resolve()
if target == robot_dir.resolve():
logger.info(f"~/.deepagents already points to {robot_dir}")
return True
else:
# Remove existing symlink pointing elsewhere
deepagents_dir.unlink()
logger.info(f"Removed existing symlink pointing to {target}")
# Create the symbolic link # Create the symbolic link
# Check again before creating to handle race conditions
if deepagents_dir.is_symlink() or deepagents_dir.exists():
logger.warning(f"Path {deepagents_dir} exists, attempting to remove before symlink")
if deepagents_dir.is_symlink():
deepagents_dir.unlink()
elif deepagents_dir.is_dir():
import shutil
shutil.rmtree(str(deepagents_dir))
else:
deepagents_dir.unlink()
os.symlink(robot_dir, deepagents_dir, target_is_directory=True) os.symlink(robot_dir, deepagents_dir, target_is_directory=True)
logger.info(f"Created symbolic link: {deepagents_dir} -> {robot_dir}") logger.info(f"Created symbolic link: {deepagents_dir} -> {robot_dir}")
return True return True