fix(memory): preserve agent id in tool inputs

This commit is contained in:
matevip 2026-08-13 03:45:57 -04:00
parent 49d31d0847
commit 06105faa4d
4 changed files with 117 additions and 32 deletions

View File

@ -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<Map<String, String>> 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 必须是数字字符串");
}
}
}

View File

@ -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<WorkspaceFileEntity> files = workspaceFileService.listVisibleFiles(agentId, readOwner(toolContext)).stream()
List<WorkspaceFileEntity> 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全部/ memoryMEMORY.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<MemorySearchHit> 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;

View File

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

View File

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