diff --git a/README.md b/README.md index 2bdb6e914b..f708371b6e 100644 --- a/README.md +++ b/README.md @@ -1657,6 +1657,15 @@ changed, or cleared, including unscoped runs accepted before this feature. This default applies to Gateway-hosted custom-agent turns; direct harness/client integrations continue to supply their own execution scope. +In Agent settings, **Tool access** can inherit every configured tool group, +allow selected groups, or disable all groups. Saved groups stay visible and +removable while the catalog loads or fails to load. A saved group is marked +unavailable only when a successful catalog load confirms it is missing. +Saving a selection that removes `knowledge` also clears the saved default +knowledge selection. When enabling knowledge access for an agent that +previously lacked it, save the tool access first, then reopen settings to +choose its default knowledge bases. + The `knowledge_base` block is provider-neutral and only controls whether the knowledge capability and selector are enabled. RAGFlow connection, dataset allowlist, and retrieval parameters (`base_url`, `api_key`, `datasets`, diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index cb3f9e6564..58df58187a 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -16,6 +16,10 @@ 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. +`GET /api/agent-tool-groups` projects only configured group names for the +custom-Agent settings allowlist. Never expose tool provider paths, credentials, +or raw tool configuration through this catalog. + Custom Agent update guards apply legacy shared-file checks only when the resolved store is `FileAgentStore`, including the `update_agent` tool. DB imports retain source files for rollback; those files cannot veto SQL updates. diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 5415de97b9..e831f3b266 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -6,10 +6,11 @@ import re from pathlib import Path from typing import Literal -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, ConfigDict, Field from app.gateway.authz import require_permission +from app.gateway.deps import get_config from app.gateway.persistent_writes import run_drained_write from deerflow.agents.memory.manager import get_memory_manager from deerflow.config.agents_api_config import get_agents_api_config @@ -22,7 +23,7 @@ from deerflow.config.agents_config import ( load_agent_soul, preserve_non_managed_fields, ) -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.paths import get_paths from deerflow.knowledge_scope import KnowledgeScope, canonicalize_knowledge_scope from deerflow.persistence.agents import AgentDeleteOutcome, AgentExistsError, get_agent_store @@ -67,6 +68,12 @@ class AgentsListResponse(BaseModel): agents: list[AgentResponse] +class ToolGroupsResponse(BaseModel): + """Configured tool groups safe to present in Agent settings.""" + + tool_groups: list[str] + + class AgentCreateRequest(BaseModel): """Request body for creating a custom agent.""" @@ -267,6 +274,11 @@ def _build_agent_config(body: AgentCreateRequest, normalized_name: str, *, memor return config_data +def _configured_tool_group_names(config: AppConfig) -> list[str]: + """Project configured group names without exposing tool definitions.""" + return list(dict.fromkeys(group.name for group in config.tool_groups)) + + 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) @@ -312,6 +324,22 @@ async def list_agents(request: Request) -> AgentsListResponse: raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") +@router.get( + "/agent-tool-groups", + response_model=ToolGroupsResponse, + summary="List Agent Tool Groups", + description="List configured tool-group names available for custom-agent allowlists.", +) +@require_permission("agents", "read") +async def list_agent_tool_groups( + request: Request, + config: AppConfig = Depends(get_config), +) -> ToolGroupsResponse: + """Return names only; tool implementations and configuration stay private.""" + _require_agents_api_enabled() + return ToolGroupsResponse(tool_groups=_configured_tool_group_names(config)) + + @router.get( "/agents/check", summary="Check Agent Name", diff --git a/backend/tests/test_agent_tool_groups_router.py b/backend/tests/test_agent_tool_groups_router.py new file mode 100644 index 0000000000..0b86dfc4a0 --- /dev/null +++ b/backend/tests/test_agent_tool_groups_router.py @@ -0,0 +1,103 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.authz import AuthContext, Permissions +from app.gateway.deps import get_config +from app.gateway.routers.agents import ( + ToolGroupsResponse, + _configured_tool_group_names, + router, +) +from deerflow.config.agents_api_config import AgentsApiConfig +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.tool_config import ToolGroupConfig + + +@pytest.fixture +def tool_group_config() -> AppConfig: + return AppConfig( + sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"), + tool_groups=[ + ToolGroupConfig(name="web"), + ToolGroupConfig(name="file:read"), + ToolGroupConfig(name="web"), + ToolGroupConfig(name="knowledge"), + ], + ) + + +@pytest.fixture +def tool_group_client(monkeypatch: pytest.MonkeyPatch, tool_group_config: AppConfig): + api_config = AgentsApiConfig(enabled=True) + auth = AuthContext( + user=User(email="tool-groups@example.com", password_hash="test-only"), + permissions=[Permissions.AGENTS_READ], + ) + monkeypatch.setattr("app.gateway.routers.agents.get_agents_api_config", lambda: api_config) + monkeypatch.setattr("app.gateway.authz._authenticate", AsyncMock(return_value=auth)) + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_config] = lambda: tool_group_config + with TestClient(app) as client: + yield client, auth, api_config + + +def test_tool_group_catalog_preserves_operator_order_and_deduplicates(tool_group_config: AppConfig) -> None: + response = ToolGroupsResponse(tool_groups=_configured_tool_group_names(tool_group_config)) + + assert response.model_dump() == { + "tool_groups": ["web", "file:read", "knowledge"], + } + + +def test_tool_group_catalog_does_not_reserve_a_valid_agent_name() -> None: + paths = {route.path for route in router.routes} + + assert "/api/agent-tool-groups" in paths + assert "/api/agents/tool-groups" not in paths + + +def test_tool_group_catalog_returns_configured_names_over_http(tool_group_client) -> None: + client, _, _ = tool_group_client + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 200 + assert response.json() == {"tool_groups": ["web", "file:read", "knowledge"]} + + +def test_tool_group_catalog_rejects_disabled_agents_api(tool_group_client) -> None: + client, _, api_config = tool_group_client + api_config.enabled = False + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 403 + assert "Custom-agent management API is disabled" in response.json()["detail"] + + +@pytest.mark.parametrize("permissions", [[], [Permissions.AGENTS_WRITE]]) +def test_tool_group_catalog_requires_agents_read_permission(tool_group_client, permissions: list[str]) -> None: + client, auth, _ = tool_group_client + auth.permissions = permissions + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 403 + assert response.json() == {"detail": "Permission denied: agents:read"} + + +def test_tool_group_catalog_requires_authentication(tool_group_client) -> None: + client, auth, _ = tool_group_client + auth.user = None + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 28214af08e..9a7fd27d11 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -109,6 +109,18 @@ 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 tool access is edited from the configured names returned by +`GET /api/agent-tool-groups`. Preserve the `null` / `[]` / explicit-list +contract (inherit all / disable all / allowlist), retain missing selected names +as removable entries, and submit `tool_groups` only when the user changes it. +Mark missing names unavailable only after a successful catalog load; loading +and errors preserve neutral labels and removable selections. +When a changed allowlist excludes `knowledge`, send `knowledge_scope: null` in +the same update so a saved retrieval default cannot break run admission. Gate +knowledge selection on both draft and persisted tool access: catalog reads +authorize against the saved agent, so new access must be saved first. Restored +tool selections remain unchanged and must not clear a knowledge default. + 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, diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 5a8e90e092..6418ad7b7e 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -232,13 +232,13 @@ Markdown. Source records retain retrieval-time evidence, not live documents. Resolve knowledge destinations before testing the label in message and artifact link renderers: Sources lists use ordinary titles without a `citation:` prefix. -Custom-agent settings persist `knowledge_scope` as an optional retrieval default. -Use `knowledgeScopeToSelection` to restore all execution IDs, falling back to IDs -where the bounded display snapshot lacks names. Only dirty knowledge settings -are sent on save. Custom-agent chat derives its initial selection from the saved -binding; explicit page-local overrides survive new-thread route replacement and -reset on conversation changes. Gateway supplies defaults for clients without a -selector; frontend visibility must not become a runtime enforcement boundary. +Custom-agent settings store an optional `knowledge_scope` retrieval default. +`knowledgeScopeToSelection` restores all execution IDs; missing display names +fall back to IDs. Save only edited scopes, except removing knowledge tool access +must atomically clear the default. Catalog selection needs saved and draft access. +Chats initialize from saved bindings; explicit page-local overrides survive +new-thread route replacement and reset on conversation changes. Gateway supplies +defaults for clients without a selector; UI visibility is not runtime enforcement. MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured. diff --git a/frontend/src/components/workspace/agents/agent-capability-selection.tsx b/frontend/src/components/workspace/agents/agent-capability-selection.tsx index 588c61054c..22a6095f1a 100644 --- a/frontend/src/components/workspace/agents/agent-capability-selection.tsx +++ b/frontend/src/components/workspace/agents/agent-capability-selection.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAgentToolGroups } from "@/core/agents"; import { capabilityCopy } from "@/core/capabilities/copy"; import { useCapabilityInstallations } from "@/core/capabilities/hooks"; import { useI18n } from "@/core/i18n/hooks"; @@ -71,16 +72,89 @@ function Selection({ ); } + +function ToolGroupSelection({ + value, + onChange, +}: { + value: string[] | null; + onChange: (value: string[] | null) => void; +}) { + const { t } = useI18n(); + const { toolGroups, isLoading, error } = useAgentToolGroups(); + const options = new Map(toolGroups.map((name) => [name, name])); + for (const name of value ?? []) { + if (!options.has(name)) { + options.set( + name, + !isLoading && !error + ? `${name} (${t.agents.settingsToolGroupUnavailable})` + : name, + ); + } + } + + return ( +
+ ); +} export function AgentCapabilitySelection({ plugins, skills, + toolGroups, onPluginsChange, onSkillsChange, + onToolGroupsChange, }: { plugins: string[] | null; skills: string[] | null; + toolGroups: string[] | null; onPluginsChange: (value: string[] | null) => void; onSkillsChange: (value: string[] | null) => void; + onToolGroupsChange: (value: string[] | null) => void; }) { const { locale } = useI18n(); const copy = capabilityCopy(locale); @@ -92,6 +166,7 @@ export function AgentCapabilitySelection({{copy.hint}
+ {t.agents.settingsKnowledgeSaveToolAccess} +
+ )} {knowledgeSelection.mode === "selected" && (
{knowledgeSelection.datasets
diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts
index 89d2997c7b..01a718adb4 100644
--- a/frontend/src/core/agents/api.ts
+++ b/frontend/src/core/agents/api.ts
@@ -48,6 +48,15 @@ export async function listAgents(): Promise