From 50c6eb92f92b18f9a489779f09a603eaaa48e94d Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 14 Aug 2026 04:34:51 -0400 Subject: [PATCH] fix(agent): refresh context previews without MCP hot-path scan --- .../java/vip/mate/agent/AgentService.java | 10 +-- .../main/java/vip/mate/tool/ToolRegistry.java | 61 +++++++++++++ .../DefaultToolDisclosureService.java | 72 +++++++++++---- .../document/WorkspaceFileService.java | 12 +-- .../event/WorkspaceFileChangedEvent.java | 19 ++-- .../disclosure/ToolDisclosureServiceTest.java | 89 +++++++++---------- .../document/WorkspaceMemorySearchTest.java | 16 ++++ 7 files changed, 192 insertions(+), 87 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 6955a0ed..08c4327c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -236,15 +236,13 @@ public class AgentService { } /** - * Invalidate the cached agent instance whenever one of its workspace files - * changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured - * memory) is baked into the cached instance at build time, so memory edits made - * via tools, consolidation, or cleanup would otherwise stay invisible until an - * agent config change or restart. Rebuilding on the next turn picks them up. + * Invalidate the cached agent instance only for shared workspace files that + * are baked into the system prompt. Owner-scoped PERSONAL memory rows are + * injected per turn, so updating them must not force a cold agent rebuild. */ @org.springframework.context.event.EventListener public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) { - if (event.agentId() != null) { + if (event.agentId() != null && event.affectsSystemPrompt()) { agentInstances.remove(event.agentId()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 67532885..b76e533c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -155,6 +156,66 @@ public class ToolRegistry { 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 enabledToolBeanFunctionNamesFor(Set aliases) { + if (aliases == null || aliases.isEmpty()) { + return Set.of(); + } + Map> index = enabledToolBeanFunctionNameIndex(); + LinkedHashSet out = new LinkedHashSet<>(); + for (String alias : aliases) { + Set 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> enabledToolBeanFunctionNameIndex() { + LinkedHashMap beansByName = getEnabledToolBeansByName(); + Map> index = new LinkedHashMap<>(); + for (Map.Entry entry : beansByName.entrySet()) { + String beanName = entry.getKey(); + Object bean = entry.getValue(); + ToolCallback[] callbacks = ToolCallbacks.from(bean); + LinkedHashSet 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> index, String alias, Set functionNames) { + if (alias == null || alias.isBlank() || functionNames == null || functionNames.isEmpty()) { + return; + } + index.computeIfAbsent(alias, ignored -> new LinkedHashSet<>()).addAll(functionNames); + } + /** * 获取统一的 AgentToolSet(包含 @Tool Bean + ToolCallbackProvider) *

diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java index bec6f365..34dcff5f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java @@ -1,5 +1,8 @@ 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.extern.slf4j.Slf4j; import org.springframework.ai.tool.ToolCallback; @@ -9,10 +12,9 @@ import vip.mate.agent.AgentToolSet; import vip.mate.agent.context.TokenEstimator; import vip.mate.tool.ToolRegistry; 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.model.AvailableToolDTO; import vip.mate.tool.model.ToolEntity; -import vip.mate.tool.service.AvailableToolService; import vip.mate.tool.service.ToolService; import java.util.ArrayList; @@ -54,7 +56,6 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { private final ToolService toolService; private final McpServerService mcpServerService; - private final AvailableToolService availableToolService; private final ToolRegistry toolRegistry; private final ToolUsageRecencyTracker usageRecencyTracker; @@ -277,15 +278,15 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { private Snapshot buildSnapshot() { // resolveTier() queries by the runtime function name (cb.getToolDefinition().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 - // tool set's alias index so a persisted tier actually reaches the runtime split. + // (e.g. "imageGenerateTool"). Bridge both onto the function name(s) via a built-in + // alias index so a persisted tier actually reaches the runtime split. Do not call + // ToolRegistry.getEnabledToolSet() here: it synchronously enumerates MCP providers. Map builtinTierByName = new LinkedHashMap<>(); - AgentToolSet globalSet = null; + Map> builtinFunctionIndex = Map.of(); try { - globalSet = toolRegistry.getEnabledToolSet(); + builtinFunctionIndex = toolRegistry.enabledToolBeanFunctionNameIndex(); } catch (Exception e) { - log.warn("ToolDisclosureService: global tool set unavailable, tier name bridge disabled: {}", - e.getMessage()); + log.warn("ToolDisclosureService: built-in tier name bridge unavailable: {}", e.getMessage()); } try { 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 // store a function name. builtinTierByName.put(t.getName(), tier); - if (globalSet != null) { - Set aliases = new LinkedHashSet<>(); - aliases.add(t.getName()); - if (t.getBeanName() != null && !t.getBeanName().isBlank()) { - aliases.add(t.getBeanName()); + Set aliases = new LinkedHashSet<>(); + aliases.add(t.getName()); + if (t.getBeanName() != null && !t.getBeanName().isBlank()) { + aliases.add(t.getBeanName()); + } + for (String alias : aliases) { + Set functionNames = builtinFunctionIndex.get(alias); + if (functionNames == null) { + continue; } - for (String functionName : globalSet.functionNamesFor(aliases)) { + for (String functionName : functionNames) { builtinTierByName.put(functionName, tier); } } @@ -314,9 +319,13 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { Map mcpToolToServerId = new LinkedHashMap<>(); try { - for (AvailableToolDTO d : availableToolService.listAvailable()) { - if ("mcp".equals(d.getSource()) && d.getName() != null && d.getProviderId() != null) { - mcpToolToServerId.put(d.getName(), d.getProviderId()); + for (McpServerEntity server : mcpServerService.listEnabled()) { + if (server == null || server.getId() == null || server.getToolsCacheJson() == null + || server.getToolsCacheJson().isBlank()) { + continue; + } + for (String toolName : cachedMcpToolNames(server)) { + mcpToolToServerId.put(toolName, server.getId()); } } } catch (Exception e) { @@ -346,6 +355,33 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { System.currentTimeMillis()); } + private List cachedMcpToolNames(McpServerEntity server) { + try { + JSONArray arr = JSONUtil.parseArray(server.getToolsCacheJson()); + List 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 builtinTierByName, Map mcpToolToServerId, Map serverTierById, diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java index 623ef124..b9cb5501 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java @@ -92,7 +92,7 @@ public class WorkspaceFileService { existing.setContent(content); existing.setFileSize(size); fileMapper.updateById(existing); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, true)); return existing; } WorkspaceFileEntity entity = new WorkspaceFileEntity(); @@ -110,7 +110,7 @@ public class WorkspaceFileService { entity.setOwnerKey(SHARED_OWNER_KEY); WorkspaceFileEntity saved = insertOrUpdateOnConflict( entity, () -> getFile(agentId, filename), content, size); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, true)); return saved; } @@ -255,7 +255,7 @@ public class WorkspaceFileService { existing.setContent(content); existing.setFileSize(size); fileMapper.updateById(existing); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false)); return existing; } WorkspaceFileEntity entity = new WorkspaceFileEntity(); @@ -269,7 +269,7 @@ public class WorkspaceFileService { entity.setScope(MemoryScope.PERSONAL); WorkspaceFileEntity saved = insertOrUpdateOnConflict( entity, () -> getMemoryFile(agentId, filename, ownerKey), content, size); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false)); return saved; } @@ -288,7 +288,7 @@ public class WorkspaceFileService { .eq(WorkspaceFileEntity::getAgentId, agentId) .eq(WorkspaceFileEntity::getFilename, filename) .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::getScope, MemoryScope.PERSONAL) .eq(WorkspaceFileEntity::getOwnerKey, ownerKey)); - eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename)); + eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename, false)); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/event/WorkspaceFileChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/event/WorkspaceFileChangedEvent.java index ddaac410..869bb370 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/event/WorkspaceFileChangedEvent.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/event/WorkspaceFileChangedEvent.java @@ -3,14 +3,17 @@ package vip.mate.workspace.document.event; /** * Published whenever an agent's workspace file is created, updated, or deleted. *

- * Workspace files (AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, structured/*.md) - * are baked into the agent's system prompt when its runtime instance is built. - * Listeners use this to invalidate the cached agent instance so memory edits - * (tool writes, consolidation, cleanup) take effect on the next turn instead of - * only after an agent config change or restart. + * Shared workspace files (AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, ...) + * are baked into the cached agent system prompt. Owner-scoped PERSONAL memory + * rows are injected per turn instead, so they should not evict the agent cache. * - * @param agentId the affected agent - * @param filename the workspace file that changed + * @param agentId the affected agent + * @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); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java index f8d89278..13c102ac 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java @@ -10,10 +10,9 @@ import vip.mate.agent.AgentToolSet; import vip.mate.agent.context.TokenEstimator; import vip.mate.tool.ToolRegistry; 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.model.AvailableToolDTO; import vip.mate.tool.model.ToolEntity; -import vip.mate.tool.service.AvailableToolService; import vip.mate.tool.service.ToolService; import java.util.ArrayList; @@ -47,15 +46,14 @@ class ToolDisclosureServiceTest { public String image_generate() { return ""; } } - /** Global tool set the bridge resolves DB class/bean names against. */ - private static AgentToolSet globalSet() { - Object t1 = new Tools(); - Object t2 = new ImageGenerateTool(); - List cbs = new ArrayList<>(); - cbs.addAll(List.of(ToolCallbacks.from(t1))); - cbs.addAll(List.of(ToolCallbacks.from(t2))); - Map beanNames = Map.of(t1, "tools", t2, "imageGenerateTool"); - return AgentToolSet.fromCallbacks(List.of(t1, t2), cbs, beanNames::get); + private static Map> globalFunctionIndex() { + return Map.of( + "Tools", Set.of("image_generate", "my_core_tool"), + "tools", Set.of("image_generate", "my_core_tool"), + "ImageGenerateTool", Set.of("image_generate"), + "imageGenerateTool", Set.of("image_generate"), + "image_generate", Set.of("image_generate"), + "my_core_tool", Set.of("my_core_tool")); } private static ToolEntity toolRow(String name, String type, String tier) { @@ -71,31 +69,26 @@ class ToolDisclosureServiceTest { s.setId(id); s.setName(name); s.setDisclosureTier(tier); + s.setToolsCacheJson("[{\"name\":\"create_issue\",\"description\":\"create issue\"}]"); return s; } - private static AvailableToolDTO mcpDto(String name, Long serverId) { - return AvailableToolDTO.builder().source("mcp").providerId(serverId).name(name).build(); - } - private DefaultToolDisclosureService service(List tools, - List servers, - List available) { + List servers) { ToolService ts = mock(ToolService.class); McpServerService ms = mock(McpServerService.class); - AvailableToolService as = mock(AvailableToolService.class); ToolRegistry tr = mock(ToolRegistry.class); lenient().when(ts.listTools()).thenReturn(tools); + lenient().when(ms.listEnabled()).thenReturn(servers); lenient().when(ms.listAll()).thenReturn(servers); - lenient().when(as.listAvailable()).thenReturn(available); - lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); - return new DefaultToolDisclosureService(ts, ms, as, tr, new ToolUsageRecencyTracker()); + lenient().when(tr.enabledToolBeanFunctionNameIndex()).thenReturn(globalFunctionIndex()); + return new DefaultToolDisclosureService(ts, ms, tr, new ToolUsageRecencyTracker()); } @Test @DisplayName("skill and progressive bridge meta-tools are always core") 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("load_skill")); assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_search")); @@ -106,7 +99,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("generative tools default to extension even without a DB row") 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("browser_use")); } @@ -114,14 +107,14 @@ class ToolDisclosureServiceTest { @Test @DisplayName("unknown tools default to core (conservative)") 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")); } @Test @DisplayName("mate_tool.disclosure_tier overrides the code default") 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")); } @@ -129,25 +122,24 @@ class ToolDisclosureServiceTest { @DisplayName("DB tier stored by Java class name bridges to the runtime function name") void dbTierBridgesClassNameToFunctionName() { // 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")); // Admin un-hides it by setting the row to core; the DB value must win over // 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")); } @Test @DisplayName("MCP tool tier follows its owning server") void mcpFollowsServer() { - var extSvc = service(List.of(), List.of(server(7L, "github", "extension")), - List.of(mcpDto("mcp_github_create_issue", 7L))); - assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName("mcp_github_create_issue")); + String toolName = McpToolNameResolver.prefixedName(7L, "create_issue"); + var extSvc = service(List.of(), List.of(server(7L, "github", "extension"))); + assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName(toolName)); - var coreSvc = service(List.of(), List.of(server(7L, "github", "core")), - List.of(mcpDto("mcp_github_create_issue", 7L))); - assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName("mcp_github_create_issue")); + var coreSvc = service(List.of(), List.of(server(7L, "github", "core"))); + assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName(toolName)); } @Test @@ -155,16 +147,16 @@ class ToolDisclosureServiceTest { void mcpDefaultsExtensionWhenServerTierUnset() { // Move 5: MCP tools default to EXTENSION so they don't flood the // CORE tool list. Pre-Move-4 this returned CORE. - var svc = service(List.of(), List.of(server(7L, "github", null)), - List.of(mcpDto("mcp_github_create_issue", 7L))); - assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("mcp_github_create_issue"), + String toolName = McpToolNameResolver.prefixedName(7L, "create_issue"); + var svc = service(List.of(), List.of(server(7L, "github", null))); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName(toolName), "Move 5: MCP tools with no explicit tier must default to EXTENSION"); } @Test @DisplayName("split partitions into active (core + enabled) and the full extension catalog") 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()), List.of(ToolCallbacks.from(new Tools()))); @@ -181,7 +173,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("legacy mode advertises everything and renders no catalog") void legacyMode() { - var svc = service(List.of(), List.of(), List.of()); + var svc = service(List.of(), List.of()); ReflectionTestUtils.setField(svc, "disclosureMode", "legacy"); AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), List.of(ToolCallbacks.from(new Tools()))); @@ -196,7 +188,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("renderExtensionCatalog lists extension tools under a heading") 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()), List.of(ToolCallbacks.from(new Tools()))); String catalog = svc.renderExtensionCatalog(set, 8192); @@ -232,7 +224,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("no demotion when the core schemas fit the budget, or when budget is absent") void noDemotionWhenBudgetFits() { - var svc = service(List.of(), List.of(), List.of()); + var svc = service(List.of(), List.of()); AgentToolSet set = manyCoreSet(); assertTrue(svc.computeAutoDemotions(set, Integer.MAX_VALUE).isEmpty()); assertTrue(svc.computeAutoDemotions(set, null).isEmpty()); @@ -242,7 +234,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("tiny budget demotes every demotable tool, alphabetical when nothing was ever used") void tinyBudgetDemotesAll() { - var svc = service(List.of(), List.of(), List.of()); + var svc = service(List.of(), List.of()); var demoted = svc.computeAutoDemotions(manyCoreSet(), 1); assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted); } @@ -250,7 +242,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("budget one tool short demotes exactly the first never-used candidate") void partialDemotionTakesFirstCandidate() { - var svc = service(List.of(), List.of(), List.of()); + var svc = service(List.of(), List.of()); AgentToolSet set = manyCoreSet(); int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); var demoted = svc.computeAutoDemotions(set, coreTokens - 1); @@ -264,13 +256,12 @@ class ToolDisclosureServiceTest { tracker.recordUse("tool_a"); ToolService ts = mock(ToolService.class); McpServerService ms = mock(McpServerService.class); - AvailableToolService as = mock(AvailableToolService.class); ToolRegistry tr = mock(ToolRegistry.class); lenient().when(ts.listTools()).thenReturn(List.of()); + lenient().when(ms.listEnabled()).thenReturn(List.of()); lenient().when(ms.listAll()).thenReturn(List.of()); - lenient().when(as.listAvailable()).thenReturn(List.of()); - lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); - var svc = new DefaultToolDisclosureService(ts, ms, as, tr, tracker); + lenient().when(tr.enabledToolBeanFunctionNameIndex()).thenReturn(globalFunctionIndex()); + var svc = new DefaultToolDisclosureService(ts, ms, tr, tracker); AgentToolSet set = manyCoreSet(); int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); @@ -283,7 +274,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("hard schema ceiling may demote explicit core rows") 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); assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted); } @@ -291,7 +282,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("auto-demoted tools behave as extension in split and can be enabled back") void splitHonorsAutoDemotions() { - var svc = service(List.of(), List.of(), List.of()); + var svc = service(List.of(), List.of()); AgentToolSet set = manyCoreSet(); var split = svc.split(set, Set.of(), Set.of("tool_b")); @@ -305,7 +296,7 @@ class ToolDisclosureServiceTest { @Test @DisplayName("catalog rendering lists auto-demoted tools for discoverability") 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")); assertTrue(catalog.contains("tool_b")); assertTrue(catalog.contains("tool_call")); diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java index 8ba5225e..e2c8d692 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java @@ -68,6 +68,22 @@ class WorkspaceMemorySearchTest { org.mockito.Mockito.verify(eventPublisher).publishEvent(captor.capture()); assertThat(captor.getValue().agentId()).isEqualTo(1000000001L); 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 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 ----------