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
* 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());
}
}

View File

@ -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<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
* <p>

View File

@ -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<String, DisclosureTier> builtinTierByName = new LinkedHashMap<>();
AgentToolSet globalSet = null;
Map<String, Set<String>> 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<String> aliases = new LinkedHashSet<>();
aliases.add(t.getName());
if (t.getBeanName() != null && !t.getBeanName().isBlank()) {
aliases.add(t.getBeanName());
Set<String> aliases = new LinkedHashSet<>();
aliases.add(t.getName());
if (t.getBeanName() != null && !t.getBeanName().isBlank()) {
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);
}
}
@ -314,9 +319,13 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
Map<String, Long> 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<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,
Map<String, Long> mcpToolToServerId,
Map<Long, DisclosureTier> serverTierById,

View File

@ -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));
}
/**

View File

@ -3,14 +3,17 @@ package vip.mate.workspace.document.event;
/**
* Published whenever an agent's workspace file is created, updated, or deleted.
* <p>
* 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);
}
}

View File

@ -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<org.springframework.ai.tool.ToolCallback> cbs = new ArrayList<>();
cbs.addAll(List.of(ToolCallbacks.from(t1)));
cbs.addAll(List.of(ToolCallbacks.from(t2)));
Map<Object, String> beanNames = Map.of(t1, "tools", t2, "imageGenerateTool");
return AgentToolSet.fromCallbacks(List.of(t1, t2), cbs, beanNames::get);
private static Map<String, Set<String>> 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<ToolEntity> tools,
List<McpServerEntity> servers,
List<AvailableToolDTO> available) {
List<McpServerEntity> 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"));

View File

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