package com.xly.agent; import com.xly.service.EventProjectionService; import com.xly.service.LedgerService; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.memory.ChatMemory; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 事件账本上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(追加原子—— * 对话流与按钮端点并发写互不覆盖),{@link #messages()} 读取 * {@link EventProjectionService} 的四段式 token 记账投影。账本即唯一事实源,本类不持有任何会话状态。 * *

写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做 * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条 * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai) * 只由本类落账。 * *

tool_call 事件存**中立格式** {@code calls=[{id,name,args}]}(脱 LangChain4j 序列化耦合), * 旧版 {@code payload} 载荷投影层仍可读。 */ public class EventLogChatMemory implements ChatMemory { private static final int LLM_EVENT_WINDOW = 400; private final String convId; private final LedgerService log; private final EventProjectionService projection; private final AgentIdentity identity; private final boolean internalUserTurn; /** system prompt 每次由 provider 提供,不落账(保持账本纯业务事件)。 */ private volatile String systemText; /** * 本轮实际喂给模型的用户文本(原话 + 编排层附加的后缀)。账本只存原话; * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。 */ private volatile String currentTurnUserText; public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection, AgentIdentity identity, boolean internalUserTurn) { this.convId = convId; this.log = log; this.projection = projection; this.identity = identity; this.internalUserTurn = internalUserTurn; } @Override public Object id() { return convId; } @Override public void add(ChatMessage m) { if (m instanceof SystemMessage sm) { systemText = sm.text(); return; } if (m instanceof UserMessage um) { String text = um.hasSingleText() ? um.singleText() : String.valueOf(um.contents()); if (!internalUserTurn && alreadyLoggedPrefixOf(text)) { currentTurnUserText = text; return; } Map data = new LinkedHashMap<>(); data.put("text", text); if (internalUserTurn) { data.put("internal", true); } log.append(convId, "user", data, identity); return; } if (m instanceof AiMessage am) { if (am.hasToolExecutionRequests()) { Map data = new LinkedHashMap<>(); data.put("text", am.text() == null ? "" : am.text()); List> calls = new ArrayList<>(); List tools = new ArrayList<>(); for (ToolExecutionRequest r : am.toolExecutionRequests()) { Map c = new LinkedHashMap<>(); c.put("id", r.id() == null ? "" : r.id()); c.put("name", r.name() == null ? "" : r.name()); c.put("args", r.arguments() == null ? "" : r.arguments()); calls.add(c); tools.add(r.name()); } data.put("calls", calls); data.put("tools", tools); log.append(convId, "tool_call", data, identity); } else if (am.text() != null && !am.text().isBlank()) { log.append(convId, "ai", Map.of("text", am.text()), identity); } return; } if (m instanceof ToolExecutionResultMessage tr) { String text = tr.text() == null ? "" : tr.text(); Map data = new LinkedHashMap<>(); data.put("tcId", tr.id() == null ? "" : tr.id()); data.put("name", tr.toolName() == null ? "" : tr.toolName()); data.put("text", text); data.put("digest", digest(text)); log.append(convId, "tool_result", data, identity); } } /** * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件) * → 跳过,避免重复。编排层可能在原话后附加后缀,故用前缀而非全等判定。 */ private boolean alreadyLoggedPrefixOf(String text) { List> tail = log.events(convId, 6); for (int i = tail.size() - 1; i >= 0; i--) { String type = String.valueOf(tail.get(i).get("type")); switch (type) { case "user": String logged = String.valueOf(tail.get(i).get("text")); return !logged.isEmpty() && text.startsWith(logged); case "form_submit", "ai", "tool_call", "tool_result", "assistant": return false; default: // queued/confirm 等按钮事件可能插在中间,继续往前找 } } return false; } @Override public List messages() { List out = projection.project(systemText, log.events(convId, LLM_EVENT_WINDOW)); String full = currentTurnUserText; if (full != null) { // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀) for (int i = out.size() - 1; i >= 0; i--) { if (out.get(i) instanceof UserMessage u && u.hasSingleText() && full.startsWith(u.singleText())) { out.set(i, UserMessage.from(full)); break; } } } return out; } /** 账本是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */ @Override public void clear() { } private static String digest(String text) { String t = text.replace('\n', ' ').trim(); return t.length() > EventProjectionService.TOOL_DIGEST_LEN ? t.substring(0, EventProjectionService.TOOL_DIGEST_LEN) + "…" : t; } }