diff --git a/README.md b/README.md index 340687eed1..bbba39f813 100644 --- a/README.md +++ b/README.md @@ -1800,6 +1800,16 @@ the stable English `name`. API callers can pass `display_name` to agent creation or update requests; an omitted update preserves it and `null` clears it. The same optional field is supported in the agent's `config.yaml`. +Custom Agents can also be moved between DeerFlow users or deployments from the +Agents gallery. **Export Agent** downloads a versioned +`*.deerflow-agent.json` package containing the portable definition and +`SOUL.md`; **Import Agent** validates that package and lets the receiving user +choose a local name. Invalid packages show validation messages so you can correct +the package and retry. Imports never overwrite an existing agent. Packages carry +model, tool/MCP, skill, knowledge, sub-agent, reasoning, and memory-policy +settings, but deliberately exclude memory contents, conversations, credentials, +and operator-owned GitHub bindings. + Sub-agents are an optimization, not the default response to a complex request. After Stop interrupts a delegated task before it returns a reply, the next user diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md new file mode 100644 index 0000000000..bff540aa53 --- /dev/null +++ b/backend/app/gateway/routers/AGENTS.md @@ -0,0 +1,9 @@ +# Router contracts + +Custom Agent packages are strict, versioned definition-only JSON documents. +Export explicitly projects the caller's portable `AgentConfig` fields and SOUL; +exclude `github` installation bindings, memory contents, conversations and +credentials. Import may rename locally but must remain owner-scoped and +create-only: collisions return 409, never overwrite or upsert. Ordinary creation +and import share `_persist_new_agent`, which uses `run_drained_write` so a +cancelled request still drains the owned persistence worker. diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index d73372cae8..459f520aa5 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -6,8 +6,8 @@ import re from pathlib import Path from typing import Literal -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, Field +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, Field from app.gateway.authz import require_permission from app.gateway.persistent_writes import run_drained_write @@ -101,6 +101,30 @@ class AgentUpdateRequest(BaseModel): soul: str | None = Field(default=None, description="Updated SOUL.md content") +class AgentPackageAgent(AgentCreateRequest): + """Portable, user-authored portion of a custom agent definition. + + Deliberately excludes ``github`` because its installation binding belongs + to the target deployment. Runtime memory, conversations, credentials, and + other user state are not part of ``AgentConfig`` and are never read while + building this document. + """ + + model_config = ConfigDict(extra="forbid") + + memory_enabled: bool = Field(default=True, description="Whether the imported agent may use memory") + + +class AgentPackage(BaseModel): + """Versioned interchange document for one custom agent.""" + + model_config = ConfigDict(extra="forbid") + + format: Literal["deerflow.custom-agent"] + version: Literal[1] + agent: AgentPackageAgent + + def _validate_agent_name(name: str) -> None: """Validate agent name against allowed pattern. @@ -219,6 +243,43 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False ) +def _build_agent_config(body: AgentCreateRequest, normalized_name: str, *, memory_enabled: bool = True) -> dict: + """Build the persisted config shared by ordinary creation and import.""" + config_data: dict = {"name": normalized_name} + if body.display_name: + config_data["display_name"] = body.display_name + if body.description: + config_data["description"] = body.description + if body.tool_groups is not None: + config_data["tool_groups"] = body.tool_groups + if body.knowledge_scope is not None: + config_data["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope) + if body.mcp_plugins is not None: + config_data["mcp_plugins"] = body.mcp_plugins + if body.skills is not None: + config_data["skills"] = body.skills + if body.allowed_subagents is not None: + config_data["allowed_subagents"] = body.allowed_subagents + if not memory_enabled: + config_data["memory_enabled"] = False + _apply_model_behavior(config_data, body) + return config_data + + +async def _persist_new_agent(body: AgentCreateRequest, normalized_name: str, user_id: str, *, memory_enabled: bool = True) -> AgentResponse: + """Atomically create and reload one user-scoped agent.""" + config_data = _build_agent_config(body, normalized_name, memory_enabled=memory_enabled) + + def _create_agent() -> AgentResponse: + store = get_agent_store() + store.create(normalized_name, config_data, body.soul, user_id=user_id) + logger.info("Created agent '%s'", normalized_name) + agent_cfg = load_agent_config(normalized_name, user_id=user_id) + return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id) + + return await run_drained_write("Create agent", _create_agent, (AgentExistsError,)) + + @router.get( "/agents", response_model=AgentsListResponse, @@ -283,6 +344,88 @@ async def check_agent_name(name: str, request: Request) -> dict: return {"available": not exists, "name": normalized} +@router.post( + "/agents/import", + response_model=AgentResponse, + status_code=201, + summary="Import Custom Agent", + description="Create a user-scoped custom agent from a versioned DeerFlow agent package without importing runtime state.", +) +@require_permission("agents", "write") +async def import_agent_package(body: AgentPackage, request: Request, name: str | None = None) -> AgentResponse: + """Import a portable definition, optionally under a different name.""" + _require_agents_api_enabled() + target_name = name or body.agent.name + _validate_agent_name(target_name) + _validate_model_exists(body.agent.model) + normalized_name = _normalize_agent_name(target_name) + user_id = get_effective_user_id() + + try: + return await _persist_new_agent( + body.agent, + normalized_name, + user_id, + memory_enabled=body.agent.memory_enabled, + ) + except AgentExistsError: + raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") + except Exception as e: + logger.error("Failed to import agent '%s' (%s)", normalized_name, type(e).__name__, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to import agent") + + +@router.get( + "/agents/{name}/export", + response_model=AgentPackage, + response_model_exclude_none=True, + summary="Export Custom Agent", + description="Export a versioned custom-agent definition and SOUL without memory, conversations, credentials, or deployment bindings.", +) +@require_permission("agents", "read") +async def export_agent_package(name: str, request: Request, response: Response) -> AgentPackage: + """Export the portable portion of a caller-owned custom agent.""" + _require_agents_api_enabled() + _validate_agent_name(name) + normalized_name = _normalize_agent_name(name) + user_id = get_effective_user_id() + + def _export() -> AgentPackage: + agent_cfg = load_agent_config(normalized_name, user_id=user_id) + soul = load_agent_soul(normalized_name, user_id=user_id) or "" + return AgentPackage( + format="deerflow.custom-agent", + version=1, + agent=AgentPackageAgent( + name=agent_cfg.name, + display_name=agent_cfg.display_name, + description=agent_cfg.description, + model=agent_cfg.model, + tool_groups=agent_cfg.tool_groups, + mcp_plugins=agent_cfg.mcp_plugins, + knowledge_scope=agent_cfg.knowledge_scope, + skills=agent_cfg.skills, + allowed_subagents=agent_cfg.allowed_subagents, + model_settings=agent_cfg.model_settings, + thinking_enabled=agent_cfg.thinking_enabled, + reasoning_effort=agent_cfg.reasoning_effort, + memory_enabled=agent_cfg.memory_enabled, + soul=soul, + ), + ) + + try: + package = await asyncio.to_thread(_export) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Agent '{normalized_name}' not found") + except Exception as e: + logger.error("Failed to export agent '%s' (%s)", normalized_name, type(e).__name__, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to export agent") + + response.headers["Content-Disposition"] = f'attachment; filename="{normalized_name}.deerflow-agent.json"' + return package + + @router.get( "/agents/{name}", response_model=AgentResponse, @@ -348,37 +491,8 @@ async def create_agent_endpoint(body: AgentCreateRequest, request: Request) -> A normalized_name = _normalize_agent_name(body.name) user_id = get_effective_user_id() - # Config document — only the fields the caller set, matching the historical - # writer (an omitted field stays absent rather than being materialized). - config_data: dict = {"name": normalized_name} - if body.display_name: - config_data["display_name"] = body.display_name - if body.description: - config_data["description"] = body.description - if body.tool_groups is not None: - config_data["tool_groups"] = body.tool_groups - if body.knowledge_scope is not None: - config_data["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope) - if body.mcp_plugins is not None: - config_data["mcp_plugins"] = body.mcp_plugins - if body.skills is not None: - config_data["skills"] = body.skills - if body.allowed_subagents is not None: - config_data["allowed_subagents"] = body.allowed_subagents - # model / model_settings / thinking_enabled / reasoning_effort (issue #4336). - _apply_model_behavior(config_data, body) - - def _create_agent() -> AgentResponse: - # Worker thread: existence checks + persistence (file IO or a DB round - # trip) must stay off the event loop. - store = get_agent_store() - store.create(normalized_name, config_data, body.soul, user_id=user_id) - logger.info("Created agent '%s'", normalized_name) - agent_cfg = load_agent_config(normalized_name, user_id=user_id) - return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id) - try: - return await run_drained_write("Create agent", _create_agent, (AgentExistsError,)) + return await _persist_new_agent(body, normalized_name, user_id) except AgentExistsError: raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") except Exception as e: diff --git a/backend/docs/API.md b/backend/docs/API.md index 1c4c6bc661..c8fc1942e0 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -526,6 +526,36 @@ via `config.configurable.thread_id` to keep conversation history. Base URL: `/api` +### Custom Agent portability + +`GET /api/agents/{name}/export` downloads a version-1 JSON package for a +caller-owned Custom Agent. The package uses `format: "deerflow.custom-agent"` +and contains the portable Agent configuration plus SOUL. It excludes memory +contents, conversations, credentials, and deployment-owned GitHub bindings. + +`POST /api/agents/import` creates the packaged Agent for the current user. +Pass `?name=` to choose a different local identifier. The document +schema rejects unknown fields and unsupported format/version values; invalid +names or models return 422, and an existing name returns 409 without changing +the existing Agent. Import is create-only and never restores runtime state. + +```json +{ + "format": "deerflow.custom-agent", + "version": 1, + "agent": { + "name": "research-lead", + "description": "Coordinates parallel research", + "model": "deepseek-v3", + "tool_groups": ["web"], + "skills": ["literature-review"], + "allowed_subagents": ["researcher", "reporter"], + "memory_enabled": true, + "soul": "Delegate independent searches, then synthesize evidence." + } +} +``` + ### Models #### List Models diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 2709862b29..049364acb3 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -83,6 +83,7 @@ FastAPI application providing REST endpoints plus the public LangGraph-compatibl **Routers**: - `models.py` - `/api/models` - Model listing and details +- `agents.py` - `/api/agents` - User-scoped Custom Agent CRUD and versioned definition import/export - `thread_runs.py` / `runs.py` - `/api/threads/{id}/runs`, `/api/runs/*` - LangGraph-compatible runs and streaming - `mcp.py` - `/api/mcp` - MCP server configuration - `skills.py` - `/api/skills` - Skills management diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index a749cb03d8..b017d558dc 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -13,6 +13,7 @@ EXPECTED_GUIDANCE_PATHS = { "backend/tests/AGENTS.md", "frontend/AGENTS.md", "backend/app/gateway/AGENTS.md", + "backend/app/gateway/routers/AGENTS.md", "backend/app/channels/AGENTS.md", "backend/packages/harness/deerflow/AGENTS.md", "backend/packages/harness/deerflow/agents/AGENTS.md", diff --git a/backend/tests/test_agents_router_write_drain.py b/backend/tests/test_agents_router_write_drain.py index 6e9f2d175b..61604eab68 100644 --- a/backend/tests/test_agents_router_write_drain.py +++ b/backend/tests/test_agents_router_write_drain.py @@ -65,12 +65,94 @@ async def test_agent_persistent_writes_route_through_write_drain(_agent_env, mon monkeypatch.setattr(router, "run_drained_write", drained) await create_agent_endpoint(AgentCreateRequest(name="planner", model="agent-model")) + await router.import_agent_package(router.AgentPackage(format="deerflow.custom-agent", version=1, agent={"name": "imported", "soul": "imported soul"})) await update_agent("planner", AgentUpdateRequest(description="later")) await get_agent("planner") await update_user_profile(UserProfileUpdateRequest(content="prefs")) await router.delete_agent("planner") - assert calls == ["Create agent", "Update agent", "Update user profile", "Delete agent"] + assert calls == ["Create agent", "Create agent", "Update agent", "Update user profile", "Delete agent"] + + +async def test_agent_import_drains_started_store_write_across_repeated_cancellation(_agent_env, monkeypatch): + from app.gateway.routers import agents as router + + store = router.get_agent_store() + started = threading.Event() + release = threading.Event() + + class BlockingStore: + def create(self, *args, **kwargs): + started.set() + assert release.wait(timeout=5) + return store.create(*args, **kwargs) + + monkeypatch.setattr(router, "get_agent_store", lambda: BlockingStore()) + package = router.AgentPackage( + format="deerflow.custom-agent", + version=1, + agent={"name": "imported", "soul": "imported soul", "memory_enabled": False}, + ) + task = asyncio.create_task(router.import_agent_package(package)) + try: + assert await asyncio.to_thread(started.wait, 5) + task.cancel() + await asyncio.sleep(0.05) + task.cancel() + await asyncio.sleep(0.05) + assert not task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await task + imported = await get_agent("imported") + assert imported.soul == "imported soul" + assert router.load_agent_config("imported", user_id=router.get_effective_user_id()).memory_enabled is False + finally: + release.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def test_agent_creation_drains_store_write_across_repeated_cancellation(_agent_env, monkeypatch): + from app.gateway.routers import agents as router + + store = router.get_agent_store() + user_id = router.get_effective_user_id() + create = store.create + started = threading.Event() + release = threading.Event() + + def blocked_create(*args, **kwargs): + started.set() + assert release.wait(timeout=5) + return create(*args, **kwargs) + + monkeypatch.setattr(store, "create", blocked_create) + monkeypatch.setattr(router, "get_agent_store", lambda: store) + + task = asyncio.create_task(create_agent_endpoint(AgentCreateRequest(name="planner", model="agent-model", soul="Keep working."))) + try: + assert await asyncio.to_thread(started.wait, 5) + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + release.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + config = router.load_agent_config("planner", user_id=user_id) + assert config.memory_enabled is True + assert router.load_agent_soul("planner", user_id=user_id) == "Keep working." async def test_agent_update_drains_started_store_write_across_repeated_cancellation(_agent_env, monkeypatch): diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index 4f3e2781f7..c48478ab1a 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -605,6 +605,152 @@ def disabled_agent_client(tmp_path): class TestAgentsAPI: + def test_agent_package_round_trip_preserves_portable_behavior(self, agent_client): + payload = { + "name": "research-lead", + "display_name": "Research Lead", + "description": "Coordinates a research team", + "model": "deepseek-v3", + "tool_groups": ["web", "file:read"], + "mcp_plugins": ["papers"], + "skills": ["literature-review"], + "allowed_subagents": ["researcher", "reporter"], + "model_settings": {"temperature": 0.2, "max_tokens": 12000}, + "thinking_enabled": True, + "reasoning_effort": "high", + "soul": "Delegate independent searches, then synthesize evidence.", + } + assert agent_client.post("/api/agents", json=payload).status_code == 201 + + exported = agent_client.get("/api/agents/research-lead/export") + + assert exported.status_code == 200 + assert exported.headers["content-disposition"] == 'attachment; filename="research-lead.deerflow-agent.json"' + package = exported.json() + assert package == { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {**payload, "memory_enabled": True}, + } + + imported = agent_client.post( + "/api/agents/import", + params={"name": "research-lead-copy"}, + json=package, + ) + assert imported.status_code == 201 + assert imported.json() == {**payload, "name": "research-lead-copy", "knowledge_scope": None} + + def test_agent_package_excludes_runtime_state_and_operator_github_binding(self, agent_client, tmp_path): + assert agent_client.post("/api/agents", json={"name": "portable", "soul": "Portable soul"}).status_code == 201 + agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "portable" + config_file = agent_dir / "config.yaml" + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + config["memory_enabled"] = False + config["github"] = { + "installation_id": 12345, + "bot_login": "private-bot", + "bindings": [{"repo": "private/repository"}], + } + config_file.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + (agent_dir / "memory.json").write_text('{"secret": "remembered"}', encoding="utf-8") + + package = agent_client.get("/api/agents/portable/export").json() + + assert package["agent"]["memory_enabled"] is False + assert "github" not in package["agent"] + assert "memory" not in package + assert "memory" not in package["agent"] + assert "secret" not in str(package) + + @pytest.mark.parametrize( + "package", + [ + {"agent": {"name": "bad", "soul": "x"}}, + {"format": "deerflow.custom-agent", "agent": {"name": "bad", "soul": "x"}}, + {"format": "deerflow.custom-agent", "version": 2, "agent": {"name": "bad", "soul": "x"}}, + {"format": "unknown", "version": 1, "agent": {"name": "bad", "soul": "x"}}, + { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {"name": "bad", "soul": "x", "github": {"installation_id": 1}}, + }, + {"format": "deerflow.custom-agent", "version": 1, "agent": {"name": "bad name", "soul": "x"}}, + ], + ) + def test_agent_package_import_rejects_unsupported_or_unsafe_documents(self, agent_client, package): + response = agent_client.post("/api/agents/import", json=package) + assert response.status_code == 422 + assert agent_client.get("/api/agents").json()["agents"] == [] + + def test_agent_package_import_conflicts_explicitly_and_never_overwrites(self, agent_client): + assert agent_client.post("/api/agents", json={"name": "reviewer", "soul": "original"}).status_code == 201 + package = { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {"name": "reviewer", "description": "incoming", "soul": "replacement"}, + } + + response = agent_client.post("/api/agents/import", json=package) + + assert response.status_code == 409 + assert agent_client.get("/api/agents/reviewer").json()["soul"] == "original" + + def test_agent_package_import_is_scoped_to_current_user(self, agent_client, tmp_path): + import app.gateway.routers.agents as agents_router + + package = { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {"name": "shared-agent", "description": "shared definition", "soul": "shared soul"}, + } + + with patch.object(agents_router, "get_effective_user_id", return_value="alice"): + assert agent_client.post("/api/agents/import", json=package).status_code == 201 + with patch.object(agents_router, "get_effective_user_id", return_value="bob"): + assert agent_client.post("/api/agents/import", json=package).status_code == 201 + + alice_dir = tmp_path / "users" / "alice" / "agents" / "shared-agent" + bob_dir = tmp_path / "users" / "bob" / "agents" / "shared-agent" + assert (alice_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul" + assert (bob_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul" + assert alice_dir != bob_dir + + def test_agent_package_round_trip_uses_configured_store_abstraction(self, agent_client, tmp_path): + from sqlalchemy import create_engine + + import app.gateway.routers.agents as agents_router + from deerflow.persistence.agents.model import AgentRow + from deerflow.persistence.agents.sql import SqlAgentStore + from deerflow.persistence.base import Base + + url = f"sqlite:///{tmp_path}/agents.db" + engine = create_engine(url) + Base.metadata.create_all(engine, tables=[AgentRow.__table__]) + engine.dispose() + store = SqlAgentStore(url) + package = { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {"name": "database-agent", "description": "shared backend", "soul": "database soul"}, + } + + with ( + patch.object(agents_router, "get_agent_store", return_value=store), + patch("deerflow.persistence.agents.get_agent_store", return_value=store), + ): + imported = agent_client.post("/api/agents/import", json=package) + exported = agent_client.get("/api/agents/database-agent/export") + + assert imported.status_code == 201 + assert exported.status_code == 200 + assert exported.json()["agent"] == { + "name": "database-agent", + "description": "shared backend", + "memory_enabled": True, + "soul": "database soul", + } + @pytest.mark.parametrize("display_name", ["x" * 150, 123, "\u200b" * 3]) def test_invalid_stored_display_name_falls_back_in_api(self, agent_client, display_name): from deerflow.persistence.agents.file import FileAgentStore @@ -972,6 +1118,15 @@ class TestAgentsApiDisabled: response = disabled_agent_client.post("/api/agents", json={"name": "example-agent", "soul": "blocked"}) assert response.status_code == 403 + def test_agent_portability_routes_return_403(self, disabled_agent_client): + package = { + "format": "deerflow.custom-agent", + "version": 1, + "agent": {"name": "example-agent", "soul": "blocked"}, + } + assert disabled_agent_client.post("/api/agents/import", json=package).status_code == 403 + assert disabled_agent_client.get("/api/agents/example-agent/export").status_code == 403 + def test_agent_update_returns_403(self, disabled_agent_client): response = disabled_agent_client.put("/api/agents/example-agent", json={"description": "blocked"}) assert response.status_code == 403 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 72f3f62141..d1dd499f6a 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -103,6 +103,12 @@ keep `name` for React identity, URLs, requests, and runtime `agent_name`. The 100-code-point budget uses `[...value.trim()].length`, matching Pydantic; do not use HTML `maxLength`, which counts UTF-16 code units instead. +Custom Agent portability uses the versioned `deerflow.custom-agent` JSON +document through `core/agents/api.ts`. Keep file parsing client-side only for +previewing the proposed local name; the Gateway is authoritative for schema, +name, model, and conflict validation. Export downloads must never synthesize +runtime state from browser caches. + - **Imports**: Enforced ordering (builtin → external → internal → parent → sibling), alphabetized, newlines between groups. Use inline type imports: `import { type Foo }`. - **Unused variables**: Prefix with `_`. - **Class names**: Use `cn()` from `@/lib/utils` for conditional Tailwind classes. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 2276232c5d..399c0aca68 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -108,6 +108,12 @@ Language-map membership checks only own properties; inherited names such as definitions. Only administrators see managed-definition mutation controls; Custom Agent settings consume the same query and preserve stale selected names as removable "missing" entries instead of silently widening the allowlist. + The Agents gallery imports and exports versioned JSON packages through + `core/agents`; it may prefill an import name from untrusted JSON, but only the + Gateway validates or persists the package. A 409 is an explicit rename + prompt/error, never permission to overwrite the existing Agent. + Import errors join FastAPI 422 validation messages into readable text while + preserving string conflict details; unusable error bodies use a status fallback. 6. Components subscribe to thread state and render updates AI message grouping uses `extractContentFromMessage()` to identify visible answer content. A non-empty content array may contain only Anthropic thinking blocks; keep it in `assistant:processing` until answer content arrives. Cover both streamed snapshots in `tests/unit/core/messages/utils.test.ts`. diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx index 4193046320..31e52246e8 100644 --- a/frontend/src/components/workspace/agents/agent-card.tsx +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -2,6 +2,7 @@ import { BotIcon, + DownloadIcon, MessageSquareIcon, Settings2Icon, Trash2Icon, @@ -33,7 +34,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useDeleteAgent } from "@/core/agents"; +import { exportAgentPackage, useDeleteAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; @@ -116,6 +117,7 @@ export function AgentCard({ agent }: AgentCardProps) { const deleteAgent = useDeleteAgent(); const [deleteOpen, setDeleteOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [exporting, setExporting] = useState(false); function handleChat() { router.push(`/workspace/agents/${agent.name}/chats/new`); @@ -131,6 +133,29 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleExport() { + setExporting(true); + try { + const agentPackage = await exportAgentPackage(agent.name); + const blob = new Blob([JSON.stringify(agentPackage, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${agent.name}.deerflow-agent.json`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + toast.success(t.agents.exportSuccess); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setExporting(false); + } + } + return ( <> @@ -195,6 +220,16 @@ export function AgentCard({ agent }: AgentCardProps) { {t.agents.chat}
+
- +
+ + + +
{/* Content */} @@ -64,6 +137,44 @@ export function AgentGallery() { )} + + !open && closeImportDialog()} + > + + + {t.agents.importTitle} + {t.agents.importDescription} + +
+ + setImportName(event.target.value)} + placeholder={t.agents.nameStepPlaceholder} + /> +
+ + + + +
+
); } diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index c0932defe5..89d2997c7b 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -2,7 +2,12 @@ import { fetch } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; export { fetchAgentsApiEnabled } from "@/core/features/api"; -import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; +import type { + Agent, + AgentPackage, + CreateAgentRequest, + UpdateAgentRequest, +} from "./types"; const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]); @@ -65,6 +70,54 @@ export async function createAgent(request: CreateAgentRequest): Promise { return res.json() as Promise; } +export async function exportAgentPackage(name: string): Promise { + const res = await fetch( + `${getBackendBaseURL()}/api/agents/${encodeURIComponent(name)}/export`, + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to export agent: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function importAgentPackage( + agentPackage: unknown, + name?: string, +): Promise { + const query = name ? `?name=${encodeURIComponent(name)}` : ""; + const res = await fetch(`${getBackendBaseURL()}/api/agents/import${query}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(agentPackage), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { + detail?: unknown; + } | null; + const detail = err?.detail; + const message = Array.isArray(detail) + ? detail + .filter( + (issue): issue is { msg: string } => + typeof issue === "object" && + issue !== null && + "msg" in issue && + typeof issue.msg === "string" && + issue.msg.trim().length > 0, + ) + .map((issue) => issue.msg) + .join("; ") + : typeof detail === "string" + ? detail + : null; + throw new Error( + message?.trim() ? message : `Failed to import agent: ${res.statusText}`, + ); + } + return res.json() as Promise; +} + export async function updateAgent( name: string, request: UpdateAgentRequest, diff --git a/frontend/src/core/agents/hooks.ts b/frontend/src/core/agents/hooks.ts index 67eb316c01..f6d5a42b2b 100644 --- a/frontend/src/core/agents/hooks.ts +++ b/frontend/src/core/agents/hooks.ts @@ -6,6 +6,7 @@ import { deleteAgent, fetchAgentsApiEnabled, getAgent, + importAgentPackage, listAgents, updateAgent, } from "./api"; @@ -82,6 +83,22 @@ export function useCreateAgent() { }); } +export function useImportAgentPackage() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + agentPackage, + name, + }: { + agentPackage: unknown; + name?: string; + }) => importAgentPackage(agentPackage, name), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["agents"] }); + }, + }); +} + export function useUpdateAgent() { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/core/agents/types.ts b/frontend/src/core/agents/types.ts index 82a899e991..c4c7cdaaa2 100644 --- a/frontend/src/core/agents/types.ts +++ b/frontend/src/core/agents/types.ts @@ -39,6 +39,17 @@ export interface CreateAgentRequest { soul?: string; } +export interface PortableAgentDefinition extends CreateAgentRequest { + name: string; + memory_enabled?: boolean; +} + +export interface AgentPackage { + format: "deerflow.custom-agent"; + version: 1; + agent: PortableAgentDefinition; +} + export interface UpdateAgentRequest { display_name?: string | null; description?: string | null; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 03ea225d2e..6eedc303fe 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -997,6 +997,15 @@ export const enUS: Translations = { description: "Create and manage custom agents with specialized prompts and capabilities.", newAgent: "New Agent", + importAgent: "Import Agent", + importTitle: "Import custom agent", + importDescription: + "Choose the local name for this agent. Existing agents are never overwritten.", + importName: "Agent name", + importInvalidFile: "This file is not valid JSON", + importSuccess: "Agent imported", + exportAgent: "Export Agent", + exportSuccess: "Agent package downloaded", emptyTitle: "No custom agents yet", emptyDescription: "Create your first custom agent with a specialized system prompt.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 425ab8b0a2..0556f176fa 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -820,6 +820,14 @@ export interface Translations { title: string; description: string; newAgent: string; + importAgent: string; + importTitle: string; + importDescription: string; + importName: string; + importInvalidFile: string; + importSuccess: string; + exportAgent: string; + exportSuccess: string; emptyTitle: string; emptyDescription: string; featureDisabledTitle: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index c954cd5411..d91c48009f 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -937,6 +937,14 @@ export const zhCN: Translations = { title: "智能体", description: "创建和管理具有专属 Prompt 与能力的自定义智能体。", newAgent: "新建智能体", + importAgent: "导入智能体", + importTitle: "导入自定义智能体", + importDescription: "为该智能体选择本地名称。已有智能体不会被覆盖。", + importName: "智能体名称", + importInvalidFile: "该文件不是有效的 JSON", + importSuccess: "智能体已导入", + exportAgent: "导出智能体", + exportSuccess: "智能体包已下载", emptyTitle: "还没有自定义智能体", emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。", featureDisabledTitle: "智能体功能未启用", diff --git a/frontend/tests/unit/core/agents/api.test.ts b/frontend/tests/unit/core/agents/api.test.ts index e6cbef3f64..7498e02ad4 100644 --- a/frontend/tests/unit/core/agents/api.test.ts +++ b/frontend/tests/unit/core/agents/api.test.ts @@ -29,6 +29,8 @@ rs.mock("@/core/config", () => ({ import { AgentsApiDisabledError, checkAgentName, + exportAgentPackage, + importAgentPackage, updateAgent, } from "@/core/agents/api"; import { fetch as fetcher } from "@/core/api/fetcher"; @@ -205,3 +207,141 @@ describe("updateAgent", () => { }); }); }); + +describe("agent portability", () => { + const agentPackage = { + format: "deerflow.custom-agent" as const, + version: 1 as const, + agent: { + name: "research-lead", + description: "Coordinates research", + model: "agent-model", + tool_groups: ["web"], + skills: ["literature-review"], + allowed_subagents: ["researcher"], + soul: "Delegate and synthesize.", + }, + }; + + test("exports the versioned package", async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, agentPackage)); + + await expect(exportAgentPackage("research lead/unsafe")).resolves.toEqual( + agentPackage, + ); + expect(mockedFetch).toHaveBeenCalledWith( + "/api/agents/research%20lead%2Funsafe/export", + ); + }); + + test("imports the exact package with an optional target name", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(201, { ...agentPackage.agent, name: "research-copy" }), + ); + + await importAgentPackage(agentPackage, "research-copy"); + + const [url, init] = mockedFetch.mock.calls[0]!; + expect(url).toBe("/api/agents/import?name=research-copy"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body as string)).toEqual(agentPackage); + }); + + test("surfaces explicit import conflicts from the backend", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(409, { detail: "Agent 'research-lead' already exists" }), + ); + + await expect(importAgentPackage(agentPackage)).rejects.toThrow( + "Agent 'research-lead' already exists", + ); + }); + + test.each([ + { + detail: [ + { + type: "string_pattern_mismatch", + loc: ["body", "agent", "name"], + msg: "String should match pattern '^[A-Za-z0-9-]+$'", + input: "research lead", + }, + ], + message: "String should match pattern '^[A-Za-z0-9-]+$'", + }, + { + detail: [ + { + type: "literal_error", + loc: ["body", "format"], + msg: "Input should be 'deerflow.custom-agent'", + input: "old-format", + }, + { + type: "extra_forbidden", + loc: ["body", "agent", "github"], + msg: "Extra inputs are not permitted", + input: {}, + }, + ], + message: + "Input should be 'deerflow.custom-agent'; Extra inputs are not permitted", + }, + ])( + "surfaces package validation messages: $message", + async ({ detail, message }) => { + mockedFetch.mockResolvedValueOnce(jsonResponse(422, { detail })); + + await expect(importAgentPackage(agentPackage)).rejects.toMatchObject({ + message, + }); + }, + ); + + test("keeps readable validation messages when some entries are malformed", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(422, { + detail: [null, { msg: 42 }, { msg: " " }, { msg: "Field required" }], + }), + ); + + await expect(importAgentPackage(agentPackage)).rejects.toMatchObject({ + message: "Field required", + }); + }); + + test.each([ + null, + { detail: { unexpected: "value" } }, + { detail: [] }, + { detail: [null, { msg: 42 }] }, + { detail: " " }, + ])( + "falls back when an import error has no usable detail: %j", + async (body) => { + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 503, + statusText: "Service Unavailable", + }), + ); + + await expect(importAgentPackage(agentPackage)).rejects.toMatchObject({ + message: "Failed to import agent: Service Unavailable", + }); + }, + ); + + test("falls back when an import error body is not JSON", async () => { + mockedFetch.mockResolvedValueOnce( + new Response("Bad Gateway", { + status: 502, + statusText: "Bad Gateway", + }), + ); + + await expect(importAgentPackage(agentPackage)).rejects.toMatchObject({ + message: "Failed to import agent: Bad Gateway", + }); + }); +});