fix(memory): preserve remaining agent id tool inputs

This commit is contained in:
matevip 2026-08-13 03:53:27 -04:00
parent cabff7e498
commit b1f3332ae1
6 changed files with 177 additions and 11 deletions

View File

@ -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<FactEntity> facts = queryService.probe(agentId, entity);
Long parsedAgentId = parseAgentId(agentId);
List<FactEntity> 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<FactContradictionEntity> contradictions = queryService.listContradictions(agentId);
Long parsedAgentId = parseAgentId(agentId);
List<FactContradictionEntity> 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");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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