Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions app/src/main/java/ai/javaclaw/chat/ChatChannel.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package ai.javaclaw.chat;

import ai.javaclaw.JavaClawConfiguration;
import ai.javaclaw.agent.Agent;
import ai.javaclaw.agent.ResponseListener;
import ai.javaclaw.channels.Channel;
import ai.javaclaw.channels.ChannelMessageReceivedEvent;
import ai.javaclaw.channels.ChannelRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.UserMessage;
import org.springframework.ai.session.Session;
import org.springframework.ai.session.SessionService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
Expand Down Expand Up @@ -41,15 +43,15 @@ public class ChatChannel implements Channel {

private final Agent agent;
private final ChannelRegistry channelRegistry;
private final ChatMemoryRepository chatMemoryRepository;
private final SessionService sessionService;
private final ObjectMapper objectMapper;
private final ConcurrentLinkedQueue<String> pendingMessages = new ConcurrentLinkedQueue<>();
private final AtomicReference<WebSocketSession> wsSession = new AtomicReference<>();

public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository, ObjectMapper objectMapper) {
public ChatChannel(Agent agent, ChannelRegistry channelRegistry, SessionService sessionService, ObjectMapper objectMapper) {
this.agent = agent;
this.channelRegistry = channelRegistry;
this.chatMemoryRepository = chatMemoryRepository;
this.sessionService = sessionService;
this.objectMapper = objectMapper;
channelRegistry.registerChannel(this);
log.info("Started Web Chat channel");
Expand Down Expand Up @@ -117,7 +119,8 @@ public void flushPendingMessages() {
public List<String> conversationIds() {
List<String> result = new ArrayList<>();
result.add("web");
chatMemoryRepository.findConversationIds().stream()
sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID).stream()
.map(Session::id)
.filter(id -> !id.equals("web"))
.forEach(result::add);
return result;
Expand All @@ -128,7 +131,7 @@ public List<String> conversationIds() {
* Returns a single welcome bubble if no history exists yet.
*/
public List<String> loadHistoryAsHtml(String conversationId) {
List<Message> history = chatMemoryRepository.findByConversationId(conversationId);
List<Message> history = sessionService.getMessages(conversationId);
if (history.isEmpty()) {
return List.of(ChatHtml.agentBubble("Hi! I'm your JavaClaw assistant. How can I help you today?"));
}
Expand Down
32 changes: 20 additions & 12 deletions app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.javaclaw.chat;

import ai.javaclaw.JavaClawConfiguration;
import ai.javaclaw.agent.Agent;
import ai.javaclaw.agent.ResponseListener;
import ai.javaclaw.channels.ChannelRegistry;
Expand All @@ -8,9 +9,10 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.session.Session;
import org.springframework.ai.session.SessionService;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import tools.jackson.databind.ObjectMapper;
Expand All @@ -31,13 +33,19 @@
class ChatChannelTest {

@Mock Agent agent;
@Mock ChatMemoryRepository chatMemoryRepository;
@Mock SessionService sessionService;

ChatChannel chatChannel;

@BeforeEach
void setUp() {
chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository, new ObjectMapper());
chatChannel = new ChatChannel(agent, new ChannelRegistry(), sessionService, new ObjectMapper());
}

private static List<Session> sessions(String... ids) {
return java.util.Arrays.stream(ids)
.map(id -> Session.builder().id(id).userId(JavaClawConfiguration.AGENT_USER_ID).build())
.toList();
}

// -----------------------------------------------------------------------
Expand All @@ -46,7 +54,7 @@ void setUp() {

@Test
void conversationIdsAlwaysContainsWebFirst() {
when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("telegram-42", "web"));
when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("telegram-42", "web"));

List<String> ids = chatChannel.conversationIds();

Expand All @@ -55,7 +63,7 @@ void conversationIdsAlwaysContainsWebFirst() {

@Test
void conversationIdsIncludesWebEvenWhenRepositoryReturnsEmpty() {
when(chatMemoryRepository.findConversationIds()).thenReturn(List.of());
when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(List.of());

List<String> ids = chatChannel.conversationIds();

Expand All @@ -64,7 +72,7 @@ void conversationIdsIncludesWebEvenWhenRepositoryReturnsEmpty() {

@Test
void conversationIdsIncludesOtherChannelsAfterWeb() {
when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("telegram-42", "telegram-99"));
when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("telegram-42", "telegram-99"));

List<String> ids = chatChannel.conversationIds();

Expand All @@ -73,7 +81,7 @@ void conversationIdsIncludesOtherChannelsAfterWeb() {

@Test
void conversationIdsDeduplicatesWeb() {
when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("web", "telegram-42"));
when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("web", "telegram-42"));

List<String> ids = chatChannel.conversationIds();

Expand All @@ -86,7 +94,7 @@ void conversationIdsDeduplicatesWeb() {

@Test
void loadHistoryReturnsWelcomeBubbleWhenNoHistory() {
when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of());
when(sessionService.getMessages("web")).thenReturn(List.of());

List<String> bubbles = chatChannel.loadHistoryAsHtml("web");

Expand All @@ -96,7 +104,7 @@ void loadHistoryReturnsWelcomeBubbleWhenNoHistory() {

@Test
void loadHistoryRendersUserAndAgentBubbles() {
when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of(
when(sessionService.getMessages("web")).thenReturn(List.of(
new UserMessage("Hello"),
new AssistantMessage("Hi there")
));
Expand All @@ -110,7 +118,7 @@ void loadHistoryRendersUserAndAgentBubbles() {

@Test
void loadHistoryEscapesHtmlInMessages() {
when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of(
when(sessionService.getMessages("web")).thenReturn(List.of(
new UserMessage("<script>alert('xss')</script>")
));

Expand All @@ -121,11 +129,11 @@ void loadHistoryEscapesHtmlInMessages() {

@Test
void loadHistoryUsesSuppliedConversationId() {
when(chatMemoryRepository.findByConversationId("telegram-42")).thenReturn(List.of());
when(sessionService.getMessages("telegram-42")).thenReturn(List.of());

chatChannel.loadHistoryAsHtml("telegram-42");

verify(chatMemoryRepository).findByConversationId("telegram-42");
verify(sessionService).getMessages("telegram-42");
}

// -----------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import ai.javaclaw.channels.ChannelRegistry;
import ai.javaclaw.chat.ChatChannel;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.session.DefaultSessionService;
import org.springframework.ai.session.InMemorySessionRepository;
import org.springframework.ai.session.SessionService;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
Expand Down Expand Up @@ -109,8 +110,10 @@ ChannelRegistry channelRegistry() {
}

@Bean
ChatMemoryRepository chatMemoryRepository() {
return new InMemoryChatMemoryRepository();
SessionService sessionService() {
return DefaultSessionService.builder()
.sessionRepository(InMemorySessionRepository.builder().build())
.build();
}

@Bean
Expand Down
2 changes: 2 additions & 0 deletions base/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies {
implementation(libs.jobrunr.spring.starter)
implementation 'org.apache.commons:commons-lang3'
implementation 'com.fasterxml.jackson.core:jackson-core'
implementation 'tools.jackson.core:jackson-databind'

// No idea?
runtimeOnly(libs.netty.resolver.dns.native.macos)
Expand All @@ -21,6 +22,7 @@ dependencies {
implementation(libs.spring.ai.lucene)

implementation(libs.spring.ai.agent.utils)
api(libs.spring.ai.session)

runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
39 changes: 30 additions & 9 deletions base/src/main/java/ai/javaclaw/JavaClawConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@
import org.springaicommunity.agent.tools.SkillsTool;
import org.springaicommunity.agent.tools.SmartWebFetchTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.ai.model.SpringAIModelProperties;
import org.springframework.ai.session.DefaultSessionService;
import org.springframework.ai.session.SessionRepository;
import org.springframework.ai.session.SessionService;
import org.springframework.ai.session.advisor.SessionMemoryAdvisor;
import org.springframework.ai.session.compaction.TokenCountTrigger;
import org.springframework.ai.session.compaction.TurnWindowCompactionStrategy;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
Expand All @@ -44,19 +47,30 @@ public class JavaClawConfiguration {

public static final String AGENT_MD = "AGENT.private.md";

/** Single-tenant install: every session belongs to this user. */
public static final String AGENT_USER_ID = "javaclaw";

/**
* Estimated token count of a session's history at which older turns are
* compacted out of the context window sent to the model.
*/
private static final int COMPACTION_TOKEN_THRESHOLD = 50_000;

@Value("${agent.skills.paths}")
List<Resource> skillPaths;


@Bean
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = "unknown", matchIfMissing = true)
public ChatModel chatModel() {
return prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect."))));
return _ -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect."))));
}

@Bean
public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).build();
public SessionService sessionService(SessionRepository sessionRepository) {
return DefaultSessionService.builder()
.sessionRepository(sessionRepository)
.build();
}

@Bean
Expand All @@ -73,7 +87,7 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider<ChatModel> chatModelP
@Bean
@DependsOn({"mcpHeaderCustomizer"})
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
ChatMemory chatMemory,
SessionService sessionService,
ObjectProvider<ToolSearchToolCallingAdvisor> toolSearchToolCallAdvisorProvider,
SyncMcpToolCallbackProvider mcpToolProvider,
TaskManager taskManager,
Expand Down Expand Up @@ -115,7 +129,14 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
SmartWebFetchTool.builder(chatClientBuilder.clone().build()).build())
.defaultAdvisors(
toolCallAdvisor,
MessageChatMemoryAdvisor.builder(chatMemory).build()
SessionMemoryAdvisor.builder(sessionService)
.defaultUserId(AGENT_USER_ID)
.compactionTrigger(TokenCountTrigger.builder()
.threshold(COMPACTION_TOKEN_THRESHOLD)
.tokenCountEstimator(new JTokkitTokenCountEstimator())
.build())
.compactionStrategy(TurnWindowCompactionStrategy.builder().build())
.build()
);

autoDiscoveredTools.forEach(autoDiscoveredTool -> chatClientBuilder.defaultTools(autoDiscoveredTool.tool()));
Expand Down
8 changes: 4 additions & 4 deletions base/src/main/java/ai/javaclaw/agent/DefaultAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.session.advisor.SessionMemoryAdvisor;
import org.springframework.stereotype.Component;

@Component
Expand All @@ -21,7 +21,7 @@ public DefaultAgent(ChatClient chatClient) {
public String respondTo(String conversationId, String question) {
return chatClient
.prompt(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId))
.call()
.content();
}
Expand All @@ -32,7 +32,7 @@ public String respondTo(String conversationId, String question, ResponseListener
try {
chatClient
.prompt(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId))
.stream()
.content()
.doOnNext(token -> {
Expand All @@ -59,7 +59,7 @@ public String respondTo(String conversationId, String question, ResponseListener
public <T> T prompt(String conversationId, String input, Class<T> result) {
return chatClient
.prompt(input)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId))
.call()
.entity(result);
}
Expand Down
Loading
Loading