mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
fix(agents): honor explicit tool opt-out for general capabilities (#655)
This commit is contained in:
parent
adc350207e
commit
9f49637ac9
@ -506,7 +506,8 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* <p>Auto-included on every non-null result, in addition to the bound
|
||||
* tools and skill-expanded tools:
|
||||
* <ul>
|
||||
* <li>{@link #SYSTEM_LEVEL_TOOLS} — agent-wide primitives.</li>
|
||||
* <li>{@link #MINIMAL_SYSTEM_TOOLS} for explicit tools opt-out; otherwise
|
||||
* {@link #SYSTEM_LEVEL_TOOLS} for compatibility with skill bindings.</li>
|
||||
* <li>Every currently-bindable MCP tool ({@code source="mcp"},
|
||||
* {@code available=true} in the picker) — but only when the agent
|
||||
* has not ticked any MCP tool itself. MCP servers are
|
||||
@ -569,14 +570,15 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
merged.addAll(directTools);
|
||||
}
|
||||
|
||||
// System-level tools that don't belong to any single skill but
|
||||
// are agent-wide capabilities — structured memory primitives,
|
||||
// workspace memory CRUD, etc. Without this carve-out, binding any
|
||||
// skill silently strips record_lesson / remember / *memory_file
|
||||
// tools, breaking the self-evolution loop. These survive even
|
||||
// toolsDisabled=true because they are agent-internal infrastructure,
|
||||
// unrelated to the user-facing capability picker.
|
||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||
// Binding a skill preserves the historical default capabilities, but an
|
||||
// explicit tools opt-out must not regain file/exec/network/delegation
|
||||
// tools through that compatibility list (issue #655).
|
||||
merged.addAll(toolsDisabled ? MINIMAL_SYSTEM_TOOLS : SYSTEM_LEVEL_TOOLS);
|
||||
if (toolsDisabled && !skillsDisabled) {
|
||||
// Skill opt-out is independent. Explicitly enabled skills may still
|
||||
// be discovered and contribute their declared tools above.
|
||||
merged.addAll(SKILL_DISCOVERY_TOOLS);
|
||||
}
|
||||
|
||||
// MCP tools. An agent that bound only a skill or a built-in tool
|
||||
// and ticked no MCP row normally keeps full access to every enabled
|
||||
@ -657,13 +659,25 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that exist outside the skill scope and must survive any
|
||||
* agent-level skill binding restriction.
|
||||
* Minimal internal capabilities retained by the explicit tools opt-out.
|
||||
* General file access, execution, network access and delegation are excluded.
|
||||
*
|
||||
* <p>Add new entries here only after verifying the tool is genuinely
|
||||
* agent-wide, not skill-specific. Tools added here bypass the
|
||||
* {@link #getEffectiveToolNames} allowlist completely.
|
||||
* required for internal state rather than a user-selectable capability.
|
||||
*/
|
||||
private static final Set<String> MINIMAL_SYSTEM_TOOLS = Set.of(
|
||||
"record_lesson", "remember", "remember_structured", "recall_structured", "forget_structured",
|
||||
"list_workspace_memory_files", "read_workspace_memory_file", "write_workspace_memory_file",
|
||||
"edit_workspace_memory_file", "search_workspace_memory",
|
||||
"getCurrentDate", "getCurrentDateTime", "getCurrentTime",
|
||||
"setGoal", "addGoalCriterion", "completeGoal", "getGoalStatus",
|
||||
"getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson",
|
||||
"waitForGoalInput", "resumeGoal", "progress_update",
|
||||
// These bridges resolve targets against the same effective allowlist.
|
||||
"enable_tool", "tool_search", "tool_describe", "tool_call"
|
||||
);
|
||||
|
||||
/** Compatibility defaults for skill-bound agents that have NOT disabled tools. */
|
||||
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
|
||||
// Structured memory primitives — used by every agent regardless
|
||||
// of skill bindings, otherwise the self-evolution path collapses
|
||||
|
||||
@ -574,12 +574,13 @@ class AgentBindingServiceTest {
|
||||
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
|
||||
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
|
||||
assertFalse(hasMcp, "两 flag 全开时 MCP 必须完全隐藏");
|
||||
// Sanity: the set should be roughly the SYSTEM_LEVEL_TOOLS list —
|
||||
// we don't enforce equality (the constant evolves) but it should be
|
||||
// substantially smaller than the catalog of every enabled tool.
|
||||
assertTrue(effective.size() < 100,
|
||||
"两 flag 全开时返回的应该只是 system-level 内核工具,体积明显小于完整默认集。"
|
||||
+ "实际大小: " + effective.size());
|
||||
for (String name : Set.of("execute_code", "execute_shell_command", "read_file", "append_file",
|
||||
"write_file", "edit_file", "web_search", "browser_use", "delegateToAgent", "delegateAsync",
|
||||
"renderDocx", "image_generate", "wiki_create_page")) {
|
||||
assertFalse(effective.contains(name), "Explicit opt-out must not restore " + name);
|
||||
}
|
||||
assertFalse(effective.contains("load_skill"));
|
||||
assertFalse(effective.contains("runSkillScript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
package vip.mate.agent.binding;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.tool.ToolRegistry;
|
||||
import vip.mate.tool.builtin.EnableExtensionTool;
|
||||
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||
import vip.mate.tool.disclosure.ToolDisclosureService;
|
||||
import vip.mate.tool.guard.service.ToolGuardConfigService;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AgentToolOptOutTest {
|
||||
private static final List<String> BUSINESS_TOOLS = List.of(
|
||||
"execute_code", "execute_shell_command", "read_file", "append_file", "write_file", "edit_file",
|
||||
"web_search", "browser_use", "send_file", "renderDocx", "image_generate", "music_generate",
|
||||
"video_generate", "render_html_image", "delegateToAgent", "delegateParallel", "delegateAsync",
|
||||
"wiki_create_page", "wiki_read_page", "mcp_test_tool");
|
||||
|
||||
private AgentBindingService service(boolean skillsDisabled) {
|
||||
AgentEntity entity = new AgentEntity();
|
||||
entity.setId(655L);
|
||||
entity.setToolsDisabled(true);
|
||||
entity.setSkillsDisabled(skillsDisabled);
|
||||
AgentMapper mapper = mock(AgentMapper.class);
|
||||
when(mapper.selectById(655L)).thenReturn(entity);
|
||||
AgentBindingService service = mock(AgentBindingService.class, CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(service, "agentMapper", mapper);
|
||||
if (!skillsDisabled) doReturn(Set.of(10L)).when(service).getBoundSkillIds(655L);
|
||||
return service;
|
||||
}
|
||||
|
||||
private AgentToolSet catalog() {
|
||||
var names = new java.util.ArrayList<>(BUSINESS_TOOLS);
|
||||
names.addAll(List.of("record_lesson", "getCurrentTime", "load_skill", "runSkillScript"));
|
||||
List<ToolCallback> callbacks = names.stream().map(name -> {
|
||||
ToolCallback callback = mock(ToolCallback.class);
|
||||
when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder()
|
||||
.name(name).description(name).inputSchema("{}").build());
|
||||
return callback;
|
||||
}).toList();
|
||||
return AgentToolSet.fromCallbacks(List.of(), callbacks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothDisabledRemovesBusinessAndSkillToolsFromRuntimeCallbacks() {
|
||||
AgentBindingService service = service(true);
|
||||
Set<String> allowed = service.getEffectiveToolNames(655L);
|
||||
for (String name : BUSINESS_TOOLS) assertFalse(allowed.contains(name), name);
|
||||
var filtered = catalog().withDeniedToolsFiltered(service.getSkillDiscoveryDeniedTools(655L))
|
||||
.withAllowedToolsOnly(allowed);
|
||||
assertEquals(Set.of("record_lesson", "getCurrentTime"), filtered.callbackByName().keySet());
|
||||
assertTrue(allowed.containsAll(Set.of("getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsOptOutDoesNotDisableSkillDiscoveryButDoesNotRestoreBusinessDefaults() {
|
||||
AgentBindingService service = service(false);
|
||||
Set<String> allowed = service.getEffectiveToolNames(655L);
|
||||
assertTrue(allowed.containsAll(Set.of("load_skill", "readSkillFile", "runSkillScript")));
|
||||
for (String name : BUSINESS_TOOLS) assertFalse(allowed.contains(name), name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledSkillContributesOnlyItsDeclaredToolsAfterOptOut() {
|
||||
AgentBindingService service = service(false);
|
||||
var runtime = mock(vip.mate.skill.runtime.SkillRuntimeService.class);
|
||||
var skill = mock(vip.mate.skill.runtime.model.ResolvedSkill.class);
|
||||
when(skill.getId()).thenReturn(10L);
|
||||
when(skill.isEnabled()).thenReturn(true);
|
||||
when(skill.isRuntimeAvailable()).thenReturn(true);
|
||||
when(skill.isDependencyReady()).thenReturn(true);
|
||||
when(skill.getEffectiveAllowedTools()).thenReturn(Set.of("read_file"));
|
||||
when(runtime.resolveAllSkillsStatus()).thenReturn(List.of(skill));
|
||||
ReflectionTestUtils.setField(service, "skillRuntimeService", runtime);
|
||||
Set<String> allowed = service.getEffectiveToolNames(655L);
|
||||
assertTrue(allowed.contains("read_file"));
|
||||
assertFalse(allowed.contains("execute_code"));
|
||||
assertFalse(allowed.contains("append_file"));
|
||||
when(skill.isEnabled()).thenReturn(false);
|
||||
assertFalse(service.getEffectiveToolNames(655L).contains("read_file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dynamicBridgesCannotDiscoverOrEnableDisabledTools() {
|
||||
AgentBindingService service = service(true);
|
||||
ToolRegistry registry = mock(ToolRegistry.class);
|
||||
AgentToolSet globalCatalog = catalog();
|
||||
when(registry.getEnabledToolSet()).thenReturn(globalCatalog);
|
||||
ToolGuardConfigService guard = mock(ToolGuardConfigService.class);
|
||||
when(guard.getDeniedTools()).thenReturn(Set.of());
|
||||
ToolContext context = vip.mate.agent.context.ChatOrigin.EMPTY.withAgent(655L).toToolContext();
|
||||
var bridge = new ProgressiveToolBridgeTool(registry, service, guard);
|
||||
String search = bridge.search(null, 20, context);
|
||||
for (String name : BUSINESS_TOOLS) assertFalse(search.contains(name), name);
|
||||
var enable = new EnableExtensionTool(registry, mock(ToolDisclosureService.class), service);
|
||||
for (String name : BUSINESS_TOOLS) {
|
||||
assertTrue(enable.enableTool(name, context).startsWith("Error:"), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1826,7 +1826,7 @@ export default {
|
||||
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
|
||||
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
|
||||
disableAllTools: 'This agent uses no user-pickable tools',
|
||||
disableAllToolsHint: 'Saving with this on clears the agent\'s tool bindings and marks it as "explicitly no tools": neither user-pickable tools nor any enabled MCP tools enter the allowlist. System-level primitives (structured memory, workspace memory files, delegation, etc.) remain available so the agent can still operate. When off, picking nothing still falls back to "inherit global default".',
|
||||
disableAllToolsHint: 'Saving clears tool bindings and stops automatically providing general file access, code or shell execution, network access, delegation, document generation and MCP tools. Only internal memory, time and task-state capabilities remain. Enabled skills can still contribute their declared tools; also turn on Disable all skills to disable those. When off, picking nothing inherits global defaults.',
|
||||
disableAllToolsBadge: 'Off',
|
||||
searchSkills: 'Search skill name, description, or version',
|
||||
searchTools: 'Search tool name, description, source, or group',
|
||||
|
||||
@ -1671,7 +1671,7 @@ export default {
|
||||
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
|
||||
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
||||
disableAllTools: '此智能体不使用任何用户可选工具',
|
||||
disableAllToolsHint: '开启后保存会清空该智能体的工具绑定,并标记为「显式无工具」:用户可选工具与已启用的 MCP 工具都不会进入 allowlist。系统级内核工具(结构化记忆、工作区记忆文件、委派等)仍保留以保证基本运行。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
|
||||
disableAllToolsHint: '开启后保存会清空工具绑定,不再自动提供通用文件读写、代码或命令执行、网络访问、委派、文档生成及 MCP 工具。仅保留记忆、时间和任务状态等内部能力。仍启用的技能可提供其声明的工具;如需一并禁用,请同时开启「禁用全部技能」。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
|
||||
disableAllToolsBadge: '已禁用',
|
||||
searchSkills: '搜索技能名称、描述或版本',
|
||||
searchTools: '搜索工具名称、描述、来源或分组',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user