mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-11 01:34:45 +08:00
* refactor: extract shared utils to break harness→app cross-layer imports Move _validate_skill_frontmatter to src/skills/validation.py and CONVERTIBLE_EXTENSIONS + convert_file_to_markdown to src/utils/file_conversion.py. This eliminates the two reverse dependencies from client.py (harness layer) into gateway/routers/ (app layer), preparing for the harness/app package split. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: split backend/src into harness (deerflow.*) and app (app.*) Physically split the monolithic backend/src/ package into two layers: - **Harness** (`packages/harness/deerflow/`): publishable agent framework package with import prefix `deerflow.*`. Contains agents, sandbox, tools, models, MCP, skills, config, and all core infrastructure. - **App** (`app/`): unpublished application code with import prefix `app.*`. Contains gateway (FastAPI REST API) and channels (IM integrations). Key changes: - Move 13 harness modules to packages/harness/deerflow/ via git mv - Move gateway + channels to app/ via git mv - Rename all imports: src.* → deerflow.* (harness) / app.* (app layer) - Set up uv workspace with deerflow-harness as workspace member - Update langgraph.json, config.example.yaml, all scripts, Docker files - Add build-system (hatchling) to harness pyproject.toml - Add PYTHONPATH=. to gateway startup commands for app.* resolution - Update ruff.toml with known-first-party for import sorting - Update all documentation to reflect new directory structure Boundary rule enforced: harness code never imports from app. All 429 tests pass. Lint clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add harness→app boundary check test and update docs Add test_harness_boundary.py that scans all Python files in packages/harness/deerflow/ and fails if any `from app.*` or `import app.*` statement is found. This enforces the architectural rule that the harness layer never depends on the app layer. Update CLAUDE.md to document the harness/app split architecture, import conventions, and the boundary enforcement test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add config versioning with auto-upgrade on startup When config.example.yaml schema changes, developers' local config.yaml files can silently become outdated. This adds a config_version field and auto-upgrade mechanism so breaking changes (like src.* → deerflow.* renames) are applied automatically before services start. - Add config_version: 1 to config.example.yaml - Add startup version check warning in AppConfig.from_file() - Add scripts/config-upgrade.sh with migration registry for value replacements - Add `make config-upgrade` target - Auto-run config-upgrade in serve.sh and start-daemon.sh before starting services - Add config error hints in service failure messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix comments * fix: update src.* import in test_sandbox_tools_security to deerflow.* Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle empty config and search parent dirs for config.example.yaml Address Copilot review comments on PR #1131: - Guard against yaml.safe_load() returning None for empty config files - Search parent directories for config.example.yaml instead of only looking next to config.yaml, fixing detection in common setups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct skills root path depth and config_version type coercion - loader.py: fix get_skills_root_path() to use 5 parent levels (was 3) after harness split, file lives at packages/harness/deerflow/skills/ so parent×3 resolved to backend/packages/harness/ instead of backend/ - app_config.py: coerce config_version to int() before comparison in _check_config_version() to prevent TypeError when YAML stores value as string (e.g. config_version: "1") - tests: add regression tests for both fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: update test imports from src.* to deerflow.*/app.* after harness refactor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""CRUD API for custom agents."""
|
|
|
|
import logging
|
|
import re
|
|
import shutil
|
|
|
|
import yaml
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
|
from deerflow.config.paths import get_paths
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["agents"])
|
|
|
|
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
|
|
|
|
|
class AgentResponse(BaseModel):
|
|
"""Response model for a custom agent."""
|
|
|
|
name: str = Field(..., description="Agent name (hyphen-case)")
|
|
description: str = Field(default="", description="Agent description")
|
|
model: str | None = Field(default=None, description="Optional model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
|
soul: str | None = Field(default=None, description="SOUL.md content (included on GET /{name})")
|
|
|
|
|
|
class AgentsListResponse(BaseModel):
|
|
"""Response model for listing all custom agents."""
|
|
|
|
agents: list[AgentResponse]
|
|
|
|
|
|
class AgentCreateRequest(BaseModel):
|
|
"""Request body for creating a custom agent."""
|
|
|
|
name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)")
|
|
description: str = Field(default="", description="Agent description")
|
|
model: str | None = Field(default=None, description="Optional model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
|
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
|
|
|
|
|
class AgentUpdateRequest(BaseModel):
|
|
"""Request body for updating a custom agent."""
|
|
|
|
description: str | None = Field(default=None, description="Updated description")
|
|
model: str | None = Field(default=None, description="Updated model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
|
|
|
|
|
def _validate_agent_name(name: str) -> None:
|
|
"""Validate agent name against allowed pattern.
|
|
|
|
Args:
|
|
name: The agent name to validate.
|
|
|
|
Raises:
|
|
HTTPException: 422 if the name is invalid.
|
|
"""
|
|
if not AGENT_NAME_PATTERN.match(name):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid agent name '{name}'. Must match ^[A-Za-z0-9-]+$ (letters, digits, and hyphens only).",
|
|
)
|
|
|
|
|
|
def _normalize_agent_name(name: str) -> str:
|
|
"""Normalize agent name to lowercase for filesystem storage."""
|
|
return name.lower()
|
|
|
|
|
|
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
|
"""Convert AgentConfig to AgentResponse."""
|
|
soul: str | None = None
|
|
if include_soul:
|
|
soul = load_agent_soul(agent_cfg.name) or ""
|
|
|
|
return AgentResponse(
|
|
name=agent_cfg.name,
|
|
description=agent_cfg.description,
|
|
model=agent_cfg.model,
|
|
tool_groups=agent_cfg.tool_groups,
|
|
soul=soul,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/agents",
|
|
response_model=AgentsListResponse,
|
|
summary="List Custom Agents",
|
|
description="List all custom agents available in the agents directory.",
|
|
)
|
|
async def list_agents() -> AgentsListResponse:
|
|
"""List all custom agents.
|
|
|
|
Returns:
|
|
List of all custom agents with their metadata (without soul content).
|
|
"""
|
|
try:
|
|
agents = list_custom_agents()
|
|
return AgentsListResponse(agents=[_agent_config_to_response(a) for a in agents])
|
|
except Exception as e:
|
|
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/agents/check",
|
|
summary="Check Agent Name",
|
|
description="Validate an agent name and check if it is available (case-insensitive).",
|
|
)
|
|
async def check_agent_name(name: str) -> dict:
|
|
"""Check whether an agent name is valid and not yet taken.
|
|
|
|
Args:
|
|
name: The agent name to check.
|
|
|
|
Returns:
|
|
``{"available": true/false, "name": "<normalized>"}``
|
|
|
|
Raises:
|
|
HTTPException: 422 if the name is invalid.
|
|
"""
|
|
_validate_agent_name(name)
|
|
normalized = _normalize_agent_name(name)
|
|
available = not get_paths().agent_dir(normalized).exists()
|
|
return {"available": available, "name": normalized}
|
|
|
|
|
|
@router.get(
|
|
"/agents/{name}",
|
|
response_model=AgentResponse,
|
|
summary="Get Custom Agent",
|
|
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
|
)
|
|
async def get_agent(name: str) -> AgentResponse:
|
|
"""Get a specific custom agent by name.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
|
|
Returns:
|
|
Agent details including SOUL.md content.
|
|
|
|
Raises:
|
|
HTTPException: 404 if agent not found.
|
|
"""
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
|
|
try:
|
|
agent_cfg = load_agent_config(name)
|
|
return _agent_config_to_response(agent_cfg, include_soul=True)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
except Exception as e:
|
|
logger.error(f"Failed to get agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/agents",
|
|
response_model=AgentResponse,
|
|
status_code=201,
|
|
summary="Create Custom Agent",
|
|
description="Create a new custom agent with its config and SOUL.md.",
|
|
)
|
|
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|
"""Create a new custom agent.
|
|
|
|
Args:
|
|
request: The agent creation request.
|
|
|
|
Returns:
|
|
The created agent details.
|
|
|
|
Raises:
|
|
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
|
"""
|
|
_validate_agent_name(request.name)
|
|
normalized_name = _normalize_agent_name(request.name)
|
|
|
|
agent_dir = get_paths().agent_dir(normalized_name)
|
|
|
|
if agent_dir.exists():
|
|
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
|
|
|
try:
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write config.yaml
|
|
config_data: dict = {"name": normalized_name}
|
|
if request.description:
|
|
config_data["description"] = request.description
|
|
if request.model is not None:
|
|
config_data["model"] = request.model
|
|
if request.tool_groups is not None:
|
|
config_data["tool_groups"] = request.tool_groups
|
|
|
|
config_file = agent_dir / "config.yaml"
|
|
with open(config_file, "w", encoding="utf-8") as f:
|
|
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
|
|
|
# Write SOUL.md
|
|
soul_file = agent_dir / "SOUL.md"
|
|
soul_file.write_text(request.soul, encoding="utf-8")
|
|
|
|
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
|
|
|
agent_cfg = load_agent_config(normalized_name)
|
|
return _agent_config_to_response(agent_cfg, include_soul=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
# Clean up on failure
|
|
if agent_dir.exists():
|
|
shutil.rmtree(agent_dir)
|
|
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/agents/{name}",
|
|
response_model=AgentResponse,
|
|
summary="Update Custom Agent",
|
|
description="Update an existing custom agent's config and/or SOUL.md.",
|
|
)
|
|
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|
"""Update an existing custom agent.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
request: The update request (all fields optional).
|
|
|
|
Returns:
|
|
The updated agent details.
|
|
|
|
Raises:
|
|
HTTPException: 404 if agent not found.
|
|
"""
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
|
|
try:
|
|
agent_cfg = load_agent_config(name)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
|
|
agent_dir = get_paths().agent_dir(name)
|
|
|
|
try:
|
|
# Update config if any config fields changed
|
|
config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups])
|
|
|
|
if config_changed:
|
|
updated: dict = {
|
|
"name": agent_cfg.name,
|
|
"description": request.description if request.description is not None else agent_cfg.description,
|
|
}
|
|
new_model = request.model if request.model is not None else agent_cfg.model
|
|
if new_model is not None:
|
|
updated["model"] = new_model
|
|
|
|
new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups
|
|
if new_tool_groups is not None:
|
|
updated["tool_groups"] = new_tool_groups
|
|
|
|
config_file = agent_dir / "config.yaml"
|
|
with open(config_file, "w", encoding="utf-8") as f:
|
|
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
|
|
|
# Update SOUL.md if provided
|
|
if request.soul is not None:
|
|
soul_path = agent_dir / "SOUL.md"
|
|
soul_path.write_text(request.soul, encoding="utf-8")
|
|
|
|
logger.info(f"Updated agent '{name}'")
|
|
|
|
refreshed_cfg = load_agent_config(name)
|
|
return _agent_config_to_response(refreshed_cfg, include_soul=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}")
|
|
|
|
|
|
class UserProfileResponse(BaseModel):
|
|
"""Response model for the global user profile (USER.md)."""
|
|
|
|
content: str | None = Field(default=None, description="USER.md content, or null if not yet created")
|
|
|
|
|
|
class UserProfileUpdateRequest(BaseModel):
|
|
"""Request body for setting the global user profile."""
|
|
|
|
content: str = Field(default="", description="USER.md content — describes the user's background and preferences")
|
|
|
|
|
|
@router.get(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Get User Profile",
|
|
description="Read the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def get_user_profile() -> UserProfileResponse:
|
|
"""Return the current USER.md content.
|
|
|
|
Returns:
|
|
UserProfileResponse with content=None if USER.md does not exist yet.
|
|
"""
|
|
try:
|
|
user_md_path = get_paths().user_md_file
|
|
if not user_md_path.exists():
|
|
return UserProfileResponse(content=None)
|
|
raw = user_md_path.read_text(encoding="utf-8").strip()
|
|
return UserProfileResponse(content=raw or None)
|
|
except Exception as e:
|
|
logger.error(f"Failed to read user profile: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Update User Profile",
|
|
description="Write the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
|
"""Create or overwrite the global USER.md.
|
|
|
|
Args:
|
|
request: The update request with the new USER.md content.
|
|
|
|
Returns:
|
|
UserProfileResponse with the saved content.
|
|
"""
|
|
try:
|
|
paths = get_paths()
|
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
|
paths.user_md_file.write_text(request.content, encoding="utf-8")
|
|
logger.info(f"Updated USER.md at {paths.user_md_file}")
|
|
return UserProfileResponse(content=request.content or None)
|
|
except Exception as e:
|
|
logger.error(f"Failed to update user profile: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}")
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{name}",
|
|
status_code=204,
|
|
summary="Delete Custom Agent",
|
|
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
|
)
|
|
async def delete_agent(name: str) -> None:
|
|
"""Delete a custom agent.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
|
|
Raises:
|
|
HTTPException: 404 if agent not found.
|
|
"""
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
|
|
agent_dir = get_paths().agent_dir(name)
|
|
|
|
if not agent_dir.exists():
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
|
|
try:
|
|
shutil.rmtree(agent_dir)
|
|
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|