feat(agents): add portable custom agent packages (#6127)

* feat(agents): add portable agent packages

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* fix(agents): show readable package import validation errors

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
PeaceMaker-best 2026-10-06 10:20:38 +08:00 committed by GitHub
parent 67d30f84ac
commit 3606c4a098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 847 additions and 41 deletions

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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=<new-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

View File

@ -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

View File

@ -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",

View File

@ -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):

View File

@ -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

View File

@ -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.

View File

@ -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`.

View File

@ -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 (
<>
<Card className="group flex flex-col transition-shadow hover:shadow-md">
@ -195,6 +220,16 @@ export function AgentCard({ agent }: AgentCardProps) {
{t.agents.chat}
</Button>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={handleExport}
disabled={exporting}
title={t.agents.exportAgent}
>
<DownloadIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"

View File

@ -1,10 +1,21 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { BotIcon, PlusIcon, UploadIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { type ChangeEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useAgents, useImportAgentPackage } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
@ -13,6 +24,52 @@ export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const importAgent = useImportAgentPackage();
const fileInputRef = useRef<HTMLInputElement>(null);
const [packageDocument, setPackageDocument] = useState<unknown>(null);
const [importName, setImportName] = useState("");
const closeImportDialog = () => {
setPackageDocument(null);
setImportName("");
};
const handleImportFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const parsed: unknown = JSON.parse(await file.text());
const sourceName =
typeof parsed === "object" &&
parsed !== null &&
"agent" in parsed &&
typeof parsed.agent === "object" &&
parsed.agent !== null &&
"name" in parsed.agent &&
typeof parsed.agent.name === "string"
? parsed.agent.name
: "";
setPackageDocument(parsed);
setImportName(sourceName);
} catch {
toast.error(t.agents.importInvalidFile);
}
};
const handleImport = async () => {
if (packageDocument === null) return;
try {
await importAgent.mutateAsync({
agentPackage: packageDocument,
name: importName.trim() || undefined,
});
toast.success(t.agents.importSuccess);
closeImportDialog();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleNewAgent = () => {
router.push("/workspace/agents/new");
@ -28,10 +85,26 @@ export function AgentGallery() {
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
className="hidden"
type="file"
accept="application/json,.json"
onChange={handleImportFile}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="mr-1.5 h-4 w-4" />
{t.agents.importAgent}
</Button>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
</div>
{/* Content */}
@ -64,6 +137,44 @@ export function AgentGallery() {
</div>
)}
</div>
<Dialog
open={packageDocument !== null}
onOpenChange={(open) => !open && closeImportDialog()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.agents.importTitle}</DialogTitle>
<DialogDescription>{t.agents.importDescription}</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="agent-import-name" className="text-sm font-medium">
{t.agents.importName}
</label>
<Input
id="agent-import-name"
value={importName}
onChange={(event) => setImportName(event.target.value)}
placeholder={t.agents.nameStepPlaceholder}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={closeImportDialog}
disabled={importAgent.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={handleImport}
disabled={importAgent.isPending || importName.trim().length === 0}
>
{importAgent.isPending ? t.common.loading : t.agents.importAgent}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<Agent> {
return res.json() as Promise<Agent>;
}
export async function exportAgentPackage(name: string): Promise<AgentPackage> {
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<AgentPackage>;
}
export async function importAgentPackage(
agentPackage: unknown,
name?: string,
): Promise<Agent> {
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<Agent>;
}
export async function updateAgent(
name: string,
request: UpdateAgentRequest,

View File

@ -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({

View File

@ -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;

View File

@ -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.",

View File

@ -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;

View File

@ -937,6 +937,14 @@ export const zhCN: Translations = {
title: "智能体",
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
newAgent: "新建智能体",
importAgent: "导入智能体",
importTitle: "导入自定义智能体",
importDescription: "为该智能体选择本地名称。已有智能体不会被覆盖。",
importName: "智能体名称",
importInvalidFile: "该文件不是有效的 JSON",
importSuccess: "智能体已导入",
exportAgent: "导出智能体",
exportSuccess: "智能体包已下载",
emptyTitle: "还没有自定义智能体",
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
featureDisabledTitle: "智能体功能未启用",

View File

@ -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("<html>Bad Gateway</html>", {
status: 502,
statusText: "Bad Gateway",
}),
);
await expect(importAgentPackage(agentPackage)).rejects.toMatchObject({
message: "Failed to import agent: Bad Gateway",
});
});
});