feat(agent): add custom-agent self-updates with user isolation (#2713)
Unit Tests / backend-unit-tests (push) Waiting to run
E2E Tests / e2e-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run

* feat(agent): add update_agent tool for in-chat custom-agent self-updates (#2616)

Custom agents had no built-in way to persist updates to their own SOUL.md /
config.yaml from a normal chat — `setup_agent` was only bound during the
bootstrap flow, so when the user asked the agent to refine its description
or personality, the agent would shell out via bash/write_file and the edits
landed in a temporary sandbox/tool workspace instead of
`{base_dir}/agents/{agent_name}/`.

Changes:
- New `update_agent` builtin tool with partial-update semantics (only the
  fields you pass are written) and atomic temp-file + os.replace writes so
  a failed update never corrupts existing SOUL.md / config.yaml.
- Lead agent now binds `update_agent` in the non-bootstrap path whenever
  `agent_name` is set in the runtime context. Default agent (no
  agent_name) and bootstrap flow are unchanged.
- New `<self_update>` system-prompt section is injected for custom agents,
  instructing them to use `update_agent` — and explicitly NOT bash /
  write_file — to persist self-updates.
- Tests: 11 new cases in `tests/test_update_agent_tool.py` covering
  validation (missing/invalid agent_name, unknown agent, no fields),
  partial updates (soul-only, description-only, skills=[] vs omitted),
  no-op detection, atomic-write safety, and AgentConfig round-tripping;
  plus 2 new cases in `tests/test_lead_agent_prompt.py` covering the
  self-update prompt section.
- Docs: updated backend/CLAUDE.md builtin tools list and tools.mdx
  (en/zh) with the new tool description.

* feat(agent): isolate custom agents per user

Store custom agent definitions under the effective user, keep legacy agents readable until migration, and cover API/tool/migration behavior with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: consistent write/delete targets & add --user-id to migration

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yangzheli
2026-05-05 23:17:42 +08:00
committed by GitHub
parent e8675f266d
commit 59c4a3f0a4
18 changed files with 955 additions and 60 deletions
+4 -1
View File
@@ -263,6 +263,8 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`) - `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts) - `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
- `view_image` - Read image as base64 (added only if model supports vision) - `view_image` - Read image as base64 (added only if model supports vision)
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
4. **Subagent tool** (if enabled): 4. **Subagent tool** (if enabled):
- `task` - Delegate to subagent (description, prompt, subagent_type, max_turns) - `task` - Delegate to subagent (description, prompt, subagent_type, max_turns)
@@ -354,10 +356,11 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) to the
**Per-User Isolation**: **Per-User Isolation**:
- Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json` - Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json`
- Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json` - Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json`
- Custom agent definitions (`SOUL.md` + `config.yaml`) are also per-user at `{base_dir}/users/{user_id}/agents/{agent_name}/`. The legacy shared layout `{base_dir}/agents/{agent_name}/` remains read-only fallback for unmigrated installations
- `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context` - `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context`
- In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`) - In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`)
- Absolute `storage_path` in config opts out of per-user isolation - Absolute `storage_path` in config opts out of per-user isolation
- **Migration**: Run `PYTHONPATH=. python scripts/migrate_user_isolation.py` to move legacy `memory.json` and `threads/` into per-user layout; supports `--dry-run` - **Migration**: Run `PYTHONPATH=. python scripts/migrate_user_isolation.py` to move legacy `memory.json`, `threads/`, and `agents/` into per-user layout. Supports `--dry-run` (preview changes) and `--user-id USER_ID` (assign unowned legacy data to a user, defaults to `default`).
**Data Structure** (stored in `{base_dir}/users/{user_id}/memory.json`): **Data Structure** (stored in `{base_dir}/users/{user_id}/memory.json`):
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries) - **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
+43 -18
View File
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["agents"]) router = APIRouter(prefix="/api", tags=["agents"])
@@ -86,11 +87,11 @@ def _require_agents_api_enabled() -> None:
) )
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse: def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False, *, user_id: str | None = None) -> AgentResponse:
"""Convert AgentConfig to AgentResponse.""" """Convert AgentConfig to AgentResponse."""
soul: str | None = None soul: str | None = None
if include_soul: if include_soul:
soul = load_agent_soul(agent_cfg.name) or "" soul = load_agent_soul(agent_cfg.name, user_id=user_id) or ""
return AgentResponse( return AgentResponse(
name=agent_cfg.name, name=agent_cfg.name,
@@ -116,9 +117,10 @@ async def list_agents() -> AgentsListResponse:
""" """
_require_agents_api_enabled() _require_agents_api_enabled()
user_id = get_effective_user_id()
try: try:
agents = list_custom_agents() agents = list_custom_agents(user_id=user_id)
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents]) return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True, user_id=user_id) for a in agents])
except Exception as e: except Exception as e:
logger.error(f"Failed to list agents: {e}", exc_info=True) logger.error(f"Failed to list agents: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
@@ -144,7 +146,12 @@ async def check_agent_name(name: str) -> dict:
_require_agents_api_enabled() _require_agents_api_enabled()
_validate_agent_name(name) _validate_agent_name(name)
normalized = _normalize_agent_name(name) normalized = _normalize_agent_name(name)
available = not get_paths().agent_dir(normalized).exists() user_id = get_effective_user_id()
paths = get_paths()
# Treat the name as taken if either the per-user path or the legacy shared
# path holds an agent — picking a name that collides with an unmigrated
# legacy agent would shadow the legacy entry once migration runs.
available = not paths.user_agent_dir(user_id, normalized).exists() and not paths.agent_dir(normalized).exists()
return {"available": available, "name": normalized} return {"available": available, "name": normalized}
@@ -169,10 +176,11 @@ async def get_agent(name: str) -> AgentResponse:
_require_agents_api_enabled() _require_agents_api_enabled()
_validate_agent_name(name) _validate_agent_name(name)
name = _normalize_agent_name(name) name = _normalize_agent_name(name)
user_id = get_effective_user_id()
try: try:
agent_cfg = load_agent_config(name) agent_cfg = load_agent_config(name, user_id=user_id)
return _agent_config_to_response(agent_cfg, include_soul=True) return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
except Exception as e: except Exception as e:
@@ -202,10 +210,13 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
_require_agents_api_enabled() _require_agents_api_enabled()
_validate_agent_name(request.name) _validate_agent_name(request.name)
normalized_name = _normalize_agent_name(request.name) normalized_name = _normalize_agent_name(request.name)
user_id = get_effective_user_id()
paths = get_paths()
agent_dir = get_paths().agent_dir(normalized_name) agent_dir = paths.user_agent_dir(user_id, normalized_name)
legacy_dir = paths.agent_dir(normalized_name)
if agent_dir.exists(): if agent_dir.exists() or legacy_dir.exists():
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
try: try:
@@ -232,8 +243,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
logger.info(f"Created agent '{normalized_name}' at {agent_dir}") logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
agent_cfg = load_agent_config(normalized_name) agent_cfg = load_agent_config(normalized_name, user_id=user_id)
return _agent_config_to_response(agent_cfg, include_soul=True) return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
except HTTPException: except HTTPException:
raise raise
@@ -267,13 +278,20 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
_require_agents_api_enabled() _require_agents_api_enabled()
_validate_agent_name(name) _validate_agent_name(name)
name = _normalize_agent_name(name) name = _normalize_agent_name(name)
user_id = get_effective_user_id()
try: try:
agent_cfg = load_agent_config(name) agent_cfg = load_agent_config(name, user_id=user_id)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
agent_dir = get_paths().agent_dir(name) paths = get_paths()
agent_dir = paths.user_agent_dir(user_id, name)
if not agent_dir.exists() and paths.agent_dir(name).exists():
raise HTTPException(
status_code=409,
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."),
)
try: try:
# Update config if any config fields changed # Update config if any config fields changed
@@ -314,8 +332,8 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
logger.info(f"Updated agent '{name}'") logger.info(f"Updated agent '{name}'")
refreshed_cfg = load_agent_config(name) refreshed_cfg = load_agent_config(name, user_id=user_id)
return _agent_config_to_response(refreshed_cfg, include_soul=True) return _agent_config_to_response(refreshed_cfg, include_soul=True, user_id=user_id)
except HTTPException: except HTTPException:
raise raise
@@ -402,15 +420,22 @@ async def delete_agent(name: str) -> None:
name: The agent name. name: The agent name.
Raises: Raises:
HTTPException: 404 if agent not found. HTTPException: 404 if no per-user copy exists; 409 if only a legacy
shared copy exists (suggesting the migration script).
""" """
_require_agents_api_enabled() _require_agents_api_enabled()
_validate_agent_name(name) _validate_agent_name(name)
name = _normalize_agent_name(name) name = _normalize_agent_name(name)
user_id = get_effective_user_id()
agent_dir = get_paths().agent_dir(name) paths = get_paths()
agent_dir = paths.user_agent_dir(user_id, name)
if not agent_dir.exists(): if not agent_dir.exists():
if paths.agent_dir(name).exists():
raise HTTPException(
status_code=409,
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before deleting."),
)
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
try: try:
@@ -318,7 +318,7 @@ def make_lead_agent(config: RunnableConfig):
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Lazy import to avoid circular dependency # Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent from deerflow.tools.builtins import setup_agent, update_agent
cfg = _get_runtime_config(config) cfg = _get_runtime_config(config)
resolved_app_config = app_config resolved_app_config = app_config
@@ -390,6 +390,9 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
state_schema=ThreadState, state_schema=ThreadState,
) )
# Custom agents can update their own SOUL.md / config via update_agent.
# The default agent (no agent_name) does not see this tool.
extra_tools = [update_agent] if agent_name else []
# Default lead agent (unchanged behavior) # Default lead agent (unchanged behavior)
return create_agent( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
@@ -398,7 +401,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
groups=agent_config.tool_groups if agent_config else None, groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
app_config=resolved_app_config, app_config=resolved_app_config,
), )
+ extra_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
@@ -344,6 +344,7 @@ You are {agent_name}, an open-source super agent.
</role> </role>
{soul} {soul}
{self_update_section}
{memory_context} {memory_context}
<thinking_style> <thinking_style>
@@ -643,6 +644,26 @@ def get_agent_soul(agent_name: str | None) -> str:
return "" return ""
def _build_self_update_section(agent_name: str | None) -> str:
"""Prompt block that teaches the custom agent to persist self-updates via update_agent."""
if not agent_name:
return ""
return f"""<self_update>
You are running as the custom agent **{agent_name}** with a persisted SOUL.md and config.yaml.
When the user asks you to update your own description, personality, behaviour, skill set, tool groups, or default model,
you MUST persist the change with the `update_agent` tool. Do NOT use `bash`, `write_file`, or any sandbox tool to edit
SOUL.md or config.yaml — those write into a temporary sandbox/tool workspace and the changes will be lost on the next turn.
Rules:
- Always pass the FULL replacement text for `soul` (no patch semantics). Start from your current SOUL above and apply the user's edits.
- Only pass the fields that should change. Omit the others to preserve them.
- Pass `skills=[]` to disable all skills, or omit `skills` to keep the existing whitelist.
- After `update_agent` returns successfully, tell the user the change is persisted and will take effect on the next turn.
</self_update>
"""
def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> str: def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> str:
"""Generate <available-deferred-tools> block for the system prompt. """Generate <available-deferred-tools> block for the system prompt.
@@ -772,6 +793,7 @@ def apply_prompt_template(
prompt = SYSTEM_PROMPT_TEMPLATE.format( prompt = SYSTEM_PROMPT_TEMPLATE.format(
agent_name=agent_name or "DeerFlow 2.0", agent_name=agent_name or "DeerFlow 2.0",
soul=get_agent_soul(agent_name), soul=get_agent_soul(agent_name),
self_update_section=_build_self_update_section(agent_name),
skills_section=skills_section, skills_section=skills_section,
deferred_tools_section=deferred_tools_section, deferred_tools_section=deferred_tools_section,
memory_context=memory_context, memory_context=memory_context,
@@ -1,13 +1,22 @@
"""Configuration and loaders for custom agents.""" """Configuration and loaders for custom agents.
Custom agents are stored per-user under ``{base_dir}/users/{user_id}/agents/{name}/``.
A legacy shared layout at ``{base_dir}/agents/{name}/`` is still readable so that
installations that pre-date user isolation continue to work until they run the
``scripts/migrate_user_isolation.py`` migration. New writes always target the
per-user layout.
"""
import logging import logging
import re import re
from pathlib import Path
from typing import Any from typing import Any
import yaml import yaml
from pydantic import BaseModel from pydantic import BaseModel
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,14 +49,47 @@ class AgentConfig(BaseModel):
skills: list[str] | None = None skills: list[str] | None = None
def load_agent_config(name: str | None) -> AgentConfig | None: def resolve_agent_dir(name: str, *, user_id: str | None = None) -> Path:
"""Return the on-disk directory for an agent, preferring the per-user layout.
Resolution order:
1. ``{base_dir}/users/{user_id}/agents/{name}/`` (per-user, current layout).
2. ``{base_dir}/agents/{name}/`` (legacy shared layout — read-only fallback).
If neither exists, the per-user path is returned so callers that intend to
create the agent write into the new layout.
Args:
name: Validated agent name.
user_id: Owner of the agent. Defaults to the effective user from the
request context (or ``"default"`` in no-auth mode).
"""
paths = get_paths()
effective_user = user_id or get_effective_user_id()
user_path = paths.user_agent_dir(effective_user, name)
if user_path.exists():
return user_path
legacy_path = paths.agent_dir(name)
if legacy_path.exists():
return legacy_path
return user_path
def load_agent_config(name: str | None, *, user_id: str | None = None) -> AgentConfig | None:
"""Load the custom or default agent's config from its directory. """Load the custom or default agent's config from its directory.
Reads from the per-user layout first; falls back to the legacy shared layout
for installations that have not yet been migrated.
Args: Args:
name: The agent name. name: The agent name.
user_id: Owner of the agent. Defaults to the effective user from the
current request context.
Returns: Returns:
AgentConfig instance. AgentConfig instance, or ``None`` if ``name`` is ``None``.
Raises: Raises:
FileNotFoundError: If the agent directory or config.yaml does not exist. FileNotFoundError: If the agent directory or config.yaml does not exist.
@@ -58,7 +100,7 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
return None return None
name = validate_agent_name(name) name = validate_agent_name(name)
agent_dir = get_paths().agent_dir(name) agent_dir = resolve_agent_dir(name, user_id=user_id)
config_file = agent_dir / "config.yaml" config_file = agent_dir / "config.yaml"
if not agent_dir.exists(): if not agent_dir.exists():
@@ -84,7 +126,7 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
return AgentConfig(**data) return AgentConfig(**data)
def load_agent_soul(agent_name: str | None) -> str | None: def load_agent_soul(agent_name: str | None, *, user_id: str | None = None) -> str | None:
"""Read the SOUL.md file for a custom agent, if it exists. """Read the SOUL.md file for a custom agent, if it exists.
SOUL.md defines the agent's personality, values, and behavioral guardrails. SOUL.md defines the agent's personality, values, and behavioral guardrails.
@@ -92,11 +134,16 @@ def load_agent_soul(agent_name: str | None) -> str | None:
Args: Args:
agent_name: The name of the agent or None for the default agent. agent_name: The name of the agent or None for the default agent.
user_id: Owner of the agent. Defaults to the effective user from the
current request context.
Returns: Returns:
The SOUL.md content as a string, or None if the file does not exist. The SOUL.md content as a string, or None if the file does not exist.
""" """
agent_dir = get_paths().agent_dir(agent_name) if agent_name else get_paths().base_dir if agent_name:
agent_dir = resolve_agent_dir(agent_name, user_id=user_id)
else:
agent_dir = get_paths().base_dir
soul_path = agent_dir / SOUL_FILENAME soul_path = agent_dir / SOUL_FILENAME
if not soul_path.exists(): if not soul_path.exists():
return None return None
@@ -104,32 +151,50 @@ def load_agent_soul(agent_name: str | None) -> str | None:
return content or None return content or None
def list_custom_agents() -> list[AgentConfig]: def list_custom_agents(*, user_id: str | None = None) -> list[AgentConfig]:
"""Scan the agents directory and return all valid custom agents. """Scan the agents directory and return all valid custom agents.
Returns the union of agents in the per-user layout and the legacy shared
layout, so that pre-migration installations remain visible until they are
migrated. Per-user entries shadow legacy entries with the same name.
Args:
user_id: Owner whose agents to list. Defaults to the effective user
from the current request context.
Returns: Returns:
List of AgentConfig for each valid agent directory found. List of AgentConfig for each valid agent directory found.
""" """
agents_dir = get_paths().agents_dir paths = get_paths()
effective_user = user_id or get_effective_user_id()
if not agents_dir.exists():
return []
seen: set[str] = set()
agents: list[AgentConfig] = [] agents: list[AgentConfig] = []
for entry in sorted(agents_dir.iterdir()): user_root = paths.user_agents_dir(effective_user)
if not entry.is_dir(): legacy_root = paths.agents_dir
for root in (user_root, legacy_root):
if not root.exists():
continue continue
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
if entry.name in seen:
continue
config_file = entry / "config.yaml"
if not config_file.exists():
logger.debug(f"Skipping {entry.name}: no config.yaml")
continue
config_file = entry / "config.yaml" try:
if not config_file.exists(): agent_cfg = load_agent_config(entry.name, user_id=effective_user)
logger.debug(f"Skipping {entry.name}: no config.yaml") if agent_cfg is None:
continue continue
agents.append(agent_cfg)
try: seen.add(entry.name)
agent_cfg = load_agent_config(entry.name) except Exception as e:
agents.append(agent_cfg) logger.warning(f"Skipping agent '{entry.name}': {e}")
except Exception as e:
logger.warning(f"Skipping agent '{entry.name}': {e}")
agents.sort(key=lambda a: a.name)
return agents return agents
@@ -132,15 +132,20 @@ class Paths:
@property @property
def agents_dir(self) -> Path: def agents_dir(self) -> Path:
"""Root directory for all custom agents: `{base_dir}/agents/`.""" """Legacy root for shared (pre user-isolation) custom agents: `{base_dir}/agents/`.
New code should use :meth:`user_agents_dir` instead. This property remains
only as a read-side fallback for installations that have not yet run the
``migrate_user_isolation.py`` script.
"""
return self.base_dir / "agents" return self.base_dir / "agents"
def agent_dir(self, name: str) -> Path: def agent_dir(self, name: str) -> Path:
"""Directory for a specific agent: `{base_dir}/agents/{name}/`.""" """Legacy per-agent directory (no user isolation): `{base_dir}/agents/{name}/`."""
return self.agents_dir / name.lower() return self.agents_dir / name.lower()
def agent_memory_file(self, name: str) -> Path: def agent_memory_file(self, name: str) -> Path:
"""Per-agent memory file: `{base_dir}/agents/{name}/memory.json`.""" """Legacy per-agent memory file: `{base_dir}/agents/{name}/memory.json`."""
return self.agent_dir(name) / "memory.json" return self.agent_dir(name) / "memory.json"
def user_dir(self, user_id: str) -> Path: def user_dir(self, user_id: str) -> Path:
@@ -151,9 +156,17 @@ class Paths:
"""Per-user memory file: `{base_dir}/users/{user_id}/memory.json`.""" """Per-user memory file: `{base_dir}/users/{user_id}/memory.json`."""
return self.user_dir(user_id) / "memory.json" return self.user_dir(user_id) / "memory.json"
def user_agents_dir(self, user_id: str) -> Path:
"""Per-user root for that user's custom agents: `{base_dir}/users/{user_id}/agents/`."""
return self.user_dir(user_id) / "agents"
def user_agent_dir(self, user_id: str, agent_name: str) -> Path:
"""Per-user per-agent directory: `{base_dir}/users/{user_id}/agents/{name}/`."""
return self.user_agents_dir(user_id) / agent_name.lower()
def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path: def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path:
"""Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`.""" """Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`."""
return self.user_dir(user_id) / "agents" / agent_name.lower() / "memory.json" return self.user_agent_dir(user_id, agent_name) / "memory.json"
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path: def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
""" """
@@ -2,10 +2,12 @@ from .clarification_tool import ask_clarification_tool
from .present_file_tool import present_file_tool from .present_file_tool import present_file_tool
from .setup_agent_tool import setup_agent from .setup_agent_tool import setup_agent
from .task_tool import task_tool from .task_tool import task_tool
from .update_agent_tool import update_agent
from .view_image_tool import view_image_tool from .view_image_tool import view_image_tool
__all__ = [ __all__ = [
"setup_agent", "setup_agent",
"update_agent",
"present_file_tool", "present_file_tool",
"ask_clarification_tool", "ask_clarification_tool",
"view_image_tool", "view_image_tool",
@@ -8,6 +8,7 @@ from langgraph.types import Command
from deerflow.config.agents_config import validate_agent_name from deerflow.config.agents_config import validate_agent_name
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,7 +35,14 @@ def setup_agent(
try: try:
agent_name = validate_agent_name(agent_name) agent_name = validate_agent_name(agent_name)
paths = get_paths() paths = get_paths()
agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir if agent_name:
# Custom agents are persisted under the current user's bucket so
# different users do not see each other's agents.
user_id = get_effective_user_id()
agent_dir = paths.user_agent_dir(user_id, agent_name)
else:
# Default agent (no agent_name): SOUL.md lives at the global base dir.
agent_dir = paths.base_dir
is_new_dir = not agent_dir.exists() is_new_dir = not agent_dir.exists()
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,241 @@
"""update_agent tool — let a custom agent persist updates to its own SOUL.md / config.
Bound to the lead agent only when ``runtime.context['agent_name']`` is set
(i.e. inside an existing custom agent's chat). The default agent does not see
this tool, and the bootstrap flow continues to use ``setup_agent`` for the
initial creation handshake.
The tool writes back to ``{base_dir}/users/{user_id}/agents/{agent_name}/{config.yaml,SOUL.md}``
so an agent created by one user is never visible to (or mutable by) another.
Writes are staged into temp files first; both files are renamed into place only
after both temp files are successfully written, so a partial failure cannot leave
config.yaml updated while SOUL.md still holds stale content.
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any
import yaml
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt import ToolRuntime
from langgraph.types import Command
from deerflow.config.agents_config import load_agent_config, validate_agent_name
from deerflow.config.app_config import get_app_config
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
def _stage_temp(path: Path, text: str) -> Path:
"""Write ``text`` into a sibling temp file and return its path.
The caller is responsible for ``Path.replace``-ing the temp into the target
once every staged file is ready, or for unlinking it on failure.
"""
path.parent.mkdir(parents=True, exist_ok=True)
fd = tempfile.NamedTemporaryFile(
mode="w",
dir=path.parent,
suffix=".tmp",
delete=False,
encoding="utf-8",
)
try:
fd.write(text)
fd.flush()
fd.close()
return Path(fd.name)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
def _cleanup_temps(temps: list[Path]) -> None:
"""Best-effort removal of staged temp files."""
for tmp in temps:
try:
tmp.unlink(missing_ok=True)
except OSError:
logger.debug("Failed to clean up temp file %s", tmp, exc_info=True)
@tool
def update_agent(
runtime: ToolRuntime,
soul: str | None = None,
description: str | None = None,
skills: list[str] | None = None,
tool_groups: list[str] | None = None,
model: str | None = None,
) -> Command:
"""Persist updates to the current custom agent's SOUL.md and config.yaml.
Use this when the user asks to refine the agent's identity, description,
skill whitelist, tool-group whitelist, or default model. Only the fields
you explicitly pass are updated; omitted fields keep their existing values.
Pass ``soul`` as the FULL replacement SOUL.md content — there is no patch
semantics, so always start from the current SOUL and apply your edits.
Pass ``skills=[]`` to disable all skills for this agent. Omit ``skills``
entirely to keep the existing whitelist.
Args:
soul: Optional full replacement SOUL.md content.
description: Optional new one-line description.
skills: Optional skill whitelist. ``[]`` = no skills, omit = unchanged.
tool_groups: Optional tool-group whitelist. ``[]`` = empty, omit = unchanged.
model: Optional model override (must match a configured model name).
Returns:
Command with a ToolMessage describing the result. Changes take effect
on the next user turn (when the lead agent is rebuilt with the fresh
SOUL.md and config.yaml).
"""
tool_call_id = runtime.tool_call_id
agent_name_raw: str | None = runtime.context.get("agent_name") if runtime.context else None
def _err(message: str) -> Command:
return Command(update={"messages": [ToolMessage(content=f"Error: {message}", tool_call_id=tool_call_id)]})
if soul is None and description is None and skills is None and tool_groups is None and model is None:
return _err("No fields provided. Pass at least one of: soul, description, skills, tool_groups, model.")
try:
agent_name = validate_agent_name(agent_name_raw)
except ValueError as e:
return _err(str(e))
if not agent_name:
return _err("update_agent is only available inside a custom agent's chat. There is no agent_name in the current runtime context, so there is nothing to update. If you are inside the bootstrap flow, use setup_agent instead.")
# Resolve the active user so that updates only affect this user's agent.
# ``get_effective_user_id`` returns DEFAULT_USER_ID when no auth context
# is set (matching how memory and thread storage behave).
user_id = get_effective_user_id()
# Reject an unknown ``model`` *before* touching the filesystem. Otherwise
# ``_resolve_model_name`` silently falls back to the default at runtime
# and the user sees confusing repeated warnings on every later turn.
if model is not None and get_app_config().get_model_config(model) is None:
return _err(f"Unknown model '{model}'. Pass a model name that exists in config.yaml's models section.")
paths = get_paths()
agent_dir = paths.user_agent_dir(user_id, agent_name)
if not agent_dir.exists() and paths.agent_dir(agent_name).exists():
return _err(f"Agent '{agent_name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating.")
try:
existing_cfg = load_agent_config(agent_name, user_id=user_id)
except FileNotFoundError:
return _err(f"Agent '{agent_name}' does not exist for the current user. Use setup_agent to create a new agent first.")
except ValueError as e:
return _err(f"Agent '{agent_name}' has an unreadable config: {e}")
if existing_cfg is None:
return _err(f"Agent '{agent_name}' could not be loaded.")
updated_fields: list[str] = []
# Force the on-disk ``name`` to match the directory we are writing into,
# even if ``existing_cfg.name`` had drifted (e.g. from manual yaml edits).
config_data: dict[str, Any] = {"name": agent_name}
new_description = description if description is not None else existing_cfg.description
config_data["description"] = new_description
if description is not None and description != existing_cfg.description:
updated_fields.append("description")
new_model = model if model is not None else existing_cfg.model
if new_model is not None:
config_data["model"] = new_model
if model is not None and model != existing_cfg.model:
updated_fields.append("model")
new_tool_groups = tool_groups if tool_groups is not None else existing_cfg.tool_groups
if new_tool_groups is not None:
config_data["tool_groups"] = new_tool_groups
if tool_groups is not None and tool_groups != existing_cfg.tool_groups:
updated_fields.append("tool_groups")
new_skills = skills if skills is not None else existing_cfg.skills
if new_skills is not None:
config_data["skills"] = new_skills
if skills is not None and skills != existing_cfg.skills:
updated_fields.append("skills")
config_changed = bool({"description", "model", "tool_groups", "skills"} & set(updated_fields))
# Stage every file we intend to rewrite into a temp sibling. Only after
# *all* temp files exist do we rename them into place — so a failure on
# SOUL.md cannot leave config.yaml already replaced.
pending: list[tuple[Path, Path]] = []
staged_temps: list[Path] = []
try:
agent_dir.mkdir(parents=True, exist_ok=True)
if config_changed:
yaml_text = yaml.dump(config_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
config_target = agent_dir / "config.yaml"
config_tmp = _stage_temp(config_target, yaml_text)
staged_temps.append(config_tmp)
pending.append((config_tmp, config_target))
if soul is not None:
soul_target = agent_dir / "SOUL.md"
soul_tmp = _stage_temp(soul_target, soul)
staged_temps.append(soul_tmp)
pending.append((soul_tmp, soul_target))
updated_fields.append("soul")
# Commit phase. ``Path.replace`` is atomic per file on POSIX/NTFS and
# the staging step above means any earlier failure has already been
# reported. The remaining failure mode is a crash *between* two
# ``replace`` calls, which is reported via the partial-write error
# branch below so the caller knows which files are now on disk.
committed: list[Path] = []
try:
for tmp, target in pending:
tmp.replace(target)
committed.append(target)
except Exception as e:
_cleanup_temps([t for t, _ in pending if t not in committed])
if committed:
logger.error(
"[update_agent] Partial write for agent '%s' (user=%s): committed=%s, failed during rename: %s",
agent_name,
user_id,
[p.name for p in committed],
e,
exc_info=True,
)
return _err(f"Partial update for agent '{agent_name}': {[p.name for p in committed]} were updated, but the rest failed ({e}). Re-run update_agent to retry the remaining fields.")
raise
except Exception as e:
_cleanup_temps(staged_temps)
logger.error("[update_agent] Failed to update agent '%s' (user=%s): %s", agent_name, user_id, e, exc_info=True)
return _err(f"Failed to update agent '{agent_name}': {e}")
if not updated_fields:
return Command(update={"messages": [ToolMessage(content=f"No changes applied to agent '{agent_name}'. The provided values matched the existing config.", tool_call_id=tool_call_id)]})
logger.info("[update_agent] Updated agent '%s' (user=%s) fields: %s", agent_name, user_id, updated_fields)
return Command(
update={
"messages": [
ToolMessage(
content=(f"Agent '{agent_name}' updated successfully. Changed: {', '.join(updated_fields)}. The new configuration takes effect on the next user turn."),
tool_call_id=tool_call_id,
)
]
}
)
+86 -3
View File
@@ -1,7 +1,7 @@
"""One-time migration: move legacy thread dirs and memory into per-user layout. """One-time migration: move legacy thread dirs and memory into per-user layout.
Usage: Usage:
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run] PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run] [--user-id USER_ID]
The script is idempotent — re-running it after a successful migration is a no-op. The script is idempotent — re-running it after a successful migration is a no-op.
""" """
@@ -69,6 +69,67 @@ def migrate_thread_dirs(
return report return report
def migrate_agents(
paths: Paths,
user_id: str = "default",
*,
dry_run: bool = False,
) -> list[dict]:
"""Move legacy custom-agent directories into per-user layout.
Legacy layout: ``{base_dir}/agents/{name}/``
Per-user layout: ``{base_dir}/users/{user_id}/agents/{name}/``
Pre-existing per-user agents take precedence: if a destination already
exists for an agent name, the legacy copy is moved to
``{base_dir}/migration-conflicts/agents/{name}/`` for manual review.
Args:
paths: Paths instance.
user_id: Target user to receive the legacy agents (defaults to
``"default"``, matching ``DEFAULT_USER_ID`` for no-auth setups).
dry_run: If True, only log what would happen.
Returns:
List of migration report entries, one per legacy agent directory found.
"""
report: list[dict] = []
legacy_agents = paths.agents_dir
if not legacy_agents.exists():
logger.info("No legacy agents directory found — nothing to migrate.")
return report
for agent_dir in sorted(legacy_agents.iterdir()):
if not agent_dir.is_dir():
continue
agent_name = agent_dir.name
dest = paths.user_agent_dir(user_id, agent_name)
entry = {"agent": agent_name, "user_id": user_id, "action": ""}
if dest.exists():
conflicts_dir = paths.base_dir / "migration-conflicts" / "agents" / agent_name
entry["action"] = f"conflict -> {conflicts_dir}"
if not dry_run:
conflicts_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(agent_dir), str(conflicts_dir))
logger.warning("Conflict for agent %s: moved legacy copy to %s", agent_name, conflicts_dir)
else:
entry["action"] = f"moved -> {dest}"
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(agent_dir), str(dest))
logger.info("Migrated agent %s -> user %s", agent_name, user_id)
report.append(entry)
# Clean up empty legacy agents dir
if not dry_run and legacy_agents.exists() and not any(legacy_agents.iterdir()):
legacy_agents.rmdir()
return report
def migrate_memory( def migrate_memory(
paths: Paths, paths: Paths,
user_id: str = "default", user_id: str = "default",
@@ -127,6 +188,12 @@ def _build_owner_map_from_db(paths: Paths) -> dict[str, str]:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Migrate DeerFlow data to per-user layout") parser = argparse.ArgumentParser(description="Migrate DeerFlow data to per-user layout")
parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes") parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes")
parser.add_argument(
"--user-id",
default="default",
metavar="USER_ID",
help=("User ID to claim un-owned legacy data (global memory.json and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."),
)
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
@@ -134,26 +201,42 @@ def main() -> None:
paths = get_paths() paths = get_paths()
logger.info("Base directory: %s", paths.base_dir) logger.info("Base directory: %s", paths.base_dir)
logger.info("Dry run: %s", args.dry_run) logger.info("Dry run: %s", args.dry_run)
logger.info("Claiming un-owned legacy data for user_id=%s", args.user_id)
owner_map = _build_owner_map_from_db(paths) owner_map = _build_owner_map_from_db(paths)
logger.info("Found %d thread ownership records in DB", len(owner_map)) logger.info("Found %d thread ownership records in DB", len(owner_map))
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run) report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
migrate_memory(paths, user_id="default", dry_run=args.dry_run) migrate_memory(paths, user_id=args.user_id, dry_run=args.dry_run)
agent_report = migrate_agents(paths, user_id=args.user_id, dry_run=args.dry_run)
if report: if report:
logger.info("Migration report:") logger.info("Thread migration report:")
for entry in report: for entry in report:
logger.info(" thread=%s user=%s action=%s", entry["thread_id"], entry["user_id"], entry["action"]) logger.info(" thread=%s user=%s action=%s", entry["thread_id"], entry["user_id"], entry["action"])
else: else:
logger.info("No threads to migrate.") logger.info("No threads to migrate.")
if agent_report:
logger.info("Agent migration report:")
for entry in agent_report:
logger.info(" agent=%s user=%s action=%s", entry["agent"], entry["user_id"], entry["action"])
else:
logger.info("No agents to migrate.")
unowned = [e for e in report if e["user_id"] == "default"] unowned = [e for e in report if e["user_id"] == "default"]
if unowned: if unowned:
logger.warning("%d thread(s) had no owner and were assigned to 'default':", len(unowned)) logger.warning("%d thread(s) had no owner and were assigned to 'default':", len(unowned))
for e in unowned: for e in unowned:
logger.warning(" %s", e["thread_id"]) logger.warning(" %s", e["thread_id"])
if agent_report:
logger.warning(
"%d legacy agent(s) were assigned to '%s'. If those agents belonged to other users, move them manually under {base_dir}/users/<user_id>/agents/.",
len(agent_report),
args.user_id,
)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+16 -2
View File
@@ -537,7 +537,10 @@ class TestAgentsAPI:
def test_create_persists_files_on_disk(self, agent_client, tmp_path): def test_create_persists_files_on_disk(self, agent_client, tmp_path):
agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"}) agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"})
agent_dir = tmp_path / "agents" / "disk-check" # tests/conftest.py installs an autouse fixture that sets the
# contextvar to "test-user-autouse", so the agent is persisted under
# users/test-user-autouse/agents/ rather than the legacy shared dir.
agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "disk-check"
assert agent_dir.exists() assert agent_dir.exists()
assert (agent_dir / "config.yaml").exists() assert (agent_dir / "config.yaml").exists()
assert (agent_dir / "SOUL.md").exists() assert (agent_dir / "SOUL.md").exists()
@@ -545,12 +548,23 @@ class TestAgentsAPI:
def test_delete_removes_files_from_disk(self, agent_client, tmp_path): def test_delete_removes_files_from_disk(self, agent_client, tmp_path):
agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"}) agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"})
agent_dir = tmp_path / "agents" / "remove-me" agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "remove-me"
assert agent_dir.exists() assert agent_dir.exists()
agent_client.delete("/api/agents/remove-me") agent_client.delete("/api/agents/remove-me")
assert not agent_dir.exists() assert not agent_dir.exists()
def test_create_rejects_legacy_name_collision(self, agent_client, tmp_path):
"""An unmigrated legacy agent must still block name collision so that
running the migration script later won't shadow the legacy entry."""
legacy_dir = tmp_path / "agents" / "legacy-agent"
legacy_dir.mkdir(parents=True)
(legacy_dir / "config.yaml").write_text("name: legacy-agent\n", encoding="utf-8")
(legacy_dir / "SOUL.md").write_text("legacy soul", encoding="utf-8")
response = agent_client.post("/api/agents", json={"name": "legacy-agent", "soul": "x"})
assert response.status_code == 409
# =========================================================================== # ===========================================================================
# 9. Gateway API User Profile endpoints # 9. Gateway API User Profile endpoints
+12
View File
@@ -17,6 +17,18 @@ def _set_skills_cache_state(*, skills=None, active=False, version=0):
prompt_module._enabled_skills_refresh_event.clear() prompt_module._enabled_skills_refresh_event.clear()
def test_build_self_update_section_empty_for_default_agent():
assert prompt_module._build_self_update_section(None) == ""
def test_build_self_update_section_present_for_custom_agent():
section = prompt_module._build_self_update_section("my-agent")
assert "<self_update>" in section
assert "my-agent" in section
assert "update_agent" in section
def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch): def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch):
config = SimpleNamespace(sandbox=SimpleNamespace(mounts=[])) config = SimpleNamespace(sandbox=SimpleNamespace(mounts=[]))
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config) monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
@@ -125,3 +125,68 @@ class TestMigrateMemory:
from scripts.migrate_user_isolation import migrate_memory from scripts.migrate_user_isolation import migrate_memory
migrate_memory(paths, user_id="default") # should not raise migrate_memory(paths, user_id="default") # should not raise
class TestMigrateAgents:
@staticmethod
def _seed_legacy_agent(paths: Paths, name: str, *, soul: str = "soul", description: str = "d") -> Path:
legacy_dir = paths.agents_dir / name
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "config.yaml").write_text(f"name: {name}\ndescription: {description}\n", encoding="utf-8")
(legacy_dir / "SOUL.md").write_text(soul, encoding="utf-8")
return legacy_dir
def test_moves_legacy_into_user_layout(self, base_dir: Path, paths: Paths):
self._seed_legacy_agent(paths, "agent-a", soul="soul-a")
self._seed_legacy_agent(paths, "agent-b", soul="soul-b")
from scripts.migrate_user_isolation import migrate_agents
report = migrate_agents(paths, user_id="default")
assert {entry["agent"] for entry in report} == {"agent-a", "agent-b"}
for entry in report:
assert entry["user_id"] == "default"
assert "moved -> " in entry["action"]
for name, soul in [("agent-a", "soul-a"), ("agent-b", "soul-b")]:
dest = paths.user_agent_dir("default", name)
assert dest.exists(), f"{name} should have moved into the per-user layout"
assert (dest / "SOUL.md").read_text() == soul
# Legacy agents/ root is cleaned up once empty.
assert not paths.agents_dir.exists()
def test_dry_run_does_not_move(self, base_dir: Path, paths: Paths):
legacy_dir = self._seed_legacy_agent(paths, "agent-a")
from scripts.migrate_user_isolation import migrate_agents
report = migrate_agents(paths, user_id="default", dry_run=True)
assert len(report) == 1
assert legacy_dir.exists(), "dry-run must not touch the filesystem"
assert not paths.user_agent_dir("default", "agent-a").exists()
def test_existing_destination_is_treated_as_conflict(self, base_dir: Path, paths: Paths):
self._seed_legacy_agent(paths, "agent-a", soul="legacy soul")
dest = paths.user_agent_dir("default", "agent-a")
dest.mkdir(parents=True)
(dest / "SOUL.md").write_text("preexisting", encoding="utf-8")
from scripts.migrate_user_isolation import migrate_agents
report = migrate_agents(paths, user_id="default")
assert report[0]["action"].startswith("conflict -> ")
# Per-user destination must be left untouched.
assert (dest / "SOUL.md").read_text() == "preexisting"
# Legacy copy lands under migration-conflicts/agents/.
conflicts_dir = paths.base_dir / "migration-conflicts" / "agents" / "agent-a"
assert (conflicts_dir / "SOUL.md").read_text() == "legacy soul"
def test_no_legacy_dir_is_noop(self, base_dir: Path, paths: Paths):
from scripts.migrate_user_isolation import migrate_agents
report = migrate_agents(paths, user_id="default")
assert report == []
@@ -50,6 +50,21 @@ class TestUserAgentMemoryFile:
assert paths.user_agent_memory_file("bob", "MyAgent") == expected assert paths.user_agent_memory_file("bob", "MyAgent") == expected
class TestUserAgentDir:
def test_user_agents_dir(self, paths: Paths):
assert paths.user_agents_dir("alice") == paths.base_dir / "users" / "alice" / "agents"
def test_user_agent_dir(self, paths: Paths):
assert paths.user_agent_dir("alice", "code-reviewer") == paths.base_dir / "users" / "alice" / "agents" / "code-reviewer"
def test_user_agent_dir_lowercases_name(self, paths: Paths):
assert paths.user_agent_dir("alice", "CodeReviewer") == paths.base_dir / "users" / "alice" / "agents" / "codereviewer"
def test_user_agent_dir_validates_user_id(self, paths: Paths):
with pytest.raises(ValueError, match="Invalid user_id"):
paths.user_agent_dir("../escape", "myagent")
class TestUserThreadDir: class TestUserThreadDir:
def test_user_thread_dir(self, paths: Paths): def test_user_thread_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" expected = paths.base_dir / "users" / "u1" / "threads" / "t1"
+7 -6
View File
@@ -27,6 +27,7 @@ def _make_paths_mock(tmp_path: Path):
paths = MagicMock() paths = MagicMock()
paths.base_dir = tmp_path paths.base_dir = tmp_path
paths.agent_dir = lambda name: tmp_path / "agents" / name paths.agent_dir = lambda name: tmp_path / "agents" / name
paths.user_agent_dir = lambda user_id, name: tmp_path / "users" / user_id / "agents" / name
return paths return paths
@@ -54,7 +55,7 @@ def test_setup_agent_rejects_invalid_agent_name_before_writing(tmp_path, monkeyp
messages = result.update["messages"] messages = result.update["messages"]
assert len(messages) == 1 assert len(messages) == 1
assert "Invalid agent name" in messages[0].content assert "Invalid agent name" in messages[0].content
assert not (tmp_path / "agents").exists() assert not (tmp_path / "users" / "test-user-autouse" / "agents").exists()
assert not (outside_dir / "evil" / "SOUL.md").exists() assert not (outside_dir / "evil" / "SOUL.md").exists()
@@ -68,7 +69,7 @@ def test_setup_agent_rejects_absolute_agent_name_before_writing(tmp_path, monkey
messages = result.update["messages"] messages = result.update["messages"]
assert len(messages) == 1 assert len(messages) == 1
assert "Invalid agent name" in messages[0].content assert "Invalid agent name" in messages[0].content
assert not (tmp_path / "agents").exists() assert not (tmp_path / "users" / "test-user-autouse" / "agents").exists()
assert not (Path(absolute_agent) / "SOUL.md").exists() assert not (Path(absolute_agent) / "SOUL.md").exists()
@@ -81,10 +82,10 @@ class TestSetupAgentNoDataLoss:
def test_existing_agent_dir_preserved_on_failure(self, tmp_path: Path): def test_existing_agent_dir_preserved_on_failure(self, tmp_path: Path):
"""If the agent directory already exists and setup fails, """If the agent directory already exists and setup fails,
the directory and its contents must NOT be deleted.""" the directory and its contents must NOT be deleted."""
agent_dir = tmp_path / "agents" / "test-agent" agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "test-agent"
agent_dir.mkdir(parents=True) agent_dir.mkdir(parents=True)
old_soul = agent_dir / "SOUL.md" old_soul = agent_dir / "SOUL.md"
old_soul.write_text("original soul content") old_soul.write_text("original soul content", encoding="utf-8")
with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)): with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)):
# Force soul_file.write_text to raise after directory already exists # Force soul_file.write_text to raise after directory already exists
@@ -103,7 +104,7 @@ class TestSetupAgentNoDataLoss:
def test_new_agent_dir_cleaned_up_on_failure(self, tmp_path: Path): def test_new_agent_dir_cleaned_up_on_failure(self, tmp_path: Path):
"""If the agent directory is newly created and setup fails, """If the agent directory is newly created and setup fails,
the directory should be cleaned up.""" the directory should be cleaned up."""
agent_dir = tmp_path / "agents" / "test-agent" agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "test-agent"
assert not agent_dir.exists() assert not agent_dir.exists()
with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)): with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)):
@@ -121,7 +122,7 @@ class TestSetupAgentNoDataLoss:
"""Happy path: setup_agent creates config.yaml and SOUL.md.""" """Happy path: setup_agent creates config.yaml and SOUL.md."""
_call_setup_agent(tmp_path, soul="# My Agent", description="A test agent") _call_setup_agent(tmp_path, soul="# My Agent", description="A test agent")
agent_dir = tmp_path / "agents" / "test-agent" agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "test-agent"
assert agent_dir.exists() assert agent_dir.exists()
assert (agent_dir / "SOUL.md").read_text() == "# My Agent" assert (agent_dir / "SOUL.md").read_text() == "# My Agent"
assert (agent_dir / "config.yaml").exists() assert (agent_dir / "config.yaml").exists()
+310
View File
@@ -0,0 +1,310 @@
"""Tests for update_agent tool — partial updates, atomic writes, and validation.
Resolves issue #2616: a custom agent must be able to persist updates to its
own SOUL.md / config.yaml from inside a normal chat (not only from bootstrap).
The tool writes per-user (``{base_dir}/users/{user_id}/agents/{name}/``) so
that one user's update cannot mutate another user's agent.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import yaml
from deerflow.config.agents_config import AgentConfig
from deerflow.tools.builtins.update_agent_tool import update_agent
DEFAULT_USER = "test-user-autouse" # matches the autouse fixture in tests/conftest.py
class _DummyRuntime(SimpleNamespace):
context: dict
tool_call_id: str
def _runtime(agent_name: str | None = "test-agent", tool_call_id: str = "call_1") -> _DummyRuntime:
return _DummyRuntime(context={"agent_name": agent_name} if agent_name is not None else {}, tool_call_id=tool_call_id)
def _make_paths_mock(tmp_path: Path) -> MagicMock:
paths = MagicMock()
paths.base_dir = tmp_path
paths.agent_dir = lambda name: tmp_path / "agents" / name
paths.agents_dir = tmp_path / "agents"
paths.user_agent_dir = lambda user_id, name: tmp_path / "users" / user_id / "agents" / name
paths.user_agents_dir = lambda user_id: tmp_path / "users" / user_id / "agents"
return paths
def _user_agent_dir(tmp_path: Path, name: str = "test-agent", user_id: str = DEFAULT_USER) -> Path:
return tmp_path / "users" / user_id / "agents" / name
def _seed_agent(
tmp_path: Path,
name: str = "test-agent",
*,
description: str = "old desc",
soul: str = "old soul",
skills: list[str] | None = None,
user_id: str = DEFAULT_USER,
) -> Path:
"""Create a baseline agent dir with config.yaml and SOUL.md for tests to mutate."""
agent_dir = _user_agent_dir(tmp_path, name, user_id=user_id)
agent_dir.mkdir(parents=True, exist_ok=True)
cfg: dict = {"name": name, "description": description}
if skills is not None:
cfg["skills"] = skills
(agent_dir / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
(agent_dir / "SOUL.md").write_text(soul, encoding="utf-8")
return agent_dir
@pytest.fixture()
def patched_paths(tmp_path: Path):
paths_mock = _make_paths_mock(tmp_path)
with patch("deerflow.tools.builtins.update_agent_tool.get_paths", return_value=paths_mock):
# load_agent_config also calls get_paths(); patch the same target it uses.
with patch("deerflow.config.agents_config.get_paths", return_value=paths_mock):
yield paths_mock
@pytest.fixture()
def stub_app_config():
"""Stub get_app_config so model validation accepts only known names."""
fake = MagicMock()
fake.get_model_config.side_effect = lambda name: object() if name in {"gpt-known", "m1"} else None
with patch("deerflow.tools.builtins.update_agent_tool.get_app_config", return_value=fake):
yield fake
# --- Validation tests ---
def test_update_agent_rejects_missing_agent_name(patched_paths):
result = update_agent.func(runtime=_runtime(agent_name=None), soul="new soul")
msg = result.update["messages"][0]
assert "only available inside a custom agent's chat" in msg.content
def test_update_agent_rejects_invalid_agent_name(patched_paths):
result = update_agent.func(runtime=_runtime(agent_name="../../etc/passwd"), soul="x")
msg = result.update["messages"][0]
assert "Invalid agent name" in msg.content
def test_update_agent_rejects_unknown_agent(tmp_path, patched_paths):
result = update_agent.func(runtime=_runtime(agent_name="ghost"), soul="x")
msg = result.update["messages"][0]
assert "does not exist" in msg.content
assert not _user_agent_dir(tmp_path, "ghost").exists()
def test_update_agent_requires_at_least_one_field(tmp_path, patched_paths):
_seed_agent(tmp_path)
result = update_agent.func(runtime=_runtime())
msg = result.update["messages"][0]
assert "No fields provided" in msg.content
def test_update_agent_rejects_unknown_model(tmp_path, patched_paths, stub_app_config):
"""Copilot review: model must be validated against configured models before
being persisted; otherwise _resolve_model_name silently falls back to the
default and the user gets repeated warnings on every later turn."""
_seed_agent(tmp_path)
result = update_agent.func(runtime=_runtime(), model="not-in-config")
msg = result.update["messages"][0]
assert "Unknown model" in msg.content
cfg = yaml.safe_load((_user_agent_dir(tmp_path) / "config.yaml").read_text())
assert "model" not in cfg, "Invalid model must not have been written to config.yaml"
def test_update_agent_accepts_known_model(tmp_path, patched_paths, stub_app_config):
_seed_agent(tmp_path)
result = update_agent.func(runtime=_runtime(), model="gpt-known")
cfg = yaml.safe_load((_user_agent_dir(tmp_path) / "config.yaml").read_text())
assert cfg["model"] == "gpt-known"
assert "model" in result.update["messages"][0].content
# --- Partial update tests ---
def test_update_agent_updates_soul_only(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, description="keep me", soul="old soul")
result = update_agent.func(runtime=_runtime(), soul="brand new soul")
assert (agent_dir / "SOUL.md").read_text() == "brand new soul"
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["description"] == "keep me", "description must be preserved"
assert "soul" in result.update["messages"][0].content
def test_update_agent_updates_description_only(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, description="old desc", soul="keep this soul")
result = update_agent.func(runtime=_runtime(), description="new desc")
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["description"] == "new desc"
assert (agent_dir / "SOUL.md").read_text() == "keep this soul", "SOUL.md must be preserved"
assert "description" in result.update["messages"][0].content
def test_update_agent_skills_empty_list_disables_all(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, skills=["a", "b"])
result = update_agent.func(runtime=_runtime(), skills=[])
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["skills"] == [], "empty list must persist as empty list (not be omitted)"
assert "skills" in result.update["messages"][0].content
def test_update_agent_skills_omitted_keeps_existing(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, skills=["alpha", "beta"])
update_agent.func(runtime=_runtime(), description="bumped")
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["skills"] == ["alpha", "beta"], "omitting skills must preserve the existing whitelist"
def test_update_agent_no_op_when_values_match_existing(tmp_path, patched_paths):
_seed_agent(tmp_path, description="same")
result = update_agent.func(runtime=_runtime(), description="same")
assert "No changes applied" in result.update["messages"][0].content
def test_update_agent_forces_name_to_directory(tmp_path, patched_paths):
"""Copilot review: if the existing config.yaml has a drifted ``name`` field,
update_agent must rewrite it to match the directory name so on-disk state
stays consistent with the runtime context."""
agent_dir = _user_agent_dir(tmp_path)
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text(yaml.safe_dump({"name": "drifted-name", "description": "old"}, sort_keys=False), encoding="utf-8")
(agent_dir / "SOUL.md").write_text("soul", encoding="utf-8")
update_agent.func(runtime=_runtime(), description="bumped")
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["name"] == "test-agent", "config.yaml name must follow the directory name, not legacy yaml content"
# --- Atomicity tests ---
def test_update_agent_failure_preserves_existing_files(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, soul="original soul")
real_replace = Path.replace
def _explode(self, target):
if str(target).endswith("SOUL.md"):
raise OSError("disk full")
return real_replace(self, target)
with patch.object(Path, "replace", _explode):
result = update_agent.func(runtime=_runtime(), soul="poisoned content")
assert (agent_dir / "SOUL.md").read_text() == "original soul", "atomic write must not corrupt existing SOUL.md"
assert "Error" in result.update["messages"][0].content
leftover_tmps = list(agent_dir.glob("*.tmp"))
assert leftover_tmps == [], "temp files must be cleaned up on failure"
def test_update_agent_soul_failure_does_not_replace_config(tmp_path, patched_paths):
"""Copilot review: if both config.yaml and SOUL.md are scheduled to be
written and SOUL.md staging fails *before* any rename, config.yaml must
NOT be replaced. The fix stages every temp file first and only renames
after all temps exist on disk."""
agent_dir = _seed_agent(tmp_path, description="original-desc", soul="original soul")
real_named_temp_file = __import__("tempfile").NamedTemporaryFile
call_count = {"n": 0}
def _explode_on_soul(*args, **kwargs):
# Inspect target dir + suffix; the SOUL temp file is the second one we stage.
call_count["n"] += 1
if call_count["n"] >= 2:
raise OSError("disk full while staging SOUL.md")
return real_named_temp_file(*args, **kwargs)
with patch("deerflow.tools.builtins.update_agent_tool.tempfile.NamedTemporaryFile", side_effect=_explode_on_soul):
result = update_agent.func(runtime=_runtime(), description="new-desc", soul="new soul")
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["description"] == "original-desc", "config.yaml must not be replaced when SOUL.md staging fails"
assert (agent_dir / "SOUL.md").read_text() == "original soul"
assert "Error" in result.update["messages"][0].content
assert list(agent_dir.glob("*.tmp")) == [], "staged config.yaml temp must be cleaned up on SOUL.md failure"
# --- Per-user isolation ---
def test_update_agent_only_writes_under_current_user(tmp_path, patched_paths):
"""An update from user 'alice' must never touch user 'bob's agent files."""
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Seed an agent for both users with the same name.
alice_dir = _seed_agent(tmp_path, name="shared", description="alice-desc", soul="alice soul", user_id="alice")
bob_dir = _seed_agent(tmp_path, name="shared", description="bob-desc", soul="bob soul", user_id="bob")
# Override the autouse contextvar so update_agent runs as Alice.
token = set_current_user(SimpleNamespace(id="alice"))
try:
update_agent.func(runtime=_runtime(agent_name="shared"), description="alice-bumped")
finally:
reset_current_user(token)
alice_cfg = yaml.safe_load((alice_dir / "config.yaml").read_text())
bob_cfg = yaml.safe_load((bob_dir / "config.yaml").read_text())
assert alice_cfg["description"] == "alice-bumped"
assert bob_cfg["description"] == "bob-desc", "bob's config.yaml must not have been touched"
assert (bob_dir / "SOUL.md").read_text() == "bob soul"
# --- Loader passthrough sanity check ---
def test_update_agent_round_trips_known_fields(tmp_path, patched_paths):
"""update_agent reads through load_agent_config so all fields the loader
knows about (name, description, model, tool_groups, skills) round-trip
on a partial update.
Note: ``load_agent_config`` strips unknown fields before constructing
AgentConfig, so legacy/extra YAML keys are NOT preserved across
updates — by design.
"""
_seed_agent(tmp_path, description="legacy")
fake_cfg = AgentConfig(name="test-agent", description="legacy", skills=["s1"], tool_groups=["g1"], model="m1")
fake_app_config = MagicMock()
fake_app_config.get_model_config.return_value = object()
with patch("deerflow.tools.builtins.update_agent_tool.load_agent_config", return_value=fake_cfg):
with patch("deerflow.tools.builtins.update_agent_tool.get_app_config", return_value=fake_app_config):
update_agent.func(runtime=_runtime(), description="bumped")
cfg = yaml.safe_load((_user_agent_dir(tmp_path) / "config.yaml").read_text())
assert cfg["description"] == "bumped"
assert cfg["skills"] == ["s1"]
assert cfg["tool_groups"] == ["g1"]
assert cfg["model"] == "m1"
@@ -64,6 +64,12 @@ Dynamically configures the current agent session. Used during the bootstrap flow
--- ---
### update_agent
Persists updates to the current custom agent's `SOUL.md` and `config.yaml`. Bound to the lead agent only when a custom agent is active (`agent_name` is set in the runtime context). Use this when the user asks the agent to refine its own description, personality, skill whitelist, tool-group whitelist, or default model — it writes directly into the per-user layout `{base_dir}/users/{user_id}/agents/{agent_name}/`, so the change is picked up automatically on the next user turn. Only the fields you explicitly pass are updated; omit a field to preserve its existing value. Pass `skills=[]` to disable all skills, or omit `skills` to keep the existing whitelist.
---
### invoke_acp_agent ### invoke_acp_agent
Invokes an external agent using the [Agent Connect Protocol (ACP)](https://agentconnectprotocol.org/). Requires `acp_agents:` configuration in `config.yaml`. See the [Subagents](/docs/harness/subagents) page for ACP configuration. Invokes an external agent using the [Agent Connect Protocol (ACP)](https://agentconnectprotocol.org/). Requires `acp_agents:` configuration in `config.yaml`. See the [Subagents](/docs/harness/subagents) page for ACP configuration.
@@ -61,6 +61,12 @@ task(agent="general-purpose", task="...", context="...")
--- ---
### update_agent
将更新持久化到当前自定义 Agent 的 `SOUL.md` 和 `config.yaml`。仅当激活了自定义 Agent(运行时上下文中存在 `agent_name`)时,才会绑定到 lead agent。当用户在 Agent 内开启 chat 并要求该 Agent 调整自身的描述、人格、技能白名单、工具组白名单或默认模型时使用——它会直接写入按用户隔离的 `{base_dir}/users/{user_id}/agents/{agent_name}/` 下的真实配置文件,下一轮对话即可生效。仅显式传入的字段会被更新;省略某个字段以保留其现有值。传入 `skills=[]` 可禁用全部技能,省略 `skills` 则保留现有白名单。
---
### invoke_acp_agent ### invoke_acp_agent
使用 [Agent Connect Protocol (ACP)](https://agentconnectprotocol.org/) 调用外部 Agent。需要在 `config.yaml` 中配置 `acp_agents:`。参见[子 Agent](/docs/harness/subagents)页面了解 ACP 配置。 使用 [Agent Connect Protocol (ACP)](https://agentconnectprotocol.org/) 调用外部 Agent。需要在 `config.yaml` 中配置 `acp_agents:`。参见[子 Agent](/docs/harness/subagents)页面了解 ACP 配置。