mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
67d30f84ac
commit
3606c4a098
10
README.md
10
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
|
||||
|
||||
9
backend/app/gateway/routers/AGENTS.md
Normal file
9
backend/app/gateway/routers/AGENTS.md
Normal 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.
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -937,6 +937,14 @@ export const zhCN: Translations = {
|
||||
title: "智能体",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
||||
newAgent: "新建智能体",
|
||||
importAgent: "导入智能体",
|
||||
importTitle: "导入自定义智能体",
|
||||
importDescription: "为该智能体选择本地名称。已有智能体不会被覆盖。",
|
||||
importName: "智能体名称",
|
||||
importInvalidFile: "该文件不是有效的 JSON",
|
||||
importSuccess: "智能体已导入",
|
||||
exportAgent: "导出智能体",
|
||||
exportSuccess: "智能体包已下载",
|
||||
emptyTitle: "还没有自定义智能体",
|
||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
||||
featureDisabledTitle: "智能体功能未启用",
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user