fix(agent): refresh context previews without MCP hot-path scan

This commit is contained in:
matevip 2026-08-14 04:34:51 -04:00
parent 04209c3f7f
commit 50c6eb92f9
7 changed files with 192 additions and 87 deletions

View File

@ -236,15 +236,13 @@ public class AgentService {
} }
/** /**
* Invalidate the cached agent instance whenever one of its workspace files * Invalidate the cached agent instance only for shared workspace files that
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured * are baked into the system prompt. Owner-scoped PERSONAL memory rows are
* memory) is baked into the cached instance at build time, so memory edits made * injected per turn, so updating them must not force a cold agent rebuild.
* via tools, consolidation, or cleanup would otherwise stay invisible until an
* agent config change or restart. Rebuilding on the next turn picks them up.
*/ */
@org.springframework.context.event.EventListener @org.springframework.context.event.EventListener
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) { public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
if (event.agentId() != null) { if (event.agentId() != null && event.affectsSystemPrompt()) {
agentInstances.remove(event.agentId()); agentInstances.remove(event.agentId());
} }
} }

View File

@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -155,6 +156,66 @@ public class ToolRegistry {
return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get); return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get);
} }
/**
* Resolve aliases for currently enabled built-in {@code @Tool} beans without
* touching {@link ToolCallbackProvider}s. This is intentionally narrower than
* {@link #getEnabledToolSet()}: disclosure-tier snapshots only need to bridge
* {@code mate_tool.name}/{@code bean_name} onto built-in function names, and
* calling providers here would synchronously enumerate MCP tools on the chat
* hot path.
*/
public Set<String> enabledToolBeanFunctionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
Map<String, Set<String>> index = enabledToolBeanFunctionNameIndex();
LinkedHashSet<String> out = new LinkedHashSet<>();
for (String alias : aliases) {
Set<String> hits = index.get(alias);
if (hits != null) {
out.addAll(hits);
}
}
return out;
}
/**
* Build {@code alias -> @Tool function names} for enabled built-in tool beans.
* The aliases mirror {@link AgentToolSet}: function name, Spring bean name,
* and Java simple class name. Provider/MCP callbacks are deliberately absent.
*/
public Map<String, Set<String>> enabledToolBeanFunctionNameIndex() {
LinkedHashMap<String, Object> beansByName = getEnabledToolBeansByName();
Map<String, Set<String>> index = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : beansByName.entrySet()) {
String beanName = entry.getKey();
Object bean = entry.getValue();
ToolCallback[] callbacks = ToolCallbacks.from(bean);
LinkedHashSet<String> functionNames = new LinkedHashSet<>();
for (ToolCallback cb : callbacks) {
if (cb != null && cb.getToolDefinition() != null) {
functionNames.add(cb.getToolDefinition().name());
}
}
if (functionNames.isEmpty()) {
continue;
}
putAlias(index, beanName, functionNames);
putAlias(index, bean.getClass().getSimpleName(), functionNames);
for (String functionName : functionNames) {
putAlias(index, functionName, Set.of(functionName));
}
}
return index;
}
private static void putAlias(Map<String, Set<String>> index, String alias, Set<String> functionNames) {
if (alias == null || alias.isBlank() || functionNames == null || functionNames.isEmpty()) {
return;
}
index.computeIfAbsent(alias, ignored -> new LinkedHashSet<>()).addAll(functionNames);
}
/** /**
* 获取统一的 AgentToolSet包含 @Tool Bean + ToolCallbackProvider * 获取统一的 AgentToolSet包含 @Tool Bean + ToolCallbackProvider
* <p> * <p>

View File

@ -1,5 +1,8 @@
package vip.mate.tool.disclosure; package vip.mate.tool.disclosure;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
@ -9,10 +12,9 @@ import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.TokenEstimator; import vip.mate.agent.context.TokenEstimator;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpHashCollisionDetector;
import vip.mate.tool.mcp.service.McpServerService; import vip.mate.tool.mcp.service.McpServerService;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.model.ToolEntity; import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.service.AvailableToolService;
import vip.mate.tool.service.ToolService; import vip.mate.tool.service.ToolService;
import java.util.ArrayList; import java.util.ArrayList;
@ -54,7 +56,6 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
private final ToolService toolService; private final ToolService toolService;
private final McpServerService mcpServerService; private final McpServerService mcpServerService;
private final AvailableToolService availableToolService;
private final ToolRegistry toolRegistry; private final ToolRegistry toolRegistry;
private final ToolUsageRecencyTracker usageRecencyTracker; private final ToolUsageRecencyTracker usageRecencyTracker;
@ -277,15 +278,15 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
private Snapshot buildSnapshot() { private Snapshot buildSnapshot() {
// resolveTier() queries by the runtime function name (cb.getToolDefinition().name()), // resolveTier() queries by the runtime function name (cb.getToolDefinition().name()),
// but mate_tool stores the Java class name (e.g. "ImageGenerateTool") and bean name // but mate_tool stores the Java class name (e.g. "ImageGenerateTool") and bean name
// (e.g. "imageGenerateTool"). Bridge both onto the function name(s) via the global // (e.g. "imageGenerateTool"). Bridge both onto the function name(s) via a built-in
// tool set's alias index so a persisted tier actually reaches the runtime split. // alias index so a persisted tier actually reaches the runtime split. Do not call
// ToolRegistry.getEnabledToolSet() here: it synchronously enumerates MCP providers.
Map<String, DisclosureTier> builtinTierByName = new LinkedHashMap<>(); Map<String, DisclosureTier> builtinTierByName = new LinkedHashMap<>();
AgentToolSet globalSet = null; Map<String, Set<String>> builtinFunctionIndex = Map.of();
try { try {
globalSet = toolRegistry.getEnabledToolSet(); builtinFunctionIndex = toolRegistry.enabledToolBeanFunctionNameIndex();
} catch (Exception e) { } catch (Exception e) {
log.warn("ToolDisclosureService: global tool set unavailable, tier name bridge disabled: {}", log.warn("ToolDisclosureService: built-in tier name bridge unavailable: {}", e.getMessage());
e.getMessage());
} }
try { try {
for (ToolEntity t : toolService.listTools()) { for (ToolEntity t : toolService.listTools()) {
@ -296,13 +297,17 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
// Key by the raw stored name too harmless, and covers rows that already // Key by the raw stored name too harmless, and covers rows that already
// store a function name. // store a function name.
builtinTierByName.put(t.getName(), tier); builtinTierByName.put(t.getName(), tier);
if (globalSet != null) { Set<String> aliases = new LinkedHashSet<>();
Set<String> aliases = new LinkedHashSet<>(); aliases.add(t.getName());
aliases.add(t.getName()); if (t.getBeanName() != null && !t.getBeanName().isBlank()) {
if (t.getBeanName() != null && !t.getBeanName().isBlank()) { aliases.add(t.getBeanName());
aliases.add(t.getBeanName()); }
for (String alias : aliases) {
Set<String> functionNames = builtinFunctionIndex.get(alias);
if (functionNames == null) {
continue;
} }
for (String functionName : globalSet.functionNamesFor(aliases)) { for (String functionName : functionNames) {
builtinTierByName.put(functionName, tier); builtinTierByName.put(functionName, tier);
} }
} }
@ -314,9 +319,13 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
Map<String, Long> mcpToolToServerId = new LinkedHashMap<>(); Map<String, Long> mcpToolToServerId = new LinkedHashMap<>();
try { try {
for (AvailableToolDTO d : availableToolService.listAvailable()) { for (McpServerEntity server : mcpServerService.listEnabled()) {
if ("mcp".equals(d.getSource()) && d.getName() != null && d.getProviderId() != null) { if (server == null || server.getId() == null || server.getToolsCacheJson() == null
mcpToolToServerId.put(d.getName(), d.getProviderId()); || server.getToolsCacheJson().isBlank()) {
continue;
}
for (String toolName : cachedMcpToolNames(server)) {
mcpToolToServerId.put(toolName, server.getId());
} }
} }
} catch (Exception e) { } catch (Exception e) {
@ -346,6 +355,33 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
System.currentTimeMillis()); System.currentTimeMillis());
} }
private List<String> cachedMcpToolNames(McpServerEntity server) {
try {
JSONArray arr = JSONUtil.parseArray(server.getToolsCacheJson());
List<String> rawNames = new ArrayList<>(arr.size());
for (Object o : arr) {
if (!(o instanceof JSONObject jo)) {
continue;
}
String name = jo.getStr("name");
if (name != null && !name.isBlank()) {
rawNames.add(name);
}
}
if (rawNames.isEmpty()) {
return List.of();
}
return McpHashCollisionDetector.classify(server.getId(), rawNames).stream()
.filter(McpHashCollisionDetector.Decision::bindable)
.map(McpHashCollisionDetector.Decision::prefixedName)
.toList();
} catch (Exception e) {
log.debug("ToolDisclosureService: failed to parse MCP tools cache for server {}: {}",
server.getId(), e.getMessage());
return List.of();
}
}
private record Snapshot(Map<String, DisclosureTier> builtinTierByName, private record Snapshot(Map<String, DisclosureTier> builtinTierByName,
Map<String, Long> mcpToolToServerId, Map<String, Long> mcpToolToServerId,
Map<Long, DisclosureTier> serverTierById, Map<Long, DisclosureTier> serverTierById,

View File

@ -92,7 +92,7 @@ public class WorkspaceFileService {
existing.setContent(content); existing.setContent(content);
existing.setFileSize(size); existing.setFileSize(size);
fileMapper.updateById(existing); fileMapper.updateById(existing);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, true));
return existing; return existing;
} }
WorkspaceFileEntity entity = new WorkspaceFileEntity(); WorkspaceFileEntity entity = new WorkspaceFileEntity();
@ -110,7 +110,7 @@ public class WorkspaceFileService {
entity.setOwnerKey(SHARED_OWNER_KEY); entity.setOwnerKey(SHARED_OWNER_KEY);
WorkspaceFileEntity saved = insertOrUpdateOnConflict( WorkspaceFileEntity saved = insertOrUpdateOnConflict(
entity, () -> getFile(agentId, filename), content, size); entity, () -> getFile(agentId, filename), content, size);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, true));
return saved; return saved;
} }
@ -255,7 +255,7 @@ public class WorkspaceFileService {
existing.setContent(content); existing.setContent(content);
existing.setFileSize(size); existing.setFileSize(size);
fileMapper.updateById(existing); fileMapper.updateById(existing);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false));
return existing; return existing;
} }
WorkspaceFileEntity entity = new WorkspaceFileEntity(); WorkspaceFileEntity entity = new WorkspaceFileEntity();
@ -269,7 +269,7 @@ public class WorkspaceFileService {
entity.setScope(MemoryScope.PERSONAL); entity.setScope(MemoryScope.PERSONAL);
WorkspaceFileEntity saved = insertOrUpdateOnConflict( WorkspaceFileEntity saved = insertOrUpdateOnConflict(
entity, () -> getMemoryFile(agentId, filename, ownerKey), content, size); entity, () -> getMemoryFile(agentId, filename, ownerKey), content, size);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false));
return saved; return saved;
} }
@ -288,7 +288,7 @@ public class WorkspaceFileService {
.eq(WorkspaceFileEntity::getAgentId, agentId) .eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getFilename, filename) .eq(WorkspaceFileEntity::getFilename, filename)
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)); .in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL));
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, true));
} }
/** /**
@ -306,7 +306,7 @@ public class WorkspaceFileService {
.eq(WorkspaceFileEntity::getFilename, filename) .eq(WorkspaceFileEntity::getFilename, filename)
.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)); .eq(WorkspaceFileEntity::getOwnerKey, ownerKey));
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false));
} }
/** /**

View File

@ -3,14 +3,17 @@ package vip.mate.workspace.document.event;
/** /**
* Published whenever an agent's workspace file is created, updated, or deleted. * Published whenever an agent's workspace file is created, updated, or deleted.
* <p> * <p>
* Workspace files (AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, structured/*.md) * Shared workspace files (AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, ...)
* are baked into the agent's system prompt when its runtime instance is built. * are baked into the cached agent system prompt. Owner-scoped PERSONAL memory
* Listeners use this to invalidate the cached agent instance so memory edits * rows are injected per turn instead, so they should not evict the agent cache.
* (tool writes, consolidation, cleanup) take effect on the next turn instead of
* only after an agent config change or restart.
* *
* @param agentId the affected agent * @param agentId the affected agent
* @param filename the workspace file that changed * @param filename the workspace file that changed
* @param affectsSystemPrompt whether cached agent instances must be rebuilt
*/ */
public record WorkspaceFileChangedEvent(Long agentId, String filename) { public record WorkspaceFileChangedEvent(Long agentId, String filename, boolean affectsSystemPrompt) {
public WorkspaceFileChangedEvent(Long agentId, String filename) {
this(agentId, filename, true);
}
} }

View File

@ -10,10 +10,9 @@ import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.TokenEstimator; import vip.mate.agent.context.TokenEstimator;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import vip.mate.tool.mcp.service.McpServerService; import vip.mate.tool.mcp.service.McpServerService;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.model.ToolEntity; import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.service.AvailableToolService;
import vip.mate.tool.service.ToolService; import vip.mate.tool.service.ToolService;
import java.util.ArrayList; import java.util.ArrayList;
@ -47,15 +46,14 @@ class ToolDisclosureServiceTest {
public String image_generate() { return ""; } public String image_generate() { return ""; }
} }
/** Global tool set the bridge resolves DB class/bean names against. */ private static Map<String, Set<String>> globalFunctionIndex() {
private static AgentToolSet globalSet() { return Map.of(
Object t1 = new Tools(); "Tools", Set.of("image_generate", "my_core_tool"),
Object t2 = new ImageGenerateTool(); "tools", Set.of("image_generate", "my_core_tool"),
List<org.springframework.ai.tool.ToolCallback> cbs = new ArrayList<>(); "ImageGenerateTool", Set.of("image_generate"),
cbs.addAll(List.of(ToolCallbacks.from(t1))); "imageGenerateTool", Set.of("image_generate"),
cbs.addAll(List.of(ToolCallbacks.from(t2))); "image_generate", Set.of("image_generate"),
Map<Object, String> beanNames = Map.of(t1, "tools", t2, "imageGenerateTool"); "my_core_tool", Set.of("my_core_tool"));
return AgentToolSet.fromCallbacks(List.of(t1, t2), cbs, beanNames::get);
} }
private static ToolEntity toolRow(String name, String type, String tier) { private static ToolEntity toolRow(String name, String type, String tier) {
@ -71,31 +69,26 @@ class ToolDisclosureServiceTest {
s.setId(id); s.setId(id);
s.setName(name); s.setName(name);
s.setDisclosureTier(tier); s.setDisclosureTier(tier);
s.setToolsCacheJson("[{\"name\":\"create_issue\",\"description\":\"create issue\"}]");
return s; return s;
} }
private static AvailableToolDTO mcpDto(String name, Long serverId) {
return AvailableToolDTO.builder().source("mcp").providerId(serverId).name(name).build();
}
private DefaultToolDisclosureService service(List<ToolEntity> tools, private DefaultToolDisclosureService service(List<ToolEntity> tools,
List<McpServerEntity> servers, List<McpServerEntity> servers) {
List<AvailableToolDTO> available) {
ToolService ts = mock(ToolService.class); ToolService ts = mock(ToolService.class);
McpServerService ms = mock(McpServerService.class); McpServerService ms = mock(McpServerService.class);
AvailableToolService as = mock(AvailableToolService.class);
ToolRegistry tr = mock(ToolRegistry.class); ToolRegistry tr = mock(ToolRegistry.class);
lenient().when(ts.listTools()).thenReturn(tools); lenient().when(ts.listTools()).thenReturn(tools);
lenient().when(ms.listEnabled()).thenReturn(servers);
lenient().when(ms.listAll()).thenReturn(servers); lenient().when(ms.listAll()).thenReturn(servers);
lenient().when(as.listAvailable()).thenReturn(available); lenient().when(tr.enabledToolBeanFunctionNameIndex()).thenReturn(globalFunctionIndex());
lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); return new DefaultToolDisclosureService(ts, ms, tr, new ToolUsageRecencyTracker());
return new DefaultToolDisclosureService(ts, ms, as, tr, new ToolUsageRecencyTracker());
} }
@Test @Test
@DisplayName("skill and progressive bridge meta-tools are always core") @DisplayName("skill and progressive bridge meta-tools are always core")
void metaToolsAlwaysCore() { void metaToolsAlwaysCore() {
var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of(), List.of()); var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of());
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("enable_tool")); assertEquals(DisclosureTier.CORE, svc.resolveTierByName("enable_tool"));
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill")); assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill"));
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_search")); assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_search"));
@ -106,7 +99,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("generative tools default to extension even without a DB row") @DisplayName("generative tools default to extension even without a DB row")
void generativeDefaultsExtension() { void generativeDefaultsExtension() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("image_generate")); assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("image_generate"));
assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("browser_use")); assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("browser_use"));
} }
@ -114,14 +107,14 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("unknown tools default to core (conservative)") @DisplayName("unknown tools default to core (conservative)")
void unknownDefaultsCore() { void unknownDefaultsCore() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("memory_recall")); assertEquals(DisclosureTier.CORE, svc.resolveTierByName("memory_recall"));
} }
@Test @Test
@DisplayName("mate_tool.disclosure_tier overrides the code default") @DisplayName("mate_tool.disclosure_tier overrides the code default")
void dbRowOverrides() { void dbRowOverrides() {
var svc = service(List.of(toolRow("my_core_tool", "builtin", "extension")), List.of(), List.of()); var svc = service(List.of(toolRow("my_core_tool", "builtin", "extension")), List.of());
assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("my_core_tool")); assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("my_core_tool"));
} }
@ -129,25 +122,24 @@ class ToolDisclosureServiceTest {
@DisplayName("DB tier stored by Java class name bridges to the runtime function name") @DisplayName("DB tier stored by Java class name bridges to the runtime function name")
void dbTierBridgesClassNameToFunctionName() { void dbTierBridgesClassNameToFunctionName() {
// mate_tool.name = class name; resolveTier is queried by function name. // mate_tool.name = class name; resolveTier is queried by function name.
var hidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "extension")), List.of(), List.of()); var hidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "extension")), List.of());
assertEquals(DisclosureTier.EXTENSION, hidden.resolveTierByName("image_generate")); assertEquals(DisclosureTier.EXTENSION, hidden.resolveTierByName("image_generate"));
// Admin un-hides it by setting the row to core; the DB value must win over // Admin un-hides it by setting the row to core; the DB value must win over
// the code-level extension default. // the code-level extension default.
var unhidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "core")), List.of(), List.of()); var unhidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "core")), List.of());
assertEquals(DisclosureTier.CORE, unhidden.resolveTierByName("image_generate")); assertEquals(DisclosureTier.CORE, unhidden.resolveTierByName("image_generate"));
} }
@Test @Test
@DisplayName("MCP tool tier follows its owning server") @DisplayName("MCP tool tier follows its owning server")
void mcpFollowsServer() { void mcpFollowsServer() {
var extSvc = service(List.of(), List.of(server(7L, "github", "extension")), String toolName = McpToolNameResolver.prefixedName(7L, "create_issue");
List.of(mcpDto("mcp_github_create_issue", 7L))); var extSvc = service(List.of(), List.of(server(7L, "github", "extension")));
assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName("mcp_github_create_issue")); assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName(toolName));
var coreSvc = service(List.of(), List.of(server(7L, "github", "core")), var coreSvc = service(List.of(), List.of(server(7L, "github", "core")));
List.of(mcpDto("mcp_github_create_issue", 7L))); assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName(toolName));
assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName("mcp_github_create_issue"));
} }
@Test @Test
@ -155,16 +147,16 @@ class ToolDisclosureServiceTest {
void mcpDefaultsExtensionWhenServerTierUnset() { void mcpDefaultsExtensionWhenServerTierUnset() {
// Move 5: MCP tools default to EXTENSION so they don't flood the // Move 5: MCP tools default to EXTENSION so they don't flood the
// CORE tool list. Pre-Move-4 this returned CORE. // CORE tool list. Pre-Move-4 this returned CORE.
var svc = service(List.of(), List.of(server(7L, "github", null)), String toolName = McpToolNameResolver.prefixedName(7L, "create_issue");
List.of(mcpDto("mcp_github_create_issue", 7L))); var svc = service(List.of(), List.of(server(7L, "github", null)));
assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("mcp_github_create_issue"), assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName(toolName),
"Move 5: MCP tools with no explicit tier must default to EXTENSION"); "Move 5: MCP tools with no explicit tier must default to EXTENSION");
} }
@Test @Test
@DisplayName("split partitions into active (core + enabled) and the full extension catalog") @DisplayName("split partitions into active (core + enabled) and the full extension catalog")
void splitPartitions() { void splitPartitions() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()),
List.of(ToolCallbacks.from(new Tools()))); List.of(ToolCallbacks.from(new Tools())));
@ -181,7 +173,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("legacy mode advertises everything and renders no catalog") @DisplayName("legacy mode advertises everything and renders no catalog")
void legacyMode() { void legacyMode() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
ReflectionTestUtils.setField(svc, "disclosureMode", "legacy"); ReflectionTestUtils.setField(svc, "disclosureMode", "legacy");
AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()),
List.of(ToolCallbacks.from(new Tools()))); List.of(ToolCallbacks.from(new Tools())));
@ -196,7 +188,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("renderExtensionCatalog lists extension tools under a heading") @DisplayName("renderExtensionCatalog lists extension tools under a heading")
void rendersCatalog() { void rendersCatalog() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()),
List.of(ToolCallbacks.from(new Tools()))); List.of(ToolCallbacks.from(new Tools())));
String catalog = svc.renderExtensionCatalog(set, 8192); String catalog = svc.renderExtensionCatalog(set, 8192);
@ -232,7 +224,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("no demotion when the core schemas fit the budget, or when budget is absent") @DisplayName("no demotion when the core schemas fit the budget, or when budget is absent")
void noDemotionWhenBudgetFits() { void noDemotionWhenBudgetFits() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
AgentToolSet set = manyCoreSet(); AgentToolSet set = manyCoreSet();
assertTrue(svc.computeAutoDemotions(set, Integer.MAX_VALUE).isEmpty()); assertTrue(svc.computeAutoDemotions(set, Integer.MAX_VALUE).isEmpty());
assertTrue(svc.computeAutoDemotions(set, null).isEmpty()); assertTrue(svc.computeAutoDemotions(set, null).isEmpty());
@ -242,7 +234,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("tiny budget demotes every demotable tool, alphabetical when nothing was ever used") @DisplayName("tiny budget demotes every demotable tool, alphabetical when nothing was ever used")
void tinyBudgetDemotesAll() { void tinyBudgetDemotesAll() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
var demoted = svc.computeAutoDemotions(manyCoreSet(), 1); var demoted = svc.computeAutoDemotions(manyCoreSet(), 1);
assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted); assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted);
} }
@ -250,7 +242,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("budget one tool short demotes exactly the first never-used candidate") @DisplayName("budget one tool short demotes exactly the first never-used candidate")
void partialDemotionTakesFirstCandidate() { void partialDemotionTakesFirstCandidate() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
AgentToolSet set = manyCoreSet(); AgentToolSet set = manyCoreSet();
int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks());
var demoted = svc.computeAutoDemotions(set, coreTokens - 1); var demoted = svc.computeAutoDemotions(set, coreTokens - 1);
@ -264,13 +256,12 @@ class ToolDisclosureServiceTest {
tracker.recordUse("tool_a"); tracker.recordUse("tool_a");
ToolService ts = mock(ToolService.class); ToolService ts = mock(ToolService.class);
McpServerService ms = mock(McpServerService.class); McpServerService ms = mock(McpServerService.class);
AvailableToolService as = mock(AvailableToolService.class);
ToolRegistry tr = mock(ToolRegistry.class); ToolRegistry tr = mock(ToolRegistry.class);
lenient().when(ts.listTools()).thenReturn(List.of()); lenient().when(ts.listTools()).thenReturn(List.of());
lenient().when(ms.listEnabled()).thenReturn(List.of());
lenient().when(ms.listAll()).thenReturn(List.of()); lenient().when(ms.listAll()).thenReturn(List.of());
lenient().when(as.listAvailable()).thenReturn(List.of()); lenient().when(tr.enabledToolBeanFunctionNameIndex()).thenReturn(globalFunctionIndex());
lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); var svc = new DefaultToolDisclosureService(ts, ms, tr, tracker);
var svc = new DefaultToolDisclosureService(ts, ms, as, tr, tracker);
AgentToolSet set = manyCoreSet(); AgentToolSet set = manyCoreSet();
int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks());
@ -283,7 +274,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("hard schema ceiling may demote explicit core rows") @DisplayName("hard schema ceiling may demote explicit core rows")
void explicitCoreStillFitsHardCeiling() { void explicitCoreStillFitsHardCeiling() {
var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of(), List.of()); var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of());
var demoted = svc.computeAutoDemotions(manyCoreSet(), 1); var demoted = svc.computeAutoDemotions(manyCoreSet(), 1);
assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted); assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted);
} }
@ -291,7 +282,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("auto-demoted tools behave as extension in split and can be enabled back") @DisplayName("auto-demoted tools behave as extension in split and can be enabled back")
void splitHonorsAutoDemotions() { void splitHonorsAutoDemotions() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
AgentToolSet set = manyCoreSet(); AgentToolSet set = manyCoreSet();
var split = svc.split(set, Set.of(), Set.of("tool_b")); var split = svc.split(set, Set.of(), Set.of("tool_b"));
@ -305,7 +296,7 @@ class ToolDisclosureServiceTest {
@Test @Test
@DisplayName("catalog rendering lists auto-demoted tools for discoverability") @DisplayName("catalog rendering lists auto-demoted tools for discoverability")
void catalogListsAutoDemoted() { void catalogListsAutoDemoted() {
var svc = service(List.of(), List.of(), List.of()); var svc = service(List.of(), List.of());
String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b")); String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b"));
assertTrue(catalog.contains("tool_b")); assertTrue(catalog.contains("tool_b"));
assertTrue(catalog.contains("tool_call")); assertTrue(catalog.contains("tool_call"));

View File

@ -68,6 +68,22 @@ class WorkspaceMemorySearchTest {
org.mockito.Mockito.verify(eventPublisher).publishEvent(captor.capture()); org.mockito.Mockito.verify(eventPublisher).publishEvent(captor.capture());
assertThat(captor.getValue().agentId()).isEqualTo(1000000001L); assertThat(captor.getValue().agentId()).isEqualTo(1000000001L);
assertThat(captor.getValue().filename()).isEqualTo("MEMORY.md"); assertThat(captor.getValue().filename()).isEqualTo("MEMORY.md");
assertThat(captor.getValue().affectsSystemPrompt()).isTrue();
}
@Test
@DisplayName("saveMemoryFile publishes a non-system-prompt change event")
void saveMemoryFilePublishesNonInvalidatingChangeEvent() {
when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(null);
service.saveMemoryFile(1000000001L, "memory/2026-08-14.md", "## 今日\n- 临时笔记", "web:admin");
ArgumentCaptor<vip.mate.workspace.document.event.WorkspaceFileChangedEvent> captor =
ArgumentCaptor.forClass(vip.mate.workspace.document.event.WorkspaceFileChangedEvent.class);
org.mockito.Mockito.verify(eventPublisher).publishEvent(captor.capture());
assertThat(captor.getValue().agentId()).isEqualTo(1000000001L);
assertThat(captor.getValue().filename()).isEqualTo("memory/2026-08-14.md");
assertThat(captor.getValue().affectsSystemPrompt()).isFalse();
} }
// ---------- tokenize ---------- // ---------- tokenize ----------