From 06105faa4dda31301fd1bf17ef1e906b9eeaae39 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 13 Aug 2026 03:45:57 -0400 Subject: [PATCH] fix(memory): preserve agent id in tool inputs --- .../memory/tool/StructuredMemoryTool.java | 27 ++++++-- .../tool/builtin/WorkspaceMemoryTool.java | 61 +++++++++++-------- ...ructuredMemoryToolIdSerializationTest.java | 30 ++++++++- ...orkspaceMemoryToolIdSerializationTest.java | 31 +++++++++- 4 files changed, 117 insertions(+), 32 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java index 24d638ea..a04f726f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/tool/StructuredMemoryTool.java @@ -60,7 +60,7 @@ public class StructuredMemoryTool { key 用 snake_case 标识符,例如 preferred_language, no_mock_db """) public String remember_structured( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "记忆类型:user / feedback / project / reference") String type, @ToolParam(description = "条目标识符(snake_case),例如 preferred_language") String key, @ToolParam(description = "条目内容") String content, @@ -71,7 +71,8 @@ public class StructuredMemoryTool { } try { - structuredMemoryService.remember(agentId, type.trim().toLowerCase(), + Long parsedAgentId = parseAgentId(agentId); + structuredMemoryService.remember(parsedAgentId, type.trim().toLowerCase(), key.trim(), content.trim(), "agent", writeOwner(toolContext)); JSONObject result = new JSONObject(); @@ -94,7 +95,7 @@ public class StructuredMemoryTool { type 为空时搜索所有类型。 """) public String recall_structured( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "记忆类型过滤(可选):user / feedback / project / reference", required = false) String type, @ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword, ToolContext toolContext) { @@ -104,8 +105,9 @@ public class StructuredMemoryTool { } try { + Long parsedAgentId = parseAgentId(agentId); List> results = structuredMemoryService.recall( - agentId, + parsedAgentId, type != null && !type.isBlank() ? type.trim().toLowerCase() : null, keyword, readOwner(toolContext)); @@ -128,7 +130,7 @@ public class StructuredMemoryTool { 需要指定类型和 key。 """) public String forget_structured( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "记忆类型:user / feedback / project / reference") String type, @ToolParam(description = "要删除的条目标识符") String key, ToolContext toolContext) { @@ -138,7 +140,8 @@ public class StructuredMemoryTool { } try { - boolean removed = structuredMemoryService.forget(agentId, + Long parsedAgentId = parseAgentId(agentId); + boolean removed = structuredMemoryService.forget(parsedAgentId, type.trim().toLowerCase(), key.trim(), writeOwner(toolContext)); JSONObject result = new JSONObject(); @@ -159,4 +162,16 @@ public class StructuredMemoryTool { result.set("message", message); return JSONUtil.toJsonPrettyStr(result); } + + private Long parseAgentId(String agentId) { + String trimmed = agentId != null ? agentId.trim() : ""; + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("agentId 不能为空"); + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("agentId 必须是数字字符串"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index d46eae5a..bf3d88fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -60,15 +60,14 @@ public class WorkspaceMemoryTool { 返回结构化 JSON,包括文件名、是否启用为系统提示词、更新时间和大小。 """) public String list_workspace_memory_files( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix, ToolContext toolContext) { - if (agentId == null) { - return error("agentId 不能为空"); - } + Long parsedAgentId = parseAgentIdOrNull(agentId); + if (parsedAgentId == null) return error("agentId 不能为空"); - List files = workspaceFileService.listVisibleFiles(agentId, readOwner(toolContext)).stream() + List files = workspaceFileService.listVisibleFiles(parsedAgentId, readOwner(toolContext)).stream() .filter(file -> filenamePrefix == null || filenamePrefix.isBlank() || (file.getFilename() != null && file.getFilename().startsWith(filenamePrefix))) .sorted(Comparator @@ -99,23 +98,24 @@ public class WorkspaceMemoryTool { 返回结构化 JSON,包括文件名、是否启用、内容和字节数。 """) public String read_workspace_memory_file( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, ToolContext toolContext) { - String validation = validate(agentId, filename); + Long parsedAgentId = parseAgentIdOrNull(agentId); + String validation = validate(parsedAgentId, filename); if (validation != null) { return error(validation); } - WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, filename, readOwner(toolContext)); + WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, filename, readOwner(toolContext)); if (file == null) { return error("工作区文件不存在: " + filename); } // 追踪主动检索信号(比被动注入更强的"真实需要"指标) String content = file.getContent() != null ? file.getContent() : ""; - memoryRecallTracker.trackActiveRetrieval(agentId, filename, content); + memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content); JSONObject result = new JSONObject(); result.set("agentId", String.valueOf(agentId)); @@ -139,19 +139,20 @@ public class WorkspaceMemoryTool { 不会出现在管理页的共享文件列表);TEAM 表示所有使用该 Agent 的用户共享的文件。向用户说明写入结果时请如实区分。 """) public String write_workspace_memory_file( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, @ToolParam(description = "要写入的完整 Markdown 内容") String content, ToolContext toolContext) { - String validation = validate(agentId, filename); + Long parsedAgentId = parseAgentIdOrNull(agentId); + String validation = validate(parsedAgentId, filename); if (validation != null) { return error(validation); } String ownerKey = writeOwner(toolContext); - WorkspaceFileEntity before = workspaceFileService.getVisibleFile(agentId, filename, ownerKey); - WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, content != null ? content : "", ownerKey); + WorkspaceFileEntity before = workspaceFileService.getVisibleFile(parsedAgentId, filename, ownerKey); + WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(parsedAgentId, filename, content != null ? content : "", ownerKey); JSONObject result = new JSONObject(); result.set("agentId", String.valueOf(agentId)); @@ -175,14 +176,15 @@ public class WorkspaceMemoryTool { 默认只替换第一处匹配,replaceAll=true 时替换全部。 """) public String edit_workspace_memory_file( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename, @ToolParam(description = "要查找的原始文本,要求精确匹配") String oldText, @ToolParam(description = "替换后的新文本") String newText, @ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll, ToolContext toolContext) { - String validation = validate(agentId, filename); + Long parsedAgentId = parseAgentIdOrNull(agentId); + String validation = validate(parsedAgentId, filename); if (validation != null) { return error(validation); } @@ -197,7 +199,7 @@ public class WorkspaceMemoryTool { } String ownerKey = writeOwner(toolContext); - WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, filename, ownerKey); + WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(parsedAgentId, filename, ownerKey); if (existing == null) { return error("工作区文件不存在: " + filename); } @@ -219,7 +221,7 @@ public class WorkspaceMemoryTool { replacements = 1; } - WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); + WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(parsedAgentId, filename, updated, ownerKey); JSONObject result = new JSONObject(); result.set("agentId", String.valueOf(agentId)); @@ -241,16 +243,15 @@ public class WorkspaceMemoryTool { many memory entries. Returns ranked hits with filename, line number, and snippet \ (matched terms wrapped in [[...]]).""") public String search_workspace_memory( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "关键词或短语,2-64 字符") String query, @ToolParam(description = "搜索范围:all(全部)/ memory(MEMORY.md 与 memory/)/ profile / persona,默认 all", required = false) String scope, @ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit, ToolContext toolContext) { - if (agentId == null) { - return error("agentId 不能为空"); - } + Long parsedAgentId = parseAgentIdOrNull(agentId); + if (parsedAgentId == null) return error("agentId 不能为空"); if (query == null || query.isBlank()) { return error("query 不能为空"); } @@ -269,7 +270,7 @@ public class WorkspaceMemoryTool { // plus this owner's PERSONAL memory only. String ownerKey = readOwner(toolContext); List hits = workspaceFileService.searchSnippets( - agentId, trimmed, prefixes, effectiveLimit, ownerKey); + parsedAgentId, trimmed, prefixes, effectiveLimit, ownerKey); // Treat each unique file in the results as an active retrieval signal — // boosts that file's weight in the dream-consolidation ranker the same @@ -279,9 +280,9 @@ public class WorkspaceMemoryTool { if (retrieved.add(hit.filename())) { // Read the same visible row the hit came from (the owner's // PERSONAL row when present) so PERSONAL hits track correctly. - WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, hit.filename(), ownerKey); + WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, hit.filename(), ownerKey); if (file != null && file.getContent() != null) { - memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent()); + memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent()); } } } @@ -351,6 +352,18 @@ public class WorkspaceMemoryTool { return null; } + private Long parseAgentIdOrNull(String agentId) { + String trimmed = agentId != null ? agentId.trim() : ""; + if (trimmed.isEmpty()) { + return null; + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("agentId 必须是数字字符串"); + } + } + private int countOccurrences(String text, String target) { int count = 0; int idx = 0; diff --git a/mateclaw-server/src/test/java/vip/mate/memory/tool/StructuredMemoryToolIdSerializationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/tool/StructuredMemoryToolIdSerializationTest.java index 8fa66cdf..c6db3c01 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/tool/StructuredMemoryToolIdSerializationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/tool/StructuredMemoryToolIdSerializationTest.java @@ -2,6 +2,10 @@ package vip.mate.memory.tool; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import vip.mate.memory.MemoryProperties; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.StructuredMemoryService; @@ -14,6 +18,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class StructuredMemoryToolIdSerializationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); @Test @DisplayName("recall_structured returns agentId as a JSON string to preserve snowflake precision") @@ -26,9 +31,32 @@ class StructuredMemoryToolIdSerializationTest { new MemoryOwnerResolver(), new MemoryProperties()); - String json = tool.recall_structured(2079862124134313986L, "reference", "meeting", null); + String json = tool.recall_structured("2079862124134313986", "reference", "meeting", null); assertThat(json).contains("\"agentId\": \"2079862124134313986\""); assertThat(json).doesNotContain("\"agentId\": 2079862124134313986"); } + + @Test + @DisplayName("recall_structured publishes agentId as a string parameter so LLM tool calls preserve precision") + void recallStructuredAgentIdSchemaIsString() throws Exception { + StructuredMemoryTool tool = new StructuredMemoryTool( + mock(StructuredMemoryService.class), + new MemoryOwnerResolver(), + new MemoryProperties()); + + String schema = callback(tool, "recall_structured").getToolDefinition().inputSchema(); + JsonNode root = MAPPER.readTree(schema); + + assertThat(root.at("/properties/agentId/type").asText()).isEqualTo("string"); + } + + private static ToolCallback callback(Object tool, String name) { + for (ToolCallback callback : ToolCallbacks.from(tool)) { + if (name.equals(callback.getToolDefinition().name())) { + return callback; + } + } + throw new AssertionError("Missing tool callback: " + name); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/WorkspaceMemoryToolIdSerializationTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/WorkspaceMemoryToolIdSerializationTest.java index d1e052dd..1b907de4 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/WorkspaceMemoryToolIdSerializationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/WorkspaceMemoryToolIdSerializationTest.java @@ -2,6 +2,10 @@ package vip.mate.tool.builtin; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import vip.mate.memory.MemoryProperties; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.MemoryRecallTracker; @@ -15,6 +19,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class WorkspaceMemoryToolIdSerializationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); @Test @DisplayName("search_workspace_memory returns agentId as a JSON string to preserve snowflake precision") @@ -28,9 +33,33 @@ class WorkspaceMemoryToolIdSerializationTest { new MemoryOwnerResolver(), new MemoryProperties()); - String json = tool.search_workspace_memory(2079862124134313986L, "meeting", "all", 10, null); + String json = tool.search_workspace_memory("2079862124134313986", "meeting", "all", 10, null); assertThat(json).contains("\"agentId\": \"2079862124134313986\""); assertThat(json).doesNotContain("\"agentId\": 2079862124134313986"); } + + @Test + @DisplayName("search_workspace_memory publishes agentId as a string parameter so LLM tool calls preserve precision") + void searchWorkspaceMemoryAgentIdSchemaIsString() throws Exception { + WorkspaceMemoryTool tool = new WorkspaceMemoryTool( + mock(WorkspaceFileService.class), + mock(MemoryRecallTracker.class), + new MemoryOwnerResolver(), + new MemoryProperties()); + + String schema = callback(tool, "search_workspace_memory").getToolDefinition().inputSchema(); + JsonNode root = MAPPER.readTree(schema); + + assertThat(root.at("/properties/agentId/type").asText()).isEqualTo("string"); + } + + private static ToolCallback callback(Object tool, String name) { + for (ToolCallback callback : ToolCallbacks.from(tool)) { + if (name.equals(callback.getToolDefinition().name())) { + return callback; + } + } + throw new AssertionError("Missing tool callback: " + name); + } }