From c99d26bdbc4d790216c23fbf77f2eca33be85036 Mon Sep 17 00:00:00 2001 From: rdehuyss Date: Tue, 24 Mar 2026 17:46:40 +0100 Subject: [PATCH 1/5] Add toolcalls to memory --- .../agent/memory/ChatYamlSerializer.java | 137 ++++++++-- .../advisor/MessageChatMemoryAdvisor.java | 64 ++++- .../agent/memory/ChatYamlSerializerTest.java | 240 ++++++++++++++++++ .../FileSystemChatMemoryRepositoryTest.java | 7 +- .../advisor/MessageChatMemoryAdvisorTest.java | 159 ++++++++++++ 5 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 base/src/test/java/ai/javaclaw/agent/memory/ChatYamlSerializerTest.java create mode 100644 base/src/test/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisorTest.java diff --git a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java b/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java index 12d0050b..32dd10d4 100644 --- a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java +++ b/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java @@ -4,6 +4,7 @@ import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; @@ -18,17 +19,34 @@ * Serialises and deserialises a list of Spring AI {@link Message} objects to/from * a YAML block-list string, for use as the body of a {@link ai.javaclaw.files.YamlDocument}. * - *

Format (one entry per message, role is the key): + *

Format: *

- * - user: |
- *     Question text
- * - assistant: |
- *     Answer text
+ * - role: user
+ *   content: "Question text"
+ * - role: assistant
+ *   tool_calls:
+ *     - id: call_123
+ *       type: function
+ *       function: get_weather
+ *       arguments: '{"location": "London"}'
+ * - role: tool
+ *   tool_call_id: call_123
+ *   name: get_weather
+ *   content: "Sunny, 20°C"
+ * - role: assistant
+ *   content: "Here is the weather..."
+ * 
+ * + *

Legacy format (one entry per message, role is the key) is still supported on read: + *

+ * - user: Question text
+ * - assistant: Answer text
  * 
*/ class ChatYamlSerializer { - private static final Set PERSISTABLE_MESSAGES = Set.of(MessageType.USER, MessageType.ASSISTANT, MessageType.SYSTEM); + private static final Set PERSISTABLE_MESSAGES = + Set.of(MessageType.USER, MessageType.ASSISTANT, MessageType.SYSTEM, MessageType.TOOL); private ChatYamlSerializer() {} @@ -37,26 +55,19 @@ static List deserialize(String body) { return List.of(); } Yaml yaml = new Yaml(); - List> entries = yaml.load(body); + List> entries = yaml.load(body); if (entries == null) { return List.of(); } return entries.stream() - .map(entry -> { - Map.Entry first = entry.entrySet().iterator().next(); - return toMessage(first.getKey(), first.getValue()); - }) + .map(ChatYamlSerializer::toMessage) .collect(Collectors.toList()); } static String serialize(List messages) { - List> entries = messages.stream() - .filter(msg -> PERSISTABLE_MESSAGES.contains(msg.getMessageType()) && msg.getText() != null) - .map(msg -> { - Map entry = new LinkedHashMap<>(); - entry.put(msg.getMessageType().getValue(), msg.getText()); - return entry; - }) + List> entries = messages.stream() + .filter(msg -> PERSISTABLE_MESSAGES.contains(msg.getMessageType())) + .flatMap(msg -> toEntries(msg).stream()) .collect(Collectors.toList()); DumperOptions options = new DumperOptions(); @@ -65,7 +76,95 @@ static String serialize(List messages) { return new Yaml(options).dump(entries); } - private static Message toMessage(String role, String content) { + private static List> toEntries(Message msg) { + if (msg instanceof AssistantMessage assistantMsg && assistantMsg.hasToolCalls()) { + Map entry = new LinkedHashMap<>(); + entry.put("role", "assistant"); + if (assistantMsg.getText() != null && !assistantMsg.getText().isBlank()) { + entry.put("content", assistantMsg.getText()); + } + List> toolCallList = assistantMsg.getToolCalls().stream() + .map(tc -> { + Map tcMap = new LinkedHashMap<>(); + tcMap.put("id", tc.id()); + tcMap.put("type", tc.type()); + tcMap.put("function", tc.name()); + tcMap.put("arguments", tc.arguments()); + return tcMap; + }) + .collect(Collectors.toList()); + entry.put("tool_calls", toolCallList); + return List.of(entry); + } + if (msg instanceof ToolResponseMessage toolMsg) { + return toolMsg.getResponses().stream() + .map(tr -> { + Map entry = new LinkedHashMap<>(); + entry.put("role", "tool"); + entry.put("tool_call_id", tr.id()); + entry.put("name", tr.name()); + entry.put("content", tr.responseData()); + return entry; + }) + .collect(Collectors.toList()); + } + // USER, ASSISTANT (text only), SYSTEM + if (msg.getText() == null) { + return List.of(); + } + Map entry = new LinkedHashMap<>(); + entry.put("role", msg.getMessageType().getValue()); + entry.put("content", msg.getText()); + return List.of(entry); + } + + private static Message toMessage(Map entry) { + // Support legacy format: {user: "text"} or {assistant: "text"} + if (!entry.containsKey("role")) { + Map.Entry first = entry.entrySet().iterator().next(); + return toLegacyMessage(first.getKey(), (String) first.getValue()); + } + String role = (String) entry.get("role"); + return switch (role) { + case "user" -> new UserMessage((String) entry.get("content")); + case "system" -> new SystemMessage((String) entry.get("content")); + case "assistant" -> toAssistantMessage(entry); + case "tool" -> toToolMessage(entry); + default -> throw new IllegalArgumentException("Unknown role in chat history: " + role); + }; + } + + @SuppressWarnings("unchecked") + private static AssistantMessage toAssistantMessage(Map entry) { + String content = (String) entry.get("content"); + List> rawToolCalls = (List>) entry.get("tool_calls"); + if (rawToolCalls == null || rawToolCalls.isEmpty()) { + return new AssistantMessage(content != null ? content : ""); + } + List toolCalls = rawToolCalls.stream() + .map(tc -> new AssistantMessage.ToolCall( + tc.get("id"), + tc.getOrDefault("type", "function"), + tc.get("function"), + tc.get("arguments"))) + .collect(Collectors.toList()); + return AssistantMessage.builder() + .content(content != null ? content : "") + .toolCalls(toolCalls) + .build(); + } + + private static ToolResponseMessage toToolMessage(Map entry) { + ToolResponseMessage.ToolResponse response = new ToolResponseMessage.ToolResponse( + (String) entry.get("tool_call_id"), + (String) entry.get("name"), + (String) entry.get("content")); + return ToolResponseMessage.builder() + .responses(List.of(response)) + .build(); + } + + private static Message toLegacyMessage(String role, String content) { return switch (role) { case "user" -> new UserMessage(content); case "assistant" -> new AssistantMessage(content); diff --git a/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java b/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java index 6d40fb51..44f72b65 100644 --- a/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java +++ b/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java @@ -9,8 +9,10 @@ import org.springframework.ai.chat.client.advisor.api.BaseChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain; import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -19,7 +21,9 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.SequencedSet; +import java.util.stream.Collectors; /** * A copy of Springs MessageChatMemoryAdvisor that does not add duplicate messages from memory if they are contained in the chatClientRequest.prompt().getInstructions() @@ -60,10 +64,8 @@ public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChai List instructions = chatClientRequest.prompt().getInstructions(); - // 1. Remove duplicated messages by means of LinkedHashSet - SequencedSet allMessages = new LinkedHashSet<>(this.chatMemory.get(conversationId)); - allMessages.addAll(instructions); - List processedMessages = new ArrayList<>(allMessages); + // 1. Remove duplicated messages based on content, ignoring metadata. + List processedMessages = deduplicate(this.chatMemory.get(conversationId), instructions); // 2.1. Ensure system message, if present, appears first in the list. for (int i = 0; i < processedMessages.size(); i++) { @@ -116,6 +118,60 @@ public Flux adviseStream(ChatClientRequest chatClientRequest response -> this.after(response, streamAdvisorChain))); } + /** + * Merges {@code memoryMessages} and {@code instructions} into an ordered list with + * duplicates removed. Deduplication is based on message content only — + * {@code metadata} is deliberately ignored because + * {@link org.springframework.ai.chat.messages.AbstractMessage#equals} includes it, + * and the persisted copy of a message may have different metadata than the in-flight + * copy (e.g. tool-call assistant messages with a {@code null} vs {@code ""} text). + * Memory messages appear first; instructions are appended in order, skipping any + * that are already present. + */ + static List deduplicate(List memoryMessages, List instructions) { + SequencedSet seen = new LinkedHashSet<>(memoryMessages.stream().map(MessageWrapper::new).toList()); + instructions.stream().map(MessageWrapper::new).forEach(seen::add); + return seen.stream().map(MessageWrapper::message).collect(Collectors.toList()); + } + + /** + * Wraps a {@link Message} with {@code equals}/{@code hashCode} based on content + * only, deliberately ignoring {@code metadata}. This is necessary because + * {@link org.springframework.ai.chat.messages.AbstractMessage#equals} includes + * metadata, which may differ between the persisted copy and the in-flight copy + * of the same logical message. + */ + private record MessageWrapper(Message message) { + + @Override + public boolean equals(Object o) { + if (!(o instanceof MessageWrapper w)) return false; + Message a = this.message, b = w.message; + if (a.getMessageType() != b.getMessageType()) return false; + if (a instanceof AssistantMessage am && b instanceof AssistantMessage bm) { + if (am.hasToolCalls() || bm.hasToolCalls()) + return Objects.equals(am.getToolCalls(), bm.getToolCalls()); + } + if (a instanceof ToolResponseMessage tm1 && b instanceof ToolResponseMessage tm2) + return Objects.equals(tm1.getResponses(), tm2.getResponses()); + return Objects.equals(normalizeText(a), normalizeText(b)); + } + + @Override + public int hashCode() { + if (message instanceof AssistantMessage am && am.hasToolCalls()) + return Objects.hash(message.getMessageType(), am.getToolCalls()); + if (message instanceof ToolResponseMessage tm) + return Objects.hash(message.getMessageType(), tm.getResponses()); + return Objects.hash(message.getMessageType(), normalizeText(message)); + } + + private static String normalizeText(Message m) { + String t = m.getText(); + return (t == null) ? "" : t; + } + } + public static Builder builder(ChatMemory chatMemory) { return new Builder(chatMemory); } diff --git a/base/src/test/java/ai/javaclaw/agent/memory/ChatYamlSerializerTest.java b/base/src/test/java/ai/javaclaw/agent/memory/ChatYamlSerializerTest.java new file mode 100644 index 00000000..7f8d45bb --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/memory/ChatYamlSerializerTest.java @@ -0,0 +1,240 @@ +package ai.javaclaw.agent.memory; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatYamlSerializerTest { + + // ----------------------------------------------------------------------- + // serialize — basic messages + // ----------------------------------------------------------------------- + + @Test + void serializesUserMessage() { + String yaml = ChatYamlSerializer.serialize(List.of(new UserMessage("Hello!"))); + + assertThat(yaml).contains("role: user").contains("content: Hello!"); + } + + @Test + void serializesAssistantTextMessage() { + String yaml = ChatYamlSerializer.serialize(List.of(new AssistantMessage("Hi there!"))); + + assertThat(yaml).contains("role: assistant").contains("content: Hi there!"); + } + + @Test + void serializesSystemMessage() { + String yaml = ChatYamlSerializer.serialize(List.of(new SystemMessage("You are helpful."))); + + assertThat(yaml).contains("role: system").contains("content: You are helpful."); + } + + // ----------------------------------------------------------------------- + // serialize — tool calls + // ----------------------------------------------------------------------- + + @Test + void serializesAssistantMessageWithToolCalls() { + AssistantMessage assistantWithToolCall = AssistantMessage.builder() + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_123", "function", "get_weather", "{\"location\":\"London\"}"))) + .build(); + + String yaml = ChatYamlSerializer.serialize(List.of(assistantWithToolCall)); + + assertThat(yaml) + .contains("role: assistant") + .contains("tool_calls:") + .contains("id: call_123") + .contains("function: get_weather") + .contains("arguments:"); + } + + @Test + void serializesToolResponseMessage() { + ToolResponseMessage toolResponse = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse( + "call_123", "get_weather", "Sunny, 20 degrees"))) + .build(); + + String yaml = ChatYamlSerializer.serialize(List.of(toolResponse)); + + assertThat(yaml) + .contains("role: tool") + .contains("tool_call_id: call_123") + .contains("name: get_weather") + .contains("Sunny, 20 degrees"); + } + + @Test + void serializesToolResponseMessageWithMultipleResponses() { + ToolResponseMessage toolResponse = ToolResponseMessage.builder() + .responses(List.of( + new ToolResponseMessage.ToolResponse("call_1", "tool_a", "result_a"), + new ToolResponseMessage.ToolResponse("call_2", "tool_b", "result_b"))) + .build(); + + String yaml = ChatYamlSerializer.serialize(List.of(toolResponse)); + + // Each ToolResponse is emitted as a separate entry + assertThat(yaml) + .contains("tool_call_id: call_1") + .contains("tool_call_id: call_2"); + } + + // ----------------------------------------------------------------------- + // deserialize — basic messages + // ----------------------------------------------------------------------- + + @Test + void deserializesUserMessage() { + String yaml = "- role: user\n content: Hello!\n"; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(1); + assertThat(messages.get(0)).isInstanceOf(UserMessage.class); + assertThat(messages.get(0).getText()).isEqualTo("Hello!"); + } + + @Test + void deserializesAssistantTextMessage() { + String yaml = "- role: assistant\n content: Hi there!\n"; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(1); + assertThat(messages.get(0)).isInstanceOf(AssistantMessage.class); + assertThat(messages.get(0).getText()).isEqualTo("Hi there!"); + } + + @Test + void deserializesSystemMessage() { + String yaml = "- role: system\n content: You are helpful.\n"; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(1); + assertThat(messages.get(0)).isInstanceOf(SystemMessage.class); + assertThat(messages.get(0).getText()).isEqualTo("You are helpful."); + } + + // ----------------------------------------------------------------------- + // deserialize — tool calls and responses + // ----------------------------------------------------------------------- + + @Test + void deserializesAssistantMessageWithToolCalls() { + String yaml = """ + - role: assistant + tool_calls: + - id: call_123 + type: function + function: get_weather + arguments: '{"location":"London"}' + """; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(1); + AssistantMessage assistant = (AssistantMessage) messages.get(0); + assertThat(assistant.hasToolCalls()).isTrue(); + assertThat(assistant.getToolCalls()).hasSize(1); + AssistantMessage.ToolCall tc = assistant.getToolCalls().get(0); + assertThat(tc.id()).isEqualTo("call_123"); + assertThat(tc.type()).isEqualTo("function"); + assertThat(tc.name()).isEqualTo("get_weather"); + assertThat(tc.arguments()).isEqualTo("{\"location\":\"London\"}"); + } + + @Test + void deserializesToolResponseMessage() { + String yaml = """ + - role: tool + tool_call_id: call_123 + name: get_weather + content: Sunny, 20 degrees + """; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(1); + ToolResponseMessage tool = (ToolResponseMessage) messages.get(0); + assertThat(tool.getResponses()).hasSize(1); + ToolResponseMessage.ToolResponse tr = tool.getResponses().get(0); + assertThat(tr.id()).isEqualTo("call_123"); + assertThat(tr.name()).isEqualTo("get_weather"); + assertThat(tr.responseData()).isEqualTo("Sunny, 20 degrees"); + } + + // ----------------------------------------------------------------------- + // roundtrip + // ----------------------------------------------------------------------- + + @Test + void roundtripFullToolCallConversation() { + AssistantMessage assistantWithToolCall = AssistantMessage.builder() + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_123", "function", "get_weather", "{\"location\":\"London\"}"))) + .build(); + ToolResponseMessage toolResponse = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse( + "call_123", "get_weather", "Sunny, 20 degrees"))) + .build(); + AssistantMessage finalAnswer = new AssistantMessage("It is sunny and 20 degrees in London."); + + List original = List.of( + new UserMessage("What's the weather in London?"), + assistantWithToolCall, + toolResponse, + finalAnswer); + + String yaml = ChatYamlSerializer.serialize(original); + List loaded = ChatYamlSerializer.deserialize(yaml); + + assertThat(loaded).hasSize(4); + + assertThat(loaded.get(0)).isInstanceOf(UserMessage.class); + assertThat(loaded.get(0).getText()).isEqualTo("What's the weather in London?"); + + AssistantMessage loadedAssistant = (AssistantMessage) loaded.get(1); + assertThat(loadedAssistant.hasToolCalls()).isTrue(); + assertThat(loadedAssistant.getToolCalls().get(0).id()).isEqualTo("call_123"); + assertThat(loadedAssistant.getToolCalls().get(0).name()).isEqualTo("get_weather"); + assertThat(loadedAssistant.getToolCalls().get(0).arguments()).isEqualTo("{\"location\":\"London\"}"); + + ToolResponseMessage loadedTool = (ToolResponseMessage) loaded.get(2); + assertThat(loadedTool.getResponses().get(0).id()).isEqualTo("call_123"); + assertThat(loadedTool.getResponses().get(0).name()).isEqualTo("get_weather"); + assertThat(loadedTool.getResponses().get(0).responseData()).isEqualTo("Sunny, 20 degrees"); + + assertThat(loaded.get(3)).isInstanceOf(AssistantMessage.class); + assertThat(loaded.get(3).getText()).isEqualTo("It is sunny and 20 degrees in London."); + } + + // ----------------------------------------------------------------------- + // backward compatibility — legacy format + // ----------------------------------------------------------------------- + + @Test + void deserializesLegacyFormat() { + String yaml = "- user: Hello!\n- assistant: Hi there!\n"; + + List messages = ChatYamlSerializer.deserialize(yaml); + + assertThat(messages).hasSize(2); + assertThat(messages.get(0)).isInstanceOf(UserMessage.class); + assertThat(messages.get(0).getText()).isEqualTo("Hello!"); + assertThat(messages.get(1)).isInstanceOf(AssistantMessage.class); + assertThat(messages.get(1).getText()).isEqualTo("Hi there!"); + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java b/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java index 70fed583..822fbbea 100644 --- a/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java +++ b/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java @@ -31,7 +31,7 @@ void setUp() throws IOException { // ----------------------------------------------------------------------- @Test - void saveAndReloadConversation() throws IOException { + void saveAndReloadConversation() { List messages = List.of( new UserMessage("Hello!"), new AssistantMessage("Hi there, how can I help?") @@ -55,7 +55,8 @@ void saveCreatesFileAtCorrectPath() throws IOException { assertThat(content) .contains("createdAt:") .contains("updatedAt:") - .contains("user: Hi"); + .contains("role: user") + .contains("content: Hi"); } @Test @@ -117,7 +118,7 @@ void savePreservesMessageOrder() { assertThat(loaded).extracting(Message::getText) .containsExactly("Question 1", "Answer 1", "Question 2", "Answer 2"); } - + @Test void appendAllAddsMessagesToExistingConversation() { repository.saveAll("web", List.of(new UserMessage("Hello!"))); diff --git a/base/src/test/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisorTest.java b/base/src/test/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisorTest.java new file mode 100644 index 00000000..aca451dc --- /dev/null +++ b/base/src/test/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisorTest.java @@ -0,0 +1,159 @@ +package org.springframework.ai.chat.client.advisor; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessageChatMemoryAdvisorTest { + + // ----------------------------------------------------------------------- + // Plain text messages + // ----------------------------------------------------------------------- + + @Test + void deduplicateRetainsAllWhenNoOverlap() { + List memory = List.of(new UserMessage("Hi")); + List instructions = List.of(new AssistantMessage("Hello!")); + + List result = MessageChatMemoryAdvisor.deduplicate(memory, instructions); + + assertThat(result).hasSize(2); + } + + @Test + void deduplicateRemovesDuplicateUserMessage() { + List memory = List.of(new UserMessage("Hi")); + List instructions = List.of(new UserMessage("Hi")); + + List result = MessageChatMemoryAdvisor.deduplicate(memory, instructions); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getText()).isEqualTo("Hi"); + } + + @Test + void deduplicatePrefersMemoryCopyWhenDuplicated() { + // Memory copy has extra metadata; instruction copy does not — memory wins + Message fromMemory = UserMessage.builder().text("Hi").metadata(Map.of("extra", "data")).build(); + Message fromInstruction = new UserMessage("Hi"); + + List result = MessageChatMemoryAdvisor.deduplicate(List.of(fromMemory), List.of(fromInstruction)); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isSameAs(fromMemory); + } + + @Test + void deduplicatePreservesOrderMemoryFirst() { + List memory = List.of(new UserMessage("Q1"), new AssistantMessage("A1")); + List instructions = List.of(new UserMessage("Q2")); + + List result = MessageChatMemoryAdvisor.deduplicate(memory, instructions); + + assertThat(result).extracting(Message::getText).containsExactly("Q1", "A1", "Q2"); + } + + // ----------------------------------------------------------------------- + // AssistantMessage with tool calls — the core bug scenario + // ----------------------------------------------------------------------- + + @Test + void deduplicateRemovesDuplicateToolCallMessage() { + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "call_123", "function", "get_weather", "{\"location\":\"London\"}"); + + // Persisted copy (from ChatYamlSerializer): textContent = "" + AssistantMessage fromMemory = AssistantMessage.builder() + .content("") + .toolCalls(List.of(toolCall)) + .build(); + + // In-flight copy (from LLM): textContent = null + AssistantMessage fromInstruction = AssistantMessage.builder() + .toolCalls(List.of(toolCall)) + .build(); + + List result = MessageChatMemoryAdvisor.deduplicate( + List.of(fromMemory), List.of(fromInstruction)); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isSameAs(fromMemory); + } + + @Test + void deduplicateKeepsBothToolCallMessagesWhenArgsDiffer() { + AssistantMessage london = AssistantMessage.builder() + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_1", "function", "get_weather", "{\"location\":\"London\"}"))) + .build(); + AssistantMessage paris = AssistantMessage.builder() + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_2", "function", "get_weather", "{\"location\":\"Paris\"}"))) + .build(); + + List result = MessageChatMemoryAdvisor.deduplicate(List.of(london), List.of(paris)); + + assertThat(result).hasSize(2); + } + + // ----------------------------------------------------------------------- + // ToolResponseMessage + // ----------------------------------------------------------------------- + + @Test + void deduplicateRemovesDuplicateToolResponseMessage() { + ToolResponseMessage.ToolResponse response = new ToolResponseMessage.ToolResponse( + "call_123", "get_weather", "Sunny, 20°C"); + + ToolResponseMessage fromMemory = ToolResponseMessage.builder() + .responses(List.of(response)) + .build(); + ToolResponseMessage fromInstruction = ToolResponseMessage.builder() + .responses(List.of(response)) + .build(); + + List result = MessageChatMemoryAdvisor.deduplicate( + List.of(fromMemory), List.of(fromInstruction)); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isSameAs(fromMemory); + } + + // ----------------------------------------------------------------------- + // Full tool-call roundtrip — reproduces the original bug + // ----------------------------------------------------------------------- + + @Test + void deduplicateFullToolCallConversationDoesNotDuplicate() { + // Simulates a second turn where the memory already contains the full tool-call + // sequence and the new instructions re-supply those same messages. + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "call_abc", "function", "get_events", "{\"user_google_calendar_id\":\"x\"}"); + + // Memory: stored copies (textContent = "" from YAML deserialization) + AssistantMessage memAssistant = AssistantMessage.builder().content("").toolCalls(List.of(tc)).build(); + ToolResponseMessage memTool = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call_abc", "get_events", "[]"))) + .build(); + + // Instructions: in-flight copies from LLM (textContent = null) + AssistantMessage inFlightAssistant = AssistantMessage.builder().toolCalls(List.of(tc)).build(); + ToolResponseMessage inFlightTool = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call_abc", "get_events", "[]"))) + .build(); + UserMessage newQuestion = new UserMessage("What's the weather tomorrow?"); + + List result = MessageChatMemoryAdvisor.deduplicate( + List.of(memAssistant, memTool), + List.of(inFlightAssistant, inFlightTool, newQuestion)); + + assertThat(result).hasSize(3); // assistant tool call + tool response + new question — no duplicates + } +} From 1156f5f3c02525891b6f5fe94d7e26554027023a Mon Sep 17 00:00:00 2001 From: rdehuyss Date: Thu, 26 Mar 2026 13:25:47 +0100 Subject: [PATCH 2/5] Show toolcalling --- .../java/ai/javaclaw/chat/ChatChannel.java | 60 +++++++++- .../main/java/ai/javaclaw/chat/ChatHtml.java | 42 ++++++- .../chat/ws/ChatWebSocketHandler.java | 6 +- .../main/resources/templates/chat.html.peb | 108 ++++++++++++++++++ .../ai/javaclaw/chat/ChatChannelTest.java | 7 +- 5 files changed, 213 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index 171c0011..58ddb0ca 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -9,6 +9,7 @@ import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.Ordered; @@ -19,7 +20,9 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; @@ -108,6 +111,7 @@ public List conversationIds() { /** * Loads conversation history for the given conversationId as HTML bubbles. * Returns a single welcome bubble if no history exists yet. + * Tool-call messages are grouped with their final assistant response. */ public List loadHistoryAsHtml(String conversationId) { List history = chatMemoryRepository.findByConversationId(conversationId); @@ -115,21 +119,69 @@ public List loadHistoryAsHtml(String conversationId) { return List.of(ChatHtml.agentBubble("Hi! I'm your JavaClaw assistant. How can I help you today?")); } List bubbles = new ArrayList<>(); + List turnMessages = new ArrayList<>(); for (Message msg : history) { - if (msg instanceof UserMessage) bubbles.add(ChatHtml.userBubble(msg.getText())); - else if (msg instanceof AssistantMessage) bubbles.add(ChatHtml.agentBubble(msg.getText())); + if (msg instanceof UserMessage) { + turnMessages.clear(); + bubbles.add(ChatHtml.userBubble(msg.getText())); + } else if (msg instanceof AssistantMessage am) { + turnMessages.add(am); + if (!am.hasToolCalls()) { + bubbles.add(ChatHtml.agentTurn(am.getText(), buildToolSteps(turnMessages))); + turnMessages.clear(); + } + } else { + turnMessages.add(msg); // ToolResponseMessage — needed for call/result pairing + } } return bubbles; } + private static List buildToolSteps(List turnMessages) { + Map resultById = new LinkedHashMap<>(); + for (Message msg : turnMessages) { + if (msg instanceof ToolResponseMessage trm) { + for (ToolResponseMessage.ToolResponse tr : trm.getResponses()) { + resultById.put(tr.id(), tr.responseData()); + } + } + } + List steps = new ArrayList<>(); + for (Message msg : turnMessages) { + if (msg instanceof AssistantMessage am && am.hasToolCalls()) { + for (AssistantMessage.ToolCall tc : am.getToolCalls()) { + steps.add(new ChatHtml.ToolStep(tc.name(), tc.arguments(), resultById.getOrDefault(tc.id(), ""))); + } + } + } + return steps; + } + /** * Handles a chat message from the web UI for the given conversationId. + * Returns a {@link ChatResult} containing the agent's text response plus any tool calls + * that were executed during this turn. */ - public String chat(String conversationId, String message) { + public ChatResult chat(String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message); + String text = agent.respondTo(conversationId, message); + return new ChatResult(text, extractCurrentTurnToolSteps(conversationId)); } + private List extractCurrentTurnToolSteps(String conversationId) { + List history = chatMemoryRepository.findByConversationId(conversationId); + int start = 0; + for (int i = history.size() - 1; i >= 0; i--) { + if (history.get(i) instanceof UserMessage) { + start = i + 1; + break; + } + } + return buildToolSteps(history.subList(start, history.size())); + } + + public record ChatResult(String text, List toolSteps) {} + private static String buildBackgroundMessageHtml(String text) { return Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(text)); } diff --git a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java index 54e9a9cd..d207c16d 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java @@ -11,12 +11,52 @@ public class ChatHtml { private ChatHtml() {} + public record ToolStep(String name, String arguments, String result) {} + + /** + * Renders the full agent turn: a collapsible thinking row (if tool steps exist) + * immediately followed by the response bubble. + */ + public static String agentTurn(String text, List steps) { + if (steps.isEmpty()) return agentBubble(text); + return thinkingRow(steps) + agentBubble(text); + } + + private static String thinkingRow(List steps) { + String label = steps.size() == 1 ? "Used 1 tool" : "Used " + steps.size() + " tools"; + StringBuilder stepsHtml = new StringBuilder(); + for (ToolStep step : steps) { + String resultBlock = step.result() != null && !step.result().isBlank() + ? "\n
%s
".formatted(HtmlUtils.htmlEscape(step.result())) + : ""; + stepsHtml.append(""" +
+ 🔧 %s +
%s
%s +
""".formatted( + HtmlUtils.htmlEscape(step.name()), + HtmlUtils.htmlEscape(step.arguments()), + resultBlock)); + } + return """ +
+
JC
+
+ +
%s
+
+
""".formatted(HtmlUtils.htmlEscape(label), stepsHtml); + } + public static String agentBubble(String text) { return """
\
JC
\
%s
\ -
""".formatted(HtmlUtils.htmlEscape(text)); + """.formatted(text != null && !text.isBlank() ? HtmlUtils.htmlEscape(text) : ""); } public static String userBubble(String text) { diff --git a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java index 52dbcadd..06821136 100644 --- a/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java +++ b/app/src/main/java/ai/javaclaw/chat/ws/ChatWebSocketHandler.java @@ -94,11 +94,11 @@ private void handleUserMessage(Map payload) throws Exception { ); // Call agent (blocking — background tasks may push messages via ChatChannel during this) - String response = chatChannel.chat(conversationId, userMessage); + ChatChannel.ChatResult result = chatChannel.chat(conversationId, userMessage); - // Send agent response + clear typing indicator + // Send agent response (with thinking row if tool calls were made) + clear typing indicator chatChannel.sendHtml( - Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(response)) + + Htmx.oobAppend("chat-messages", ChatHtml.agentTurn(result.text(), result.toolSteps())) + Htmx.oobReplace("typing-indicator", "") ); } diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 4728c84f..5c5dcf6f 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -149,6 +149,114 @@ .chat-readonly-notice strong { color: rgba(180, 195, 235, .7); } + +/* Thinking row — shown above the agent bubble when tool calls were made */ +.ar-thinking { + display: flex; + align-items: flex-start; + gap: .55rem; + align-self: flex-start; + max-width: 76%; + animation: msg-in .22s cubic-bezier(.22, 1, .36, 1); +} + +.ar-thinking__body { + display: flex; + flex-direction: column; + gap: .2rem; +} + +.ar-thinking__toggle { + background: none; + border: none; + cursor: pointer; + padding: .15rem .4rem; + font-size: .75rem; + color: rgba(160, 180, 225, .45); + text-align: left; + transition: color .15s; + display: flex; + align-items: center; + gap: .3rem; + border-radius: .3rem; +} +.ar-thinking__toggle:hover { color: rgba(160, 180, 225, .8); } +.ar-thinking__toggle.is-open { color: rgba(160, 180, 225, .9); } + +.ar-thinking__chevron { + font-size: .6rem; + transition: transform .2s; + display: inline-block; +} +.ar-thinking__toggle.is-open .ar-thinking__chevron { transform: rotate(90deg); } + +/* Expandable steps list */ +.ar-thinking__steps { + display: none; + flex-direction: column; + gap: .35rem; +} +.ar-thinking__steps.is-open { display: flex; } + +.ar-thinking__step { + font-size: .78rem; + border-radius: .45rem; + background: hsl(228, 28%, 9%); + border: 1px solid rgba(255, 255, 255, .07); + overflow: hidden; +} +.ar-thinking__step > summary { + display: flex; + align-items: center; + gap: .35rem; + padding: .28rem .6rem; + cursor: pointer; + list-style: none; + font-family: monospace; + letter-spacing: .01em; + color: rgba(160, 180, 225, .75); + background: hsl(228, 28%, 9%); + user-select: none; + transition: color .15s; +} +.ar-thinking__step > summary::-webkit-details-marker { display: none; } +.ar-thinking__step > summary::before { + content: "\25BA"; + font-size: .55rem; + display: inline-block; + transition: transform .2s; + flex-shrink: 0; + opacity: .5; +} +.ar-thinking__step[open] > summary::before { transform: rotate(90deg); } +.ar-thinking__step > summary:hover { color: rgba(160, 180, 225, 1); } +.ar-thinking__step-args { + padding: .35rem .75rem; + border-top: 1px solid rgba(255, 255, 255, .06); + font-family: monospace; + font-size: .72rem; + color: hsl(230, 15%, 58%); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + background: hsl(228, 32%, 7%); +} +.ar-thinking__step-result { + padding: .35rem .75rem; + border-top: 1px solid rgba(255, 255, 255, .06); + font-family: monospace; + font-size: .72rem; + color: hsl(135, 20%, 58%); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + background: hsl(135, 20%, 6%); +} + +/* Visually group thinking row with its response bubble */ +.ar-thinking + .ar-msg--agent { + margin-top: -0.575rem; +} {% endblock %} diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index 206bf424..44e267b4 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -132,16 +132,19 @@ void loadHistoryUsesSuppliedConversationId() { @Test void chatDelegatesToAgentWithConversationId() { when(agent.respondTo("web", "hello")).thenReturn("hi"); + when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of()); - String response = chatChannel.chat("web", "hello"); + ChatChannel.ChatResult result = chatChannel.chat("web", "hello"); - assertThat(response).isEqualTo("hi"); + assertThat(result.text()).isEqualTo("hi"); + assertThat(result.toolSteps()).isEmpty(); verify(agent).respondTo(eq("web"), eq("hello")); } @Test void chatUsesSuppliedConversationId() { when(agent.respondTo(eq("telegram-42"), any())).thenReturn("reply"); + when(chatMemoryRepository.findByConversationId("telegram-42")).thenReturn(List.of()); chatChannel.chat("telegram-42", "hello"); From 41b7ed3d089ea6587d4d5bb23a65eea9aed2d17f Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi <36740618+auloin@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:31:41 +0100 Subject: [PATCH 3/5] Refactor and small UI improvements --- .../main/java/ai/javaclaw/chat/ChatHtml.java | 12 +-- .../main/resources/templates/chat.html.peb | 25 +++-- .../agent/memory/ChatYamlSerializer.java | 92 ++++++++++--------- .../advisor/MessageChatMemoryAdvisor.java | 4 +- 4 files changed, 66 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java index d207c16d..d9629ea1 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java @@ -40,15 +40,11 @@ private static String thinkingRow(List steps) { } return """
-
JC
-
- +
+ %s
%s
-
-
""".formatted(HtmlUtils.htmlEscape(label), stepsHtml); + + """.formatted(label, stepsHtml); } public static String agentBubble(String text) { diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 5c5dcf6f..f5ec5ddc 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -152,11 +152,9 @@ /* Thinking row — shown above the agent bubble when tool calls were made */ .ar-thinking { - display: flex; - align-items: flex-start; - gap: .55rem; align-self: flex-start; max-width: 76%; + padding-left: calc(30px + .55rem); animation: msg-in .22s cubic-bezier(.22, 1, .36, 1); } @@ -179,24 +177,25 @@ align-items: center; gap: .3rem; border-radius: .3rem; + list-style: none; + user-select: none; } -.ar-thinking__toggle:hover { color: rgba(160, 180, 225, .8); } -.ar-thinking__toggle.is-open { color: rgba(160, 180, 225, .9); } - -.ar-thinking__chevron { +.ar-thinking__toggle::-webkit-details-marker { display: none; } +.ar-thinking__toggle::before { + content: "\25BA"; font-size: .6rem; - transition: transform .2s; display: inline-block; + transition: transform .2s; } -.ar-thinking__toggle.is-open .ar-thinking__chevron { transform: rotate(90deg); } +.ar-thinking__body[open] > .ar-thinking__toggle::before { transform: rotate(90deg); } +.ar-thinking__toggle:hover { color: rgba(160, 180, 225, .8); } +.ar-thinking__body[open] > .ar-thinking__toggle { color: rgba(160, 180, 225, .9); } -/* Expandable steps list */ .ar-thinking__steps { - display: none; + display: flex; flex-direction: column; gap: .35rem; } -.ar-thinking__steps.is-open { display: flex; } .ar-thinking__step { font-size: .78rem; @@ -222,7 +221,7 @@ .ar-thinking__step > summary::-webkit-details-marker { display: none; } .ar-thinking__step > summary::before { content: "\25BA"; - font-size: .55rem; + font-size: .58rem; display: inline-block; transition: transform .2s; flex-shrink: 0; diff --git a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java b/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java index 59401601..3c3b9494 100644 --- a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java +++ b/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java @@ -6,7 +6,6 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.util.ObjectUtils; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; @@ -15,6 +14,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Serialises and deserialises a list of Spring AI {@link Message} objects to/from @@ -68,7 +68,7 @@ static List deserialize(String body) { static String serialize(List messages) { List> entries = messages.stream() .filter(msg -> PERSISTABLE_MESSAGES.contains(msg.getMessageType())) - .flatMap(msg -> toEntries(msg).stream()) + .flatMap(ChatYamlSerializer::toYamlEntries) .collect(Collectors.toList()); DumperOptions options = new DumperOptions(); @@ -77,46 +77,46 @@ static String serialize(List messages) { return new Yaml(options).dump(entries); } - private static List> toEntries(Message msg) { - if (msg instanceof AssistantMessage assistantMsg && assistantMsg.hasToolCalls()) { - Map entry = new LinkedHashMap<>(); - entry.put("role", "assistant"); - if (assistantMsg.getText() != null && !assistantMsg.getText().isBlank()) { - entry.put("content", assistantMsg.getText()); - } - List> toolCallList = assistantMsg.getToolCalls().stream() - .map(tc -> { - Map tcMap = new LinkedHashMap<>(); - tcMap.put("id", tc.id()); - tcMap.put("type", tc.type()); - tcMap.put("function", tc.name()); - tcMap.put("arguments", tc.arguments()); - return tcMap; - }) - .collect(Collectors.toList()); - entry.put("tool_calls", toolCallList); - return List.of(entry); + private static Stream> toYamlEntries(Message msg) { + if (msg instanceof AssistantMessage am && am.hasToolCalls()) { + return Stream.of(toAssistantToolCallYamlEntry(am)); } - if (msg instanceof ToolResponseMessage toolMsg) { - return toolMsg.getResponses().stream() - .map(tr -> { - Map entry = new LinkedHashMap<>(); - entry.put("role", "tool"); - entry.put("tool_call_id", tr.id()); - entry.put("name", tr.name()); - entry.put("content", tr.responseData()); - return entry; - }) - .collect(Collectors.toList()); - } - // USER, ASSISTANT (text only), SYSTEM - if (msg.getText() == null) { - return List.of(); + if (msg instanceof ToolResponseMessage trm) { + return trm.getResponses().stream().map(ChatYamlSerializer::toToolResponseYamlEntry); } + if (msg.getText() == null) return Stream.empty(); Map entry = new LinkedHashMap<>(); entry.put("role", msg.getMessageType().getValue()); entry.put("content", msg.getText()); - return List.of(entry); + return Stream.of(entry); + } + + private static Map toAssistantToolCallYamlEntry(AssistantMessage msg) { + Map entry = new LinkedHashMap<>(); + entry.put("role", "assistant"); + if (msg.getText() != null && !msg.getText().isBlank()) entry.put("content", msg.getText()); + entry.put("tool_calls", msg.getToolCalls().stream() + .map(ChatYamlSerializer::toToolCallYamlEntry) + .collect(Collectors.toList())); + return entry; + } + + private static Map toToolCallYamlEntry(AssistantMessage.ToolCall tc) { + Map map = new LinkedHashMap<>(); + map.put("id", tc.id()); + map.put("type", tc.type()); + map.put("function", tc.name()); + map.put("arguments", tc.arguments()); + return map; + } + + private static Map toToolResponseYamlEntry(ToolResponseMessage.ToolResponse tr) { + Map entry = new LinkedHashMap<>(); + entry.put("role", "tool"); + entry.put("tool_call_id", tr.id()); + entry.put("name", tr.name()); + entry.put("content", tr.responseData()); + return entry; } private static Message toMessage(Map entry) { @@ -142,19 +142,23 @@ private static AssistantMessage toAssistantMessage(Map entry) { if (rawToolCalls == null || rawToolCalls.isEmpty()) { return new AssistantMessage(content != null ? content : ""); } - List toolCalls = rawToolCalls.stream() - .map(tc -> new AssistantMessage.ToolCall( - tc.get("id"), - tc.getOrDefault("type", "function"), - tc.get("function"), - tc.get("arguments"))) - .collect(Collectors.toList()); + List toolCalls = toAssistantToolCalls(rawToolCalls); return AssistantMessage.builder() .content(content != null ? content : "") .toolCalls(toolCalls) .build(); } + private static List toAssistantToolCalls(List> rawToolCalls) { + return rawToolCalls.stream() + .map(tc -> new AssistantMessage.ToolCall( + tc.get("id"), + tc.getOrDefault("type", "function"), + tc.get("function"), + tc.get("arguments"))) + .collect(Collectors.toList()); + } + private static ToolResponseMessage toToolMessage(Map entry) { ToolResponseMessage.ToolResponse response = new ToolResponseMessage.ToolResponse( (String) entry.get("tool_call_id"), diff --git a/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java b/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java index 44f72b65..db330d8b 100644 --- a/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java +++ b/base/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java @@ -26,7 +26,7 @@ import java.util.stream.Collectors; /** - * A copy of Springs MessageChatMemoryAdvisor that does not add duplicate messages from memory if they are contained in the chatClientRequest.prompt().getInstructions() + * A copy of Spring's MessageChatMemoryAdvisor that does not add duplicate messages from memory if they are contained in the chatClientRequest.prompt().getInstructions() */ public final class MessageChatMemoryAdvisor implements BaseChatMemoryAdvisor { @@ -131,7 +131,7 @@ public Flux adviseStream(ChatClientRequest chatClientRequest static List deduplicate(List memoryMessages, List instructions) { SequencedSet seen = new LinkedHashSet<>(memoryMessages.stream().map(MessageWrapper::new).toList()); instructions.stream().map(MessageWrapper::new).forEach(seen::add); - return seen.stream().map(MessageWrapper::message).collect(Collectors.toList()); + return seen.stream().map(MessageWrapper::message).toList(); } /** From 393b14e7c32382c06f2537f40b2b1f41c679867c Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi <36740618+auloin@users.noreply.github.com> Date: Fri, 8 May 2026 12:21:06 +0200 Subject: [PATCH 4/5] Fix typo --- .../providers/anthropic/AnthropicAgentOnboardingProvider.java | 2 +- ...nfiguration.java => AnthropicClaudeCodeConfiguration.java} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/{AnthropticClaudeCodeConfiguration.java => AnthropicClaudeCodeConfiguration.java} (97%) diff --git a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java index 8c7ea5e0..92bdbee5 100644 --- a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java +++ b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java @@ -5,7 +5,7 @@ import java.util.Optional; -import static ai.javaclaw.providers.anthropic.AnthropticClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER; +import static ai.javaclaw.providers.anthropic.AnthropicClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER; @Component public class AnthropicAgentOnboardingProvider implements AgentOnboardingProvider { diff --git a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeConfiguration.java similarity index 97% rename from providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java rename to providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeConfiguration.java index c52c5edf..0f25b562 100644 --- a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java +++ b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeConfiguration.java @@ -19,8 +19,8 @@ import org.springframework.context.annotation.Configuration; @Configuration -@ConditionalOnProperty(name = "spring.ai.anthropic.api-key", havingValue = AnthropticClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER) -public class AnthropticClaudeCodeConfiguration { +@ConditionalOnProperty(name = "spring.ai.anthropic.api-key", havingValue = AnthropicClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER) +public class AnthropicClaudeCodeConfiguration { public static final String CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER = ""; From 66bcb192e2103634f5e9c30eecb7559abc0f2f2d Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi <36740618+auloin@users.noreply.github.com> Date: Fri, 8 May 2026 13:30:27 +0200 Subject: [PATCH 5/5] Cleanup --- .../channels/discord/DiscordChannel.java | 22 ++++--------------- .../discord/DiscordOnboardingProvider.java | 16 ++------------ .../channels/discord/DiscordUtils.java | 19 ++++++++++++++++ 3 files changed, 25 insertions(+), 32 deletions(-) create mode 100644 plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordUtils.java diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java index 5517392c..8c5a26e3 100644 --- a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordChannel.java @@ -15,6 +15,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Optional; + +import static ai.javaclaw.channels.discord.DiscordUtils.normalizeUserId; import static java.util.Optional.ofNullable; import static java.util.regex.Pattern.quote; @@ -90,11 +93,8 @@ private static void reply(MessageChannel channel, String text) { private static String normalizeText(JDA jda, Message message, boolean guildMessage) { String content = message.getContentRaw(); - if (content == null) { - return null; - } if (guildMessage) { - String mention = ofNullable(jda.getSelfUser()).map(User::getAsMention).orElse(""); + String mention = jda.getSelfUser().getAsMention(); content = content.replaceFirst("^\\s*" + quote(mention) + "\\s*", ""); } content = content.trim(); @@ -104,18 +104,4 @@ private static String normalizeText(JDA jda, Message message, boolean guildMessa private static String getConversationId(String channelId) { return "discord-" + channelId; } - - private static String normalizeUserId(String userId) { - if (userId == null) { - return null; - } - String normalized = userId.trim(); - if (normalized.startsWith("<@") && normalized.endsWith(">")) { - normalized = normalized.substring(2, normalized.length() - 1); - if (normalized.startsWith("!")) { - normalized = normalized.substring(1); - } - } - return normalized.isBlank() ? null : normalized; - } } diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java index d7bb32fa..1f8bd2c9 100644 --- a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordOnboardingProvider.java @@ -9,6 +9,8 @@ import java.io.IOException; import java.util.Map; +import static ai.javaclaw.channels.discord.DiscordUtils.normalizeUserId; + @Component @Order(53) public class DiscordOnboardingProvider implements OnboardingProvider { @@ -72,18 +74,4 @@ public void saveConfiguration(Map session, ConfigurationManager )); } } - - private static String normalizeUserId(String userId) { - if (userId == null) { - return null; - } - String normalized = userId.trim(); - if (normalized.startsWith("<@") && normalized.endsWith(">")) { - normalized = normalized.substring(2, normalized.length() - 1); - if (normalized.startsWith("!")) { - normalized = normalized.substring(1); - } - } - return normalized.isBlank() ? null : normalized; - } } diff --git a/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordUtils.java b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordUtils.java new file mode 100644 index 00000000..f59a328a --- /dev/null +++ b/plugins/discord/src/main/java/ai/javaclaw/channels/discord/DiscordUtils.java @@ -0,0 +1,19 @@ +package ai.javaclaw.channels.discord; + +public class DiscordUtils { + private DiscordUtils() {} + + public static String normalizeUserId(String userId) { + if (userId == null) { + return null; + } + String normalized = userId.trim(); + if (normalized.startsWith("<@") && normalized.endsWith(">")) { + normalized = normalized.substring(2, normalized.length() - 1); + if (normalized.startsWith("!")) { + normalized = normalized.substring(1); + } + } + return normalized.isBlank() ? null : normalized; + } +}