mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
9a0ea6abf7
commit
65fa321ec1
@ -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`,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
103
backend/tests/test_agent_tool_groups_router.py
Normal file
103
backend/tests/test_agent_tool_groups_router.py
Normal 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"}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "温度",
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user