diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java index c2732ee7..96ee904a 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java @@ -27,12 +27,13 @@ public class FactQueryTool { @Tool(description = "Probe facts about an entity. Returns relevant facts where the entity appears as subject or object.") public String fact_probe( - @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Agent ID. Must be passed as a string to preserve large integer precision") String agentId, @ToolParam(description = "Entity name to search for") String entity) { if (!properties.getFact().isProjectionEnabled()) { return "Fact projection is disabled."; } - List facts = queryService.probe(agentId, entity); + Long parsedAgentId = parseAgentId(agentId); + List facts = queryService.probe(parsedAgentId, entity); if (facts.isEmpty()) return "No facts found for entity: " + entity; // Bump use count @@ -45,11 +46,12 @@ public class FactQueryTool { @Tool(description = "List unresolved fact contradictions detected during Dream consolidation.") public String fact_list_contradictions( - @ToolParam(description = "Agent ID") Long agentId) { + @ToolParam(description = "Agent ID. Must be passed as a string to preserve large integer precision") String agentId) { if (!properties.getFact().isProjectionEnabled()) { return "Fact projection is disabled."; } - List contradictions = queryService.listContradictions(agentId); + Long parsedAgentId = parseAgentId(agentId); + List contradictions = queryService.listContradictions(parsedAgentId); if (contradictions.isEmpty()) return "No unresolved contradictions."; return contradictions.stream() @@ -58,4 +60,16 @@ public class FactQueryTool { c.getDescription() != null ? c.getDescription() : "")) .collect(Collectors.joining("\n")); } + + private static Long parseAgentId(String agentId) { + String trimmed = agentId != null ? agentId.trim() : ""; + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("agentId is required"); + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("agentId must be a numeric string"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java index 4351735c..434ade95 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/tool/UniversalMemoryTool.java @@ -55,32 +55,32 @@ public class UniversalMemoryTool { 如果你需要记录的是结构化条目,优先用 remember_structured。 """) public String remember( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId, @ToolParam(description = "要记住的内容(自由形式)") String content, @ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source, ToolContext toolContext) { - if (agentId == null) return error("agentId 不能为空"); if (content == null || content.isBlank()) return error("content 不能为空"); try { + Long parsedAgentId = parseAgentId(agentId); // Write to the requester's PERSONAL MEMORY.md when per-owner isolation // is active; otherwise the shared file (so the note is not stranded // in an un-read PERSONAL row). String ownerKey = memoryProperties.isLifecycleMediatorEnabled() ? memoryOwnerResolver.resolve(ChatOrigin.from(toolContext)) : null; - WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, MEMORY_FILENAME, ownerKey); + WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(parsedAgentId, MEMORY_FILENAME, ownerKey); String existingContent = existing != null && existing.getContent() != null ? existing.getContent() : ""; String updated = appendLesson(existingContent, content, source); - workspaceFileService.saveVisibleFile(agentId, MEMORY_FILENAME, updated, ownerKey); + workspaceFileService.saveVisibleFile(parsedAgentId, MEMORY_FILENAME, updated, ownerKey); // RFC-090 §14.3 — universal remember() targets MEMORY.md (the // canonical file), so this IS a MemoryWriteEvent. Skill-local // lessons go through SkillLessonWrittenEvent instead and do // NOT touch this path. - eventPublisher.publishEvent(new MemoryWriteEvent(agentId, MEMORY_FILENAME, + eventPublisher.publishEvent(new MemoryWriteEvent(parsedAgentId, MEMORY_FILENAME, "remember", content)); JSONObject result = new JSONObject(); @@ -142,6 +142,18 @@ public class UniversalMemoryTool { return idx < 0 ? -1 : idx + 1; // position of '#' itself } + private static 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 必须是数字字符串"); + } + } + private static String error(String msg) { JSONObject e = new JSONObject(); e.set("success", false); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java index 8fe637a1..cb2b1db9 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsTool.java @@ -42,11 +42,17 @@ public class SkillLessonsTool { public String record_lesson( @ToolParam(description = "skill 的 slug(即 SKILL.md frontmatter 里的 name)") String skillName, @ToolParam(description = "要记录的经验内容") String lesson, - @ToolParam(description = "可选:当前 Agent 的 ID", required = false) Long agentId, + @ToolParam(description = "可选:当前 Agent 的 ID。传入时必须使用字符串,避免大整数精度丢失", required = false) String agentId, @ToolParam(description = "可选:当前对话 ID", required = false) String conversationId) { if (skillName == null || skillName.isBlank()) return error("skillName 不能为空"); if (lesson == null || lesson.isBlank()) return error("lesson 不能为空"); + Long parsedAgentId; + try { + parsedAgentId = parseOptionalAgentId(agentId); + } catch (IllegalArgumentException e) { + return error(e.getMessage()); + } ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() .filter(s -> s != null && skillName.equals(s.getName())) @@ -69,7 +75,7 @@ public class SkillLessonsTool { int max = manifest != null && manifest.getSelfEvolution() != null ? manifest.getSelfEvolution().getLessonsMaxEntries() : 0; - String lessonId = lessonsService.recordLesson(resolved, agentId, conversationId, + String lessonId = lessonsService.recordLesson(resolved, parsedAgentId, conversationId, lesson, max); if (lessonId == null) { return error("Lesson 记录失败:skill 可能仅存在于数据库(无 workspace 目录)。"); @@ -83,6 +89,18 @@ public class SkillLessonsTool { return JSONUtil.toJsonPrettyStr(result); } + private static Long parseOptionalAgentId(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 static String error(String msg) { JSONObject e = new JSONObject(); e.set("success", false); diff --git a/mateclaw-server/src/test/java/vip/mate/memory/fact/tool/FactQueryToolIdSchemaTest.java b/mateclaw-server/src/test/java/vip/mate/memory/fact/tool/FactQueryToolIdSchemaTest.java new file mode 100644 index 00000000..91f28f1d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/fact/tool/FactQueryToolIdSchemaTest.java @@ -0,0 +1,42 @@ +package vip.mate.memory.fact.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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 vip.mate.memory.MemoryProperties; +import vip.mate.memory.fact.query.FactQueryService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class FactQueryToolIdSchemaTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("fact tools publish agentId as a string parameter so LLM tool calls preserve precision") + void factToolAgentIdSchemasAreString() throws Exception { + FactQueryTool tool = new FactQueryTool(mock(FactQueryService.class), mock(MemoryProperties.class)); + + assertAgentIdIsString(tool, "fact_probe"); + assertAgentIdIsString(tool, "fact_list_contradictions"); + } + + private static void assertAgentIdIsString(Object tool, String name) throws Exception { + JsonNode root = MAPPER.readTree(callback(tool, name).getToolDefinition().inputSchema()); + + 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/memory/tool/UniversalMemoryToolIdSchemaTest.java b/mateclaw-server/src/test/java/vip/mate/memory/tool/UniversalMemoryToolIdSchemaTest.java new file mode 100644 index 00000000..ae2a8609 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/tool/UniversalMemoryToolIdSchemaTest.java @@ -0,0 +1,44 @@ +package vip.mate.memory.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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 vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryOwnerResolver; +import vip.mate.workspace.document.WorkspaceFileService; + +import org.springframework.context.ApplicationEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class UniversalMemoryToolIdSchemaTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("remember publishes agentId as a string parameter so LLM tool calls preserve precision") + void rememberAgentIdSchemaIsString() throws Exception { + UniversalMemoryTool tool = new UniversalMemoryTool( + mock(WorkspaceFileService.class), + mock(ApplicationEventPublisher.class), + mock(MemoryOwnerResolver.class), + mock(MemoryProperties.class)); + + JsonNode root = MAPPER.readTree(callback(tool, "remember").getToolDefinition().inputSchema()); + + 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/skill/lessons/SkillLessonsToolIdSchemaTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lessons/SkillLessonsToolIdSchemaTest.java new file mode 100644 index 00000000..66918ecb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lessons/SkillLessonsToolIdSchemaTest.java @@ -0,0 +1,36 @@ +package vip.mate.skill.lessons; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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 vip.mate.skill.runtime.SkillRuntimeService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class SkillLessonsToolIdSchemaTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("record_lesson publishes agentId as a string parameter so LLM tool calls preserve precision") + void recordLessonAgentIdSchemaIsString() throws Exception { + SkillLessonsTool tool = new SkillLessonsTool(mock(SkillRuntimeService.class), mock(SkillLessonsService.class)); + + JsonNode root = MAPPER.readTree(callback(tool, "record_lesson").getToolDefinition().inputSchema()); + + 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); + } +}