feat(deer-flow): configure agent tool access (#6630)

* feat(deer-flow): configure agent tool access

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

* fix(frontend): reconcile knowledge defaults with agent tool access

* docs: reduce frontend agent guidance below size limit

* fix: address agent tool-group catalog review findings

---------

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-12 19:00:54 +08:00 committed by GitHub
parent 9a0ea6abf7
commit 65fa321ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 710 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({
</fieldset>
);
}
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 (
<fieldset className="space-y-2 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">
{t.agents.settingsToolGroups}
</legend>
<p className="text-muted-foreground text-xs leading-5">
{t.agents.settingsToolGroupsHint}
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value === null}
onChange={(event) => onChange(event.target.checked ? null : [])}
/>
{t.agents.settingsToolGroupsAll}
</label>
{isLoading && (
<p className="text-muted-foreground text-xs">{t.common.loading}</p>
)}
{error && (
<p role="alert" className="text-destructive text-xs">
{t.agents.settingsToolGroupsLoadFailed}
</p>
)}
{value !== null && (
<div className="max-h-44 space-y-2 overflow-y-auto">
{[...options].map(([name, label]) => (
<label key={name} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.includes(name)}
onChange={(event) =>
onChange(
event.target.checked
? [...value, name]
: value.filter((item) => item !== name),
)
}
/>
{label}
</label>
))}
</div>
)}
</fieldset>
);
}
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({
<p className="text-muted-foreground text-xs leading-5">{copy.hint}</p>
<Selection adapter="mcp" value={plugins} onChange={onPluginsChange} />
<Selection adapter="skills" value={skills} onChange={onSkillsChange} />
<ToolGroupSelection value={toolGroups} onChange={onToolGroupsChange} />
</details>
);
}

View File

@ -97,11 +97,27 @@ export function AgentSettingsDialog({
const [initialSelections] = useState(() => ({
plugins: agent.mcp_plugins ?? null,
skills: agent.skills ?? null,
toolGroups: agent.tool_groups ?? null,
}));
const [plugins, setPlugins] = useState<string[] | null>(
agent.mcp_plugins ?? null,
);
const [skills, setSkills] = useState<string[] | null>(agent.skills ?? null);
const [toolGroups, setToolGroups] = useState<string[] | null>(
agent.tool_groups ?? null,
);
const toolGroupsChanged = !sameSelection(
toolGroups,
initialSelections.toolGroups,
);
const knowledgeAllowed =
toolGroups === null || toolGroups.includes("knowledge");
const clearKnowledgeDefault = toolGroupsChanged && !knowledgeAllowed;
// Catalog reads authorize against saved groups, never the dialog's draft.
const knowledgeAccessNeedsSave =
knowledgeAllowed &&
agent.tool_groups != null &&
!agent.tool_groups.includes("knowledge");
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE);
@ -194,9 +210,9 @@ export function AgentSettingsDialog({
name: agent.name,
request: {
display_name: displayName.trim() || null,
...(knowledgeChanged && {
...((knowledgeChanged || clearKnowledgeDefault) && {
knowledge_scope:
knowledgeSelection.mode === "all"
clearKnowledgeDefault || knowledgeSelection.mode === "all"
? null
: buildKnowledgeScopeSnapshot(knowledgeSelection),
}),
@ -204,6 +220,9 @@ export function AgentSettingsDialog({
mcp_plugins: plugins,
}),
...(!sameSelection(skills, initialSelections.skills) && { skills }),
...(toolGroupsChanged && {
tool_groups: toolGroups,
}),
model: model === DEFAULT_MODEL_VALUE ? null : model,
model_settings: parsedSettings.modelSettings,
thinking_enabled: supportsThinking
@ -239,8 +258,10 @@ export function AgentSettingsDialog({
<AgentCapabilitySelection
plugins={plugins}
skills={skills}
toolGroups={toolGroups}
onPluginsChange={setPlugins}
onSkillsChange={setSkills}
onToolGroupsChange={setToolGroups}
/>
{(scopeSelectionEnabled || agent.knowledge_scope) && (
<div className="space-y-1.5 rounded-md border p-3">
@ -259,16 +280,22 @@ export function AgentSettingsDialog({
unavailableReason={
!scopeSelectionEnabled
? t.knowledge.scope.loadFailed
: agent.tool_groups != null &&
!agent.tool_groups.includes("knowledge")
: !knowledgeAllowed
? t.knowledge.scope.agentUnavailable
: undefined
: knowledgeAccessNeedsSave
? t.agents.settingsKnowledgeSaveToolAccess
: undefined
}
onChange={(selection) => {
setKnowledgeSelection(selection);
setKnowledgeChanged(true);
}}
/>
{scopeSelectionEnabled && knowledgeAccessNeedsSave && (
<p className="text-muted-foreground text-xs">
{t.agents.settingsKnowledgeSaveToolAccess}
</p>
)}
{knowledgeSelection.mode === "selected" && (
<p className="text-muted-foreground text-xs [overflow-wrap:anywhere]">
{knowledgeSelection.datasets

View File

@ -48,6 +48,15 @@ export async function listAgents(): Promise<Agent[]> {
return data.agents;
}
export async function listAgentToolGroups(): Promise<string[]> {
const res = await fetch(`${getBackendBaseURL()}/api/agent-tool-groups`);
if (!res.ok) {
throw new Error(`Failed to load agent tool groups: ${res.statusText}`);
}
const data = (await res.json()) as { tool_groups: string[] };
return data.tool_groups;
}
export async function getAgent(name: string): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`);
if (!res.ok) throw new Error(`Agent '${name}' not found`);

View File

@ -7,6 +7,7 @@ import {
fetchAgentsApiEnabled,
getAgent,
importAgentPackage,
listAgentToolGroups,
listAgents,
updateAgent,
} from "./api";
@ -64,6 +65,14 @@ export function useAgents() {
return { agents: data ?? [], isLoading, error };
}
export function useAgentToolGroups() {
const { data, isLoading, error } = useQuery({
queryKey: ["agent-tool-groups"],
queryFn: () => listAgentToolGroups(),
});
return { toolGroups: data ?? [], isLoading, error };
}
export function useAgent(name: string | null | undefined) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", name],

View File

@ -1360,12 +1360,20 @@ export const enUS: Translations = {
"Display name must be at most 100 Unicode code points.",
settingsDisplayNameHint:
"Supports Unicode. Leave blank to use the agent identifier",
settingsToolGroups: "Tool access",
settingsToolGroupsHint:
"Restrict which configured tool groups this agent and its delegated tasks may use.",
settingsToolGroupsAll: "Use every configured tool group",
settingsToolGroupsLoadFailed: "Tool groups could not be loaded.",
settingsToolGroupUnavailable: "unavailable",
settingsDescription:
"Choose a display name and model defaults for this agent. Model changes take effect on the next message.",
settingsKnowledge: "Default knowledge",
settingsKnowledgeHint:
"New conversations use this knowledge scope. Individual messages can override it. Selecting all knowledge bases removes the binding.",
settingsKnowledgeReset: "Use all knowledge bases",
settingsKnowledgeSaveToolAccess:
"Save tool access before choosing default knowledge bases.",
settingsModel: "Default model",
settingsModelDefault: "Use global default",
settingsTemperature: "Temperature",

View File

@ -1119,10 +1119,16 @@ export interface Translations {
settingsDisplayName: string;
settingsDisplayNameTooLong: string;
settingsDisplayNameHint: string;
settingsToolGroups: string;
settingsToolGroupsHint: string;
settingsToolGroupsAll: string;
settingsToolGroupsLoadFailed: string;
settingsToolGroupUnavailable: string;
settingsDescription: string;
settingsKnowledge: string;
settingsKnowledgeHint: string;
settingsKnowledgeReset: string;
settingsKnowledgeSaveToolAccess: string;
settingsModel: string;
settingsModelDefault: string;
settingsTemperature: string;

View File

@ -1274,12 +1274,18 @@ export const zhCN: Translations = {
settingsDisplayName: "显示名称",
settingsDisplayNameTooLong: "显示名称不能超过 100 个 Unicode 码点。",
settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识",
settingsToolGroups: "工具权限",
settingsToolGroupsHint: "限制该智能体及其委派任务可以使用的已配置工具组。",
settingsToolGroupsAll: "使用全部已配置工具组",
settingsToolGroupsLoadFailed: "无法加载工具组。",
settingsToolGroupUnavailable: "不可用",
settingsDescription:
"为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。",
settingsKnowledge: "默认知识库",
settingsKnowledgeHint:
"新对话默认使用此知识库范围,单条消息可以临时覆盖。选择全部知识库可解除绑定。",
settingsKnowledgeReset: "使用全部知识库",
settingsKnowledgeSaveToolAccess: "请先保存工具权限,再选择默认知识库。",
settingsModel: "默认模型",
settingsModelDefault: "使用全局默认",
settingsTemperature: "温度",

View File

@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { AgentCapabilitySelection } from "@/components/workspace/agents/agent-capability-selection";
import { enUS } from "@/core/i18n/locales/en-US";
const { useAgentToolGroups } = rs.hoisted(() => ({
useAgentToolGroups: rs.fn(),
}));
rs.mock("@/core/agents", () => ({ useAgentToolGroups }));
rs.mock("@/core/capabilities/hooks", () => ({
useCapabilityInstallations: () => ({
data: { items: [] },
isLoading: false,
isError: false,
}),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ locale: "en-US", t: enUS }),
}));
afterEach(() => {
cleanup();
useAgentToolGroups.mockReset();
});
function selection(onToolGroupsChange = rs.fn()) {
return (
<AgentCapabilitySelection
plugins={null}
skills={null}
toolGroups={["web", "legacy-tools"]}
onPluginsChange={rs.fn()}
onSkillsChange={rs.fn()}
onToolGroupsChange={onToolGroupsChange}
/>
);
}
describe("tool-group catalog availability", () => {
it.each([
{ state: "loading", isLoading: true, error: null },
{ state: "failed", isLoading: false, error: new Error("Load failed") },
])("keeps saved groups neutral and removable when $state", (query) => {
useAgentToolGroups.mockReturnValue({ toolGroups: [], ...query });
const onChange = rs.fn();
render(selection(onChange));
for (const name of ["web", "legacy-tools"]) {
const checkbox = screen.getByLabelText<HTMLInputElement>(name);
expect(checkbox.checked).toBe(true);
expect(checkbox.disabled).toBe(false);
}
expect(screen.queryByText(/\(unavailable\)/)).toBeNull();
if (query.isLoading) {
expect(screen.getByText(enUS.common.loading)).toBeTruthy();
} else {
expect(screen.getByRole("alert").textContent).toBe(
enUS.agents.settingsToolGroupsLoadFailed,
);
}
fireEvent.click(screen.getByLabelText("legacy-tools"));
expect(onChange).toHaveBeenCalledWith(["web"]);
});
it("marks only missing groups unavailable after a successful load", () => {
useAgentToolGroups.mockReturnValue({
toolGroups: ["web"],
isLoading: false,
error: null,
});
const onChange = rs.fn();
render(selection(onChange));
expect(screen.getByLabelText<HTMLInputElement>("web").checked).toBe(true);
const missing = screen.getByLabelText<HTMLInputElement>(
"legacy-tools (unavailable)",
);
expect(missing.checked).toBe(true);
fireEvent.click(missing);
expect(onChange).toHaveBeenCalledWith(["web"]);
});
it("updates availability after loading without changing the selection", () => {
useAgentToolGroups.mockReturnValue({
toolGroups: [],
isLoading: true,
error: null,
});
const onChange = rs.fn();
const { rerender } = render(selection(onChange));
expect(screen.getByLabelText("legacy-tools")).toBeTruthy();
useAgentToolGroups.mockReturnValue({
toolGroups: ["web"],
isLoading: false,
error: null,
});
rerender(selection(onChange));
expect(screen.getByLabelText<HTMLInputElement>("web").checked).toBe(true);
expect(
screen.getByLabelText<HTMLInputElement>("legacy-tools (unavailable)")
.checked,
).toBe(true);
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@ -17,6 +17,11 @@ const { mutateAsync } = rs.hoisted(() => ({
mutateAsync: rs.fn().mockResolvedValue({}),
}));
rs.mock("@/core/agents", () => ({
useAgentToolGroups: () => ({
toolGroups: ["web", "file:read"],
isLoading: false,
error: null,
}),
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
}));
rs.mock("@/core/features", () => ({
@ -200,3 +205,63 @@ describe("capability selection update isolation", () => {
expect(request).not.toHaveProperty("skills");
});
});
describe("custom agent tool groups", () => {
it("saves an explicit tool-group allowlist", async () => {
render(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
fireEvent.click(screen.getByLabelText("Use every configured tool group"));
fireEvent.click(screen.getByLabelText("web"));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request.tool_groups).toEqual(["web"]);
});
it("keeps unavailable selected groups visible so they can be removed", async () => {
render(
<AgentSettingsDialog
agent={{ ...agent, tool_groups: ["legacy-tools"] }}
open
onOpenChange={rs.fn()}
/>,
);
fireEvent.click(screen.getByLabelText("legacy-tools (unavailable)"));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request.tool_groups).toEqual([]);
});
it("omits untouched tool groups after a concurrent agent refresh", async () => {
const opened = { ...agent, tool_groups: ["web"] };
const { rerender } = render(
<AgentSettingsDialog agent={opened} open onOpenChange={rs.fn()} />,
);
rerender(
<AgentSettingsDialog
agent={{ ...opened, tool_groups: ["file:read"] }}
open
onOpenChange={rs.fn()}
/>,
);
fireEvent.change(screen.getByLabelText("Display name"), {
target: { value: "Rename only" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request).not.toHaveProperty("tool_groups");
});
});

View File

@ -0,0 +1,226 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { AgentSettingsDialog } from "@/components/workspace/agents/agent-settings-dialog";
import type { Agent } from "@/core/agents";
import { enUS } from "@/core/i18n/locales/en-US";
import type { KnowledgeScopeSnapshot } from "@/core/knowledge";
const { mutateAsync } = rs.hoisted(() => ({
mutateAsync: rs.fn().mockResolvedValue({}),
}));
rs.mock("@/core/agents", () => ({
useAgentToolGroups: () => ({
toolGroups: ["web", "knowledge", "file:read"],
isLoading: false,
error: null,
}),
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
}));
rs.mock("@/core/features", () => ({
useKnowledgeBaseEnabled: () => ({ scopeSelectionEnabled: true }),
}));
rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) }));
rs.mock("@/core/subagents", () => ({
useSubagents: () => ({ subagents: [] }),
}));
rs.mock("@/core/capabilities/hooks", () => ({
useCapabilityInstallations: () => ({
data: { items: [], can_manage: false },
isLoading: false,
isError: false,
}),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } }));
const selectedScope: KnowledgeScopeSnapshot = {
version: 1,
mode: "selected",
dataset_ids: ["policies"],
};
const agent: Agent = {
name: "researcher",
description: "",
model: null,
tool_groups: ["web", "knowledge"],
skills: null,
knowledge_scope: selectedScope,
};
afterEach(() => {
cleanup();
mutateAsync.mockClear();
rs.restoreAllMocks();
});
function renderSettings(value: Agent) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
return render(
<AgentSettingsDialog agent={value} open onOpenChange={rs.fn()} />,
{ wrapper: Wrapper },
);
}
async function saveRequest() {
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
return (
mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
}
).request;
}
describe("tool access and default knowledge", () => {
it.each([
["selected", ["web"], selectedScope],
["selected", [], selectedScope],
["disabled", ["web"], { version: 1, mode: "disabled" }],
["disabled", [], { version: 1, mode: "disabled" }],
] as const)(
"clears a %s knowledge default atomically when groups become %j",
async (_mode, groups, scope) => {
renderSettings({ ...agent, knowledge_scope: scope });
fireEvent.click(screen.getByLabelText("knowledge"));
if (groups.length === 0) fireEvent.click(screen.getByLabelText("web"));
expect(await saveRequest()).toMatchObject({
tool_groups: groups,
knowledge_scope: null,
});
},
);
it("clears the default when inherited tool access is disabled", async () => {
renderSettings({ ...agent, tool_groups: null });
fireEvent.click(screen.getByLabelText("Use every configured tool group"));
expect(await saveRequest()).toMatchObject({
tool_groups: [],
knowledge_scope: null,
});
});
it("omits knowledge and groups when a draft restriction is restored", async () => {
renderSettings(agent);
fireEvent.click(screen.getByLabelText("knowledge"));
fireEvent.click(screen.getByLabelText("knowledge"));
const request = await saveRequest();
expect(request).not.toHaveProperty("tool_groups");
expect(request).not.toHaveProperty("knowledge_scope");
});
it("preserves concurrent defaults on a rename-only save", async () => {
const { rerender } = renderSettings(agent);
rerender(
<AgentSettingsDialog
agent={{
...agent,
tool_groups: ["web"],
knowledge_scope: { version: 1, mode: "disabled" },
}}
open
onOpenChange={rs.fn()}
/>,
);
fireEvent.change(screen.getByLabelText("Display name"), {
target: { value: "Renamed" },
});
const request = await saveRequest();
expect(request).not.toHaveProperty("tool_groups");
expect(request).not.toHaveProperty("knowledge_scope");
});
it("preserves an untouched default when other groups change", async () => {
renderSettings(agent);
fireEvent.click(screen.getByLabelText("web"));
const request = await saveRequest();
expect(request.tool_groups).toEqual(["knowledge"]);
expect(request).not.toHaveProperty("knowledge_scope");
});
it.each(["knowledge", "Use every configured tool group"])(
"requires saved access before choosing knowledge after enabling %s",
async (label) => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(
Response.json({ items: [], page: 1, page_size: 100, total: 0 }),
);
renderSettings({ ...agent, tool_groups: ["web"], knowledge_scope: null });
fireEvent.click(screen.getByLabelText(label));
const trigger = screen.getByTestId("knowledge-scope-trigger");
expect((trigger as HTMLButtonElement).disabled).toBe(true);
expect(
screen.getByText(enUS.agents.settingsKnowledgeSaveToolAccess),
).toBeTruthy();
fireEvent.click(trigger);
expect(fetch).not.toHaveBeenCalled();
expect((await saveRequest()).tool_groups).toEqual(
label === "knowledge" ? ["web", "knowledge"] : null,
);
},
);
it("loads the catalog after knowledge access is persisted", async () => {
const fetch = rs.spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve(
Response.json({
items: [{ id: "policies", name: "Policies", selectable: true }],
page: 1,
page_size: 100,
total: 1,
}),
),
);
const { rerender } = renderSettings({
...agent,
tool_groups: ["web"],
knowledge_scope: null,
});
fireEvent.click(screen.getByLabelText("knowledge"));
rerender(
<AgentSettingsDialog
agent={{ ...agent, knowledge_scope: null }}
open
onOpenChange={rs.fn()}
/>,
);
const trigger = screen.getByTestId("knowledge-scope-trigger");
expect((trigger as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(trigger);
fireEvent.click(
screen.getByRole("radio", { name: "Selected knowledge bases" }),
);
await screen.findByText("Policies");
expect(fetch).toHaveBeenCalled();
fireEvent.click(screen.getByLabelText("Policies"));
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect((await saveRequest()).knowledge_scope).toMatchObject({
mode: "selected",
dataset_ids: ["policies"],
});
});
});