From e210bccf5620932890d297bb83232d15f7b62713 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Mon, 14 Sep 2026 19:28:14 +0300 Subject: [PATCH 1/4] Show tool calls in the web chat The agent could call tools without any of it being visible: ToolCallingAdvisor runs the tool loop and does not forward the assistant chunks carrying tool calls to the outbound stream, so neither the log nor the UI saw them. A failing tool surfaced only as a gap in the reply. Add ToolCallObservingAdvisor, ordered inside that loop where tool calls are still visible. It reports each call as the model asks for it and each result as the next round's history carries it back, to the log and to an optional ResponseListener. A call id is tracked from call to result, which pairs the two and de-duplicates the repeats caused by arguments streaming in as partial JSON. ResponseListener gains onToolCall/onToolResult as no-op defaults, so the other channels are unaffected. The web chat implements them and streams toolCall and toolResult frames, which render as collapsible cards inside the agent bubble showing the tool name, its input and its output. Text runs therefore need their own spans: assigning to the bubble's textContent would discard the cards. --- .../java/ai/javaclaw/chat/ChatChannel.java | 40 ++- .../ai/javaclaw/chat/StreamFrameType.java | 2 + .../main/resources/templates/chat.html.peb | 303 +++++++++++++++++- .../ai/javaclaw/chat/ChatChannelTest.java | 24 ++ .../java/ai/javaclaw/agent/DefaultAgent.java | 9 +- .../ai/javaclaw/agent/ResponseListener.java | 15 + .../agent/ToolCallObservingAdvisor.java | 112 +++++++ .../agent/ToolCallObservingAdvisorTest.java | 173 ++++++++++ 8 files changed, 668 insertions(+), 10 deletions(-) create mode 100644 base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java create mode 100644 base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index c4578b53..d64de7de 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -148,10 +148,42 @@ public List loadHistoryAsHtml(String conversationId) { public String chat(String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message, ResponseListener.of( - token -> sendChunkFrame(conversationId, token), - () -> sendDoneFrame(conversationId), - error -> sendErrorFrame(conversationId, error))); + return agent.respondTo(conversationId, message, new ResponseListener() { + + @Override + public void onToken(String token) { + sendChunkFrame(conversationId, token); + } + + @Override + public void onToolCall(String id, String name, String input) { + sendToolFrame(StreamFrameType.TOOL_CALL, conversationId, id, name, "input", input); + } + + @Override + public void onToolResult(String id, String name, String output) { + sendToolFrame(StreamFrameType.TOOL_RESULT, conversationId, id, name, "output", output); + } + + @Override + public void onComplete() { + sendDoneFrame(conversationId); + } + + @Override + public void onError(String error) { + sendErrorFrame(conversationId, error); + } + }); + } + + private void sendToolFrame(StreamFrameType type, String conversationId, + String id, String name, String payloadKey, String payload) { + Map data = new LinkedHashMap<>(); + data.put("id", id); + data.put("name", name); + data.put(payloadKey, payload == null ? "" : payload); + sendFrame(frame(type, conversationId, data)); } private void sendChunkFrame(String conversationId, String token) { diff --git a/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java index 87a48023..b9bb463a 100644 --- a/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java +++ b/app/src/main/java/ai/javaclaw/chat/StreamFrameType.java @@ -6,6 +6,8 @@ */ public enum StreamFrameType { CHUNK("chunk"), + TOOL_CALL("toolCall"), + TOOL_RESULT("toolResult"), DONE("done"), ERROR("error"); diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 632d657f..3ce7f33c 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -154,6 +154,169 @@ border-color: color-mix(in srgb, var(--bulma-danger) 45%, transparent); color: var(--bulma-danger); } + +/* A message that ran tools needs more room for JSON than a plain sentence does, + and its avatar belongs at the top rather than floating beside the last line. */ +.ar-msg--agent:has(.ar-tool) { + max-width: 92%; + align-items: flex-start; +} + +/* Tool calls render inside the agent bubble, one shade darker to read as nested. */ +.ar-tool { + display: block; + margin: .55rem 0; + background: hsl(229, 24%, 10%); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: .85rem; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, .25); + animation: msg-in .22s cubic-bezier(.22, 1, .36, 1); + transition: border-color .18s ease, box-shadow .18s ease; +} +.ar-tool:hover { border-color: rgba(255, 255, 255, .13); } +.ar-tool[open] { box-shadow: 0 4px 18px rgba(0, 0, 0, .28); } + +.ar-msg__bubble > .ar-tool:first-child { margin-top: 0; } +.ar-msg__bubble > .ar-tool:last-child { margin-bottom: 0; } + +.ar-tool, .ar-tool * { white-space: normal; } + +.ar-tool__head { + display: flex; + align-items: center; + gap: .55rem; + padding: .5rem .7rem; + cursor: pointer; + list-style: none; + user-select: none; +} +.ar-tool__head::-webkit-details-marker { display: none; } + +.ar-tool__caret { + flex-shrink: 0; + width: .42rem; height: .42rem; + border-right: 1.5px solid rgba(200, 212, 240, .4); + border-bottom: 1.5px solid rgba(200, 212, 240, .4); + transform: rotate(-45deg); + transition: transform .2s cubic-bezier(.22, 1, .36, 1); +} +.ar-tool[open] .ar-tool__caret { transform: rotate(45deg); } + +.ar-tool__glyph { + flex-shrink: 0; + width: 22px; height: 22px; + display: flex; align-items: center; justify-content: center; + border-radius: .45rem; + background: hsl(160, 30%, 14%); + border: 1px solid color-mix(in srgb, var(--bulma-primary) 30%, transparent); + color: var(--bulma-primary); +} +.ar-tool__glyph svg { width: 12px; height: 12px; } + +.ar-tool__name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .78rem; + font-weight: 600; + letter-spacing: -.01em; + color: #c8cee0; + white-space: nowrap; +} + +/* Collapsed cards stay informative: the key argument reads inline after the name. */ +.ar-tool__args { + min-width: 0; + flex: 1; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .72rem; + color: rgba(200, 212, 240, .42); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ar-tool[open] .ar-tool__args { opacity: 0; } + +.ar-tool__meta { + margin-left: auto; + flex-shrink: 0; + display: flex; align-items: center; gap: .45rem; +} +.ar-tool__time { + font-size: .68rem; + font-variant-numeric: tabular-nums; + color: rgba(200, 212, 240, .38); +} +.ar-tool__dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--bulma-primary); + animation: tool-pulse 1.15s ease-in-out infinite; +} +@keyframes tool-pulse { + 0%, 100% { opacity: .3; transform: scale(.75); } + 50% { opacity: 1; transform: scale(1); } +} +.ar-tool--done .ar-tool__dot { + animation: none; + opacity: 1; + background: var(--bulma-success); +} + +.ar-tool__body { + padding: .6rem .7rem .7rem; + border-top: 1px solid rgba(255, 255, 255, .05); +} +.ar-tool__section + .ar-tool__section { margin-top: .6rem; } + +.ar-tool__label { + display: flex; + align-items: center; + margin-bottom: .28rem; + font-size: .62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .08em; + color: rgba(200, 212, 240, .34); +} + +.ar-tool__pre { + margin: 0; + padding: .5rem .6rem; + max-height: 13rem; + overflow: auto; + border-radius: .5rem; + background: hsl(229, 20%, 7%); + border: 1px solid rgba(255, 255, 255, .05); + border-left: 2px solid color-mix(in srgb, var(--bulma-primary) 38%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .72rem; + line-height: 1.6; + color: #9fb0cc; + white-space: pre-wrap; + word-break: break-word; +} +.ar-tool__pre, .ar-tool__pre * { white-space: pre-wrap; } +.ar-tool__copy { + margin-left: auto; + padding: .1rem .35rem; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: .3rem; + background: transparent; + color: rgba(200, 212, 240, .5); + font: inherit; + font-size: .6rem; + letter-spacing: .04em; + cursor: pointer; + opacity: 0; + transition: opacity .15s ease, color .15s ease, border-color .15s ease; +} +.ar-tool__section:hover .ar-tool__copy { opacity: 1; } +.ar-tool__copy:hover { color: var(--bulma-primary); border-color: color-mix(in srgb, var(--bulma-primary) 40%, transparent); } +.ar-tool__copy:focus-visible { opacity: 1; outline: none; } + +.ar-tool__pre::-webkit-scrollbar { width: 4px; height: 4px; } +.ar-tool__pre::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .1); border-radius: 4px; } +.ar-tool__section--output .ar-tool__pre { + border-left-color: color-mix(in srgb, var(--bulma-success) 38%, transparent); +} {% endblock %} @@ -299,18 +462,152 @@ function handleStreamFrame(frame) { var id = frame.conversationId || 'web'; if (frame.type === 'chunk') { - streamBubble(id).textContent += frame.data; + appendToken(id, frame.data); + } else if (frame.type === 'toolCall') { + renderToolCall(id, frame.data); + } else if (frame.type === 'toolResult') { + renderToolResult(frame.data); } else if (frame.type === 'done') { clearTypingIndicator(); - delete streamBubbles[id]; + endStream(id); } else if (frame.type === 'error') { var bubble = streamBubble(id); bubble.textContent = frame.data; bubble.closest('.ar-msg').classList.add('ar-msg--error'); - delete streamBubbles[id]; + endStream(id); } } + // Tool cards render inside the agent bubble, between runs of text. Each run of + // text therefore needs its own span — assigning to the bubble's textContent would + // wipe out any card already in it. + var toolCards = {}; // tool call id -> { card, startedAt } + var streamTexts = {}; // conversationId -> trailing text span of the open bubble + + var TOOL_ICON = + '' + + '' + + ''; + + function renderToolCall(conversationId, data) { + clearTypingIndicator(); + var bubble = streamBubble(conversationId); + + var card = document.createElement('details'); + card.className = 'ar-tool'; + card.innerHTML = + '' + + '' + + '' + TOOL_ICON + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + section('Input', '') + + '
'; + card.querySelector('.ar-tool__name').textContent = data.name || 'tool'; + card.querySelector('.ar-tool__args').textContent = summarize(data.input); + var inputPre = card.querySelector('.ar-tool__pre'); + inputPre.textContent = pretty(data.input); + wireCopy(card.querySelector('.ar-tool__section')); + bubble.appendChild(card); + // Text arriving after this call starts a new run below the card. + delete streamTexts[conversationId]; + if (data.id) toolCards[data.id] = { card: card, startedAt: Date.now() }; + } + + function renderToolResult(data) { + var entry = data.id ? toolCards[data.id] : null; + if (!entry || !entry.card.isConnected) return; + var card = entry.card; + + card.classList.add('ar-tool--done'); + card.querySelector('.ar-tool__time').textContent = elapsed(Date.now() - entry.startedAt); + + var wrapper = document.createElement('div'); + wrapper.innerHTML = section('Output', ' ar-tool__section--output'); + var outputSection = wrapper.firstElementChild; + outputSection.querySelector('.ar-tool__pre').textContent = pretty(data.output); + card.querySelector('.ar-tool__body').appendChild(outputSection); + wireCopy(outputSection); + delete toolCards[data.id]; + } + + function section(label, extraClass) { + return '
' + + '
' + label + + '
' + + '
' +
+            '
'; + } + + function wireCopy(sectionEl) { + var button = sectionEl.querySelector('.ar-tool__copy'); + button.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(sectionEl.querySelector('.ar-tool__pre').textContent) + .then(function () { + button.textContent = 'Copied'; + setTimeout(function () { button.textContent = 'Copy'; }, 1200); + }) + .catch(function () { button.textContent = 'Failed'; }); + }); + } + + // Collapsed header hint: the first meaningful argument value, else compact JSON. + function summarize(input) { + if (!input) return ''; + try { + var parsed = JSON.parse(input); + if (parsed === null || typeof parsed !== 'object') return String(parsed); + var keys = Object.keys(parsed); + if (!keys.length) return ''; + var first = parsed[keys[0]]; + var text = (typeof first === 'object') ? JSON.stringify(first) : String(first); + return keys.length > 1 ? text + ' +' + (keys.length - 1) : text; + } catch (err) { + return input.length > 60 ? input.slice(0, 60) + '\u2026' : input; + } + } + + function elapsed(ms) { + if (ms < 1000) return ms + 'ms'; + return (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + 's'; + } + + // Tool arguments and results are usually JSON; indent them when they parse. + function pretty(value) { + if (value === null || value === undefined || value === '') return '\u2014'; + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch (err) { + return String(value); + } + } + + // Appends to the trailing text run, starting a new one when a card was just added. + function appendToken(conversationId, token) { + var bubble = streamBubble(conversationId); + var run = streamTexts[conversationId]; + if (!run || run.parentNode !== bubble) { + run = document.createElement('span'); + bubble.appendChild(run); + streamTexts[conversationId] = run; + } + run.textContent += token; + } + + function endStream(conversationId) { + delete streamBubbles[conversationId]; + delete streamTexts[conversationId]; + } + function streamBubble(conversationId) { var bubble = streamBubbles[conversationId]; if (!bubble || !bubble.isConnected) { diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index 7db87c16..cb02697b 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -271,6 +271,30 @@ void chatStreamsTokensAsChunkFramesFollowedByDoneFrame() throws IOException { .doesNotContainKey("data"); } + @Test + void chatStreamsToolCallAndResultFrames() throws IOException { + WebSocketSession session = openSession(); + agentStreams(listener -> { + listener.onToolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}"); + listener.onToolResult("call-1", "readFile", "file contents"); + listener.onComplete(); + }); + + chatChannel.chat("web", "hello"); + + List> frames = capturedFrames(session, 3); + assertThat(frames.get(0)) + .containsEntry("type", "toolCall") + .containsEntry("conversationId", "web") + .containsEntry("data", Map.of("id", "call-1", "name", "readFile", + "input", "{\"path\":\"pom.xml\"}")); + assertThat(frames.get(1)) + .containsEntry("type", "toolResult") + .containsEntry("data", Map.of("id", "call-1", "name", "readFile", + "output", "file contents")); + assertThat(frames.get(2)).containsEntry("type", "done"); + } + @Test void chatStreamsErrorFrameWhenResponseFails() throws IOException { WebSocketSession session = openSession(); diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index e63b4761..0e33a091 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -21,7 +21,8 @@ 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(ChatMemory.CONVERSATION_ID, conversationId) + .advisors(new ToolCallObservingAdvisor(conversationId))) .call() .content(); } @@ -32,7 +33,8 @@ public String respondTo(String conversationId, String question, ResponseListener try { chatClient .prompt(question) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId) + .advisors(new ToolCallObservingAdvisor(conversationId, listener))) .stream() .content() .doOnNext(token -> { @@ -59,7 +61,8 @@ public String respondTo(String conversationId, String question, ResponseListener public T prompt(String conversationId, String input, Class result) { return chatClient .prompt(input) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId) + .advisors(new ToolCallObservingAdvisor(conversationId))) .call() .entity(result); } diff --git a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java index b2d05659..bcf71f1a 100644 --- a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java +++ b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java @@ -25,8 +25,23 @@ public interface ResponseListener { */ void onError(String message); + /** + * Called when the model asks for a tool, before it runs. {@code input} is the raw JSON + * arguments. Default is a no-op, for listeners that only care about text. + */ + default void onToolCall(String id, String name, String input) { + } + + /** + * Called when a tool the model asked for has returned. {@code id} matches the + * {@link #onToolCall} that started it. Default is a no-op. + */ + default void onToolResult(String id, String name, String output) { + } + /** * Creates a listener from three lambdas, avoiding an anonymous class at the call site. + * Tool callbacks stay no-ops; implement the interface directly to observe those. */ static ResponseListener of(Consumer onToken, Runnable onComplete, Consumer onError) { return new ResponseListener() { diff --git a/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java b/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java new file mode 100644 index 00000000..340a3627 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java @@ -0,0 +1,112 @@ +package ai.javaclaw.agent; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; +import org.springframework.ai.chat.client.advisor.api.StreamAdvisor; +import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Reports every tool the model calls, to the log and to an optional {@link ResponseListener}. + *

+ * {@link ToolCallingAdvisor} runs the tool loop and does not pass the assistant chunks that carry + * tool calls on to the outbound stream, so nothing downstream — the agent included — can see them. + * Ordering this advisor inside that loop (closer to the model) is what makes them visible. + *

+ *

+ * Stateful, so build one per request. + *

+ */ +public class ToolCallObservingAdvisor implements CallAdvisor, StreamAdvisor { + + /** Greater than {@link ToolCallingAdvisor#DEFAULT_ORDER}, i.e. inside the tool loop. */ + public static final int ORDER = ToolCallingAdvisor.DEFAULT_ORDER + 100; + + private static final Logger log = LoggerFactory.getLogger(ToolCallObservingAdvisor.class); + private static final ResponseListener LOG_ONLY = ResponseListener.of(_ -> {}, () -> {}, _ -> {}); + + private final String conversationId; + private final ResponseListener listener; + /** + * Calls seen but not yet answered. Doubles as de-duplication: arguments stream in as partial + * JSON across many chunks, so the same call is seen repeatedly. + */ + private final Set pending = ConcurrentHashMap.newKeySet(); + + public ToolCallObservingAdvisor(String conversationId) { + this(conversationId, LOG_ONLY); + } + + /** @param listener also notified of each call and result, so a UI can render them. */ + public ToolCallObservingAdvisor(String conversationId, ResponseListener listener) { + this.conversationId = conversationId; + this.listener = listener; + } + + @Override + public Flux adviseStream(ChatClientRequest request, StreamAdvisorChain chain) { + reportResults(request); + return chain.nextStream(request).doOnNext(this::reportCalls); + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { + reportResults(request); + ChatClientResponse response = chain.nextCall(request); + reportCalls(response); + return response; + } + + /** Calls ride on the responses coming back from the model, before the loop executes them. */ + private void reportCalls(ChatClientResponse response) { + ChatResponse chatResponse = response.chatResponse(); + if (chatResponse == null) { + return; + } + chatResponse.getResults().stream() + .flatMap(generation -> generation.getOutput().getToolCalls().stream()) + .filter(call -> StringUtils.hasText(call.id()) && StringUtils.hasText(call.name())) + .filter(call -> pending.add(call.id())) + .forEach(call -> { + log.info("Conversation {} calls tool {}", conversationId, call.name()); + // Arguments carry whatever the user typed and can be large — keep them off INFO. + log.debug("Conversation {} tool {} arguments: {}", + conversationId, call.name(), call.arguments()); + listener.onToolCall(call.id(), call.name(), call.arguments()); + }); + } + + /** Their results arrive as message history on the next round of the loop. */ + private void reportResults(ChatClientRequest request) { + request.prompt().getInstructions().stream() + .filter(ToolResponseMessage.class::isInstance) + .map(ToolResponseMessage.class::cast) + .flatMap(message -> message.getResponses().stream()) + .filter(result -> pending.remove(result.id())) + .forEach(result -> { + log.info("Conversation {} finished tool {}", conversationId, result.name()); + listener.onToolResult(result.id(), result.name(), result.responseData()); + }); + } + + @Override + public String getName() { + return "toolCallObservingAdvisor"; + } + + @Override + public int getOrder() { + return ORDER; + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java new file mode 100644 index 00000000..0f47b749 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java @@ -0,0 +1,173 @@ +package ai.javaclaw.agent; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; +import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ToolCallObservingAdvisorTest { + + @Mock StreamAdvisorChain chain; + + ToolCallObservingAdvisor advisor; + ListAppender logs; + + @BeforeEach + void setUp() { + advisor = new ToolCallObservingAdvisor("web"); + logs = captureLogsOf(ToolCallObservingAdvisor.class); + } + + @Test + void runsInsideTheToolLoopWhereToolCallsAreStillVisible() { + assertThat(advisor.getOrder()).isGreaterThan(ToolCallingAdvisor.DEFAULT_ORDER); + } + + @Test + void logsEachToolTheModelCalls() { + when(chain.nextStream(any())).thenReturn(Flux.just(responseWith(toolCall("call-1", "readFile")))); + + advisor.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + + assertThat(infoMessages()).containsExactly("Conversation web calls tool readFile"); + } + + @Test + void logsAToolCallOnceEvenThoughArgumentsArriveAcrossChunks() { + when(chain.nextStream(any())).thenReturn(Flux.just( + responseWith(toolCall("call-1", "readFile")), + responseWith(toolCall("call-1", "readFile")), + responseWith(toolCall("call-1", "readFile")))); + + advisor.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + + assertThat(infoMessages()).containsExactly("Conversation web calls tool readFile"); + } + + @Test + void logsCompletionWhenTheNextRoundCarriesTheToolResponse() { + when(chain.nextStream(any())).thenReturn(Flux.just(responseWith(toolCall("call-1", "readFile")))); + advisor.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + + ToolResponseMessage toolResponse = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call-1", "readFile", "file contents"))) + .build(); + when(chain.nextStream(any())).thenReturn(Flux.empty()); + advisor.adviseStream(request(new UserMessage("hi"), toolResponse), chain).blockLast(); + + assertThat(infoMessages()).containsExactly( + "Conversation web calls tool readFile", + "Conversation web finished tool readFile"); + } + + @Test + void reportsToolNameInputAndOutputToTheListener() { + List seen = new ArrayList<>(); + ToolCallObservingAdvisor observed = new ToolCallObservingAdvisor("web", new ResponseListener() { + @Override + public void onToken(String token) { + } + + @Override + public void onToolCall(String id, String name, String input) { + seen.add("call " + id + " " + name + " " + input); + } + + @Override + public void onToolResult(String id, String name, String output) { + seen.add("result " + id + " " + name + " " + output); + } + + @Override + public void onComplete() { + } + + @Override + public void onError(String message) { + } + }); + + when(chain.nextStream(any())).thenReturn(Flux.just(responseWith(toolCall("call-1", "readFile")))); + observed.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + + ToolResponseMessage toolResponse = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call-1", "readFile", "file contents"))) + .build(); + when(chain.nextStream(any())).thenReturn(Flux.empty()); + observed.adviseStream(request(new UserMessage("hi"), toolResponse), chain).blockLast(); + + assertThat(seen).containsExactly( + "call call-1 readFile {\"path\":\"pom.xml\"}", + "result call-1 readFile file contents"); + } + + @Test + void ignoresOrdinaryTextResponses() { + when(chain.nextStream(any())).thenReturn(Flux.just( + ChatClientResponse.builder() + .chatResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("hello"))))) + .build())); + + advisor.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + + assertThat(infoMessages()).isEmpty(); + } + + private List infoMessages() { + return logs.list.stream() + .filter(event -> event.getLevel() == Level.INFO) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static ChatClientRequest request(org.springframework.ai.chat.messages.Message... messages) { + return ChatClientRequest.builder().prompt(new Prompt(List.of(messages))).build(); + } + + private static AssistantMessage.ToolCall toolCall(String id, String name) { + return new AssistantMessage.ToolCall(id, "function", name, "{\"path\":\"pom.xml\"}"); + } + + private static ChatClientResponse responseWith(AssistantMessage.ToolCall call) { + AssistantMessage message = AssistantMessage.builder() + .content("") + .toolCalls(List.of(call)) + .build(); + return ChatClientResponse.builder() + .chatResponse(new ChatResponse(List.of(new Generation(message)))) + .build(); + } + + private static ListAppender captureLogsOf(Class type) { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(type); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + return appender; + } +} From 1fe366cfcf1284e67d2b9d0931a25cec81259f72 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Mon, 14 Sep 2026 20:11:20 +0300 Subject: [PATCH 2/4] Report agent progress as events instead of callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResponseListener fused two unrelated concerns: streaming text (onToken, onComplete, onError) and observing tool calls (onToolCall, onToolResult). The advisor only ever emitted the tool pair, so it had to carry a fake listener whose other three methods were no-ops, and its factory covered only three of the five callbacks, leaving the web chat to spell out a 25-line anonymous class. Adding a callback as a default method also meant every existing implementor kept compiling and silently dropped the new event. Replace it with AgentEvent, a sealed interface of Token, ToolCall, ToolResult, Done and Failed, consumed as a Consumer. Producers emit values rather than invoking callbacks, so the web chat is now one exhaustive switch mapping each event onto the stream frame it already had, and a new event kind becomes a compile error at every consumer instead of silence. Tests record events and assert the sequence, which drops the mocks and their ordered verification. The streaming respondTo and ChatChannel.chat now return void. Their text already reaches the caller as events — the WebSocket handler discarded the returned string — so returning it again only kept a StringBuilder alive across three exit paths. The blocking respondTo still returns its answer, which the Discord, Telegram and WhatsApp channels send on. --- .../java/ai/javaclaw/chat/ChatChannel.java | 60 +++++------------ .../ai/javaclaw/chat/ChatChannelTest.java | 50 +++++++------- .../chat/ws/ChatStreamingIntegrationTest.java | 13 ++-- .../chat/ws/ChatWebSocketHandlerTest.java | 7 +- .../main/java/ai/javaclaw/agent/Agent.java | 14 ++-- .../java/ai/javaclaw/agent/AgentEvent.java | 24 +++++++ .../java/ai/javaclaw/agent/DefaultAgent.java | 45 +++++-------- .../ai/javaclaw/agent/ResponseListener.java | 65 ------------------- .../agent/ToolCallObservingAdvisor.java | 20 +++--- .../ai/javaclaw/agent/DefaultAgentTest.java | 41 +++++------- .../agent/ToolCallObservingAdvisorTest.java | 32 ++------- 11 files changed, 131 insertions(+), 240 deletions(-) create mode 100644 base/src/main/java/ai/javaclaw/agent/AgentEvent.java delete mode 100644 base/src/main/java/ai/javaclaw/agent/ResponseListener.java diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index d64de7de..fd2c61be 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -1,7 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; -import ai.javaclaw.agent.ResponseListener; +import ai.javaclaw.agent.AgentEvent; import ai.javaclaw.channels.Channel; import ai.javaclaw.channels.ChannelMessageReceivedEvent; import ai.javaclaw.channels.ChannelRegistry; @@ -143,59 +143,29 @@ public List loadHistoryAsHtml(String conversationId) { /** * Handles a chat message from the web UI for the given conversationId. * The response is streamed to the WebSocket session as JSON frames - * ({@code chunk}/{@code done}/{@code error}); the full response text is returned. + * ({@code chunk}/{@code toolCall}/{@code toolResult}/{@code done}/{@code error}). */ - public String chat(String conversationId, String message) { + public void chat(String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message, new ResponseListener() { - - @Override - public void onToken(String token) { - sendChunkFrame(conversationId, token); - } - - @Override - public void onToolCall(String id, String name, String input) { - sendToolFrame(StreamFrameType.TOOL_CALL, conversationId, id, name, "input", input); - } - - @Override - public void onToolResult(String id, String name, String output) { - sendToolFrame(StreamFrameType.TOOL_RESULT, conversationId, id, name, "output", output); - } - - @Override - public void onComplete() { - sendDoneFrame(conversationId); - } - - @Override - public void onError(String error) { - sendErrorFrame(conversationId, error); - } - }); + agent.respondTo(conversationId, message, event -> sendFrame(switch (event) { + case AgentEvent.Token t -> frame(StreamFrameType.CHUNK, conversationId, t.text()); + case AgentEvent.ToolCall c -> frame(StreamFrameType.TOOL_CALL, conversationId, + toolData(c.id(), c.name(), "input", c.input())); + case AgentEvent.ToolResult r -> frame(StreamFrameType.TOOL_RESULT, conversationId, + toolData(r.id(), r.name(), "output", r.output())); + case AgentEvent.Done ignored -> frame(StreamFrameType.DONE, conversationId, null); + case AgentEvent.Failed f -> frame(StreamFrameType.ERROR, conversationId, + f.message() == null ? "Unknown error" : f.message()); + })); } - private void sendToolFrame(StreamFrameType type, String conversationId, - String id, String name, String payloadKey, String payload) { + private static Map toolData(String id, String name, String payloadKey, String payload) { Map data = new LinkedHashMap<>(); data.put("id", id); data.put("name", name); data.put(payloadKey, payload == null ? "" : payload); - sendFrame(frame(type, conversationId, data)); - } - - private void sendChunkFrame(String conversationId, String token) { - sendFrame(frame(StreamFrameType.CHUNK, conversationId, token)); - } - - private void sendDoneFrame(String conversationId) { - sendFrame(frame(StreamFrameType.DONE, conversationId, null)); - } - - private void sendErrorFrame(String conversationId, String error) { - sendFrame(frame(StreamFrameType.ERROR, conversationId, error == null ? "Unknown error" : error)); + return data; } private static Map frame(StreamFrameType type, String conversationId, Object payload) { diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index cb02697b..ac9335f9 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -1,7 +1,7 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; -import ai.javaclaw.agent.ResponseListener; +import ai.javaclaw.agent.AgentEvent; import ai.javaclaw.channels.ChannelRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -134,21 +134,16 @@ void loadHistoryUsesSuppliedConversationId() { @Test void chatDelegatesToAgentWithConversationId() { - when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenReturn("hi"); - - String response = chatChannel.chat("web", "hello"); + chatChannel.chat("web", "hello"); - assertThat(response).isEqualTo("hi"); - verify(agent).respondTo(eq("web"), eq("hello"), any(ResponseListener.class)); + verify(agent).respondTo(eq("web"), eq("hello"), anyConsumer()); } @Test void chatUsesSuppliedConversationId() { - when(agent.respondTo(eq("telegram-42"), any(), any(ResponseListener.class))).thenReturn("reply"); - chatChannel.chat("telegram-42", "hello"); - verify(agent).respondTo(eq("telegram-42"), eq("hello"), any(ResponseListener.class)); + verify(agent).respondTo(eq("telegram-42"), eq("hello"), anyConsumer()); } // ----------------------------------------------------------------------- @@ -249,10 +244,10 @@ void flushPendingMessagesDoesNothingWhenBufferIsEmpty() throws IOException { @Test void chatStreamsTokensAsChunkFramesFollowedByDoneFrame() throws IOException { WebSocketSession session = openSession(); - agentStreams(listener -> { - listener.onToken("Hello "); - listener.onToken("world"); - listener.onComplete(); + agentStreams(events -> { + events.accept(new AgentEvent.Token("Hello ")); + events.accept(new AgentEvent.Token("world")); + events.accept(new AgentEvent.Done()); }); chatChannel.chat("web", "hello"); @@ -274,10 +269,10 @@ void chatStreamsTokensAsChunkFramesFollowedByDoneFrame() throws IOException { @Test void chatStreamsToolCallAndResultFrames() throws IOException { WebSocketSession session = openSession(); - agentStreams(listener -> { - listener.onToolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}"); - listener.onToolResult("call-1", "readFile", "file contents"); - listener.onComplete(); + agentStreams(events -> { + events.accept(new AgentEvent.ToolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}")); + events.accept(new AgentEvent.ToolResult("call-1", "readFile", "file contents")); + events.accept(new AgentEvent.Done()); }); chatChannel.chat("web", "hello"); @@ -298,7 +293,7 @@ void chatStreamsToolCallAndResultFrames() throws IOException { @Test void chatStreamsErrorFrameWhenResponseFails() throws IOException { WebSocketSession session = openSession(); - agentStreams(listener -> listener.onError("boom")); + agentStreams(events -> events.accept(new AgentEvent.Failed("boom"))); chatChannel.chat("web", "hello"); @@ -311,15 +306,20 @@ void chatStreamsErrorFrameWhenResponseFails() throws IOException { @Test void chatDropsStreamFramesWhenNoSessionIsActive() { - agentStreams(listener -> { - listener.onToken("Hello"); - listener.onComplete(); + agentStreams(events -> { + events.accept(new AgentEvent.Token("Hello")); + events.accept(new AgentEvent.Done()); }); // should not throw chatChannel.chat("web", "hello"); } + @SuppressWarnings("unchecked") + private static java.util.function.Consumer anyConsumer() { + return any(java.util.function.Consumer.class); + } + private WebSocketSession openSession() { WebSocketSession session = mock(WebSocketSession.class); when(session.isOpen()).thenReturn(true); @@ -327,11 +327,11 @@ private WebSocketSession openSession() { return session; } - private void agentStreams(java.util.function.Consumer progress) { - when(agent.respondTo(eq("web"), eq("hello"), any(ResponseListener.class))).thenAnswer(invocation -> { + private void agentStreams(java.util.function.Consumer> progress) { + org.mockito.Mockito.doAnswer(invocation -> { progress.accept(invocation.getArgument(2)); - return ""; - }); + return null; + }).when(agent).respondTo(eq("web"), eq("hello"), anyConsumer()); } @SuppressWarnings("unchecked") diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java index cf177b09..114a0274 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java @@ -1,7 +1,9 @@ package ai.javaclaw.chat.ws; import ai.javaclaw.agent.Agent; -import ai.javaclaw.agent.ResponseListener; +import ai.javaclaw.agent.AgentEvent; + +import java.util.function.Consumer; import ai.javaclaw.channels.ChannelRegistry; import ai.javaclaw.chat.ChatChannel; import org.junit.jupiter.api.Test; @@ -122,11 +124,10 @@ public String respondTo(String conversationId, String question) { } @Override - public String respondTo(String conversationId, String question, ResponseListener listener) { - listener.onToken("Hello "); - listener.onToken("world"); - listener.onComplete(); - return "Hello world"; + public void respondTo(String conversationId, String question, Consumer events) { + events.accept(new AgentEvent.Token("Hello ")); + events.accept(new AgentEvent.Token("world")); + events.accept(new AgentEvent.Done()); } @Override diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java index 90902957..68d3a017 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java @@ -26,14 +26,14 @@ void handleUserMessageShowsNonTransientErrorAndClearsTypingIndicatorWhenAgentFai WebSocketSession session = mock(WebSocketSession.class); ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); - when(chatChannel.chat("web", "hello")).thenThrow(new RuntimeException(""" + org.mockito.Mockito.doThrow(new RuntimeException(""" HTTP 401 - { "error": { "message": "Incorrect API key provided: Test.", "code": "invalid_api_key" } } - """)); + """)).when(chatChannel).chat("web", "hello"); handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( "type", "userMessage", @@ -66,7 +66,7 @@ void handleUserMessageShowsGenericProviderErrorForUnexpectedFailures() throws Ex WebSocketSession session = mock(WebSocketSession.class); ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); - when(chatChannel.chat(anyString(), anyString())).thenThrow(new RuntimeException("boom")); + org.mockito.Mockito.doThrow(new RuntimeException("boom")).when(chatChannel).chat(anyString(), anyString()); handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( "type", "userMessage", @@ -92,7 +92,6 @@ void handleUserMessageClearsTypingIndicatorWithoutAppendingBubbleWhenResponseWas ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); // the response is streamed to the client by ChatChannel while chat() runs - when(chatChannel.chat("web", "hello")).thenReturn("streamed response"); handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( "type", "userMessage", diff --git a/base/src/main/java/ai/javaclaw/agent/Agent.java b/base/src/main/java/ai/javaclaw/agent/Agent.java index 13836ce9..aa6d3cba 100644 --- a/base/src/main/java/ai/javaclaw/agent/Agent.java +++ b/base/src/main/java/ai/javaclaw/agent/Agent.java @@ -1,14 +1,18 @@ package ai.javaclaw.agent; +import java.util.function.Consumer; + public interface Agent { String respondTo(String conversationId, String question); - default String respondTo(String conversationId, String question, ResponseListener listener) { - String response = respondTo(conversationId, question); - listener.onToken(response); - listener.onComplete(); - return response; + /** + * Answers, reporting progress as {@link AgentEvent}s. Where the events go — WebSocket, SSE, + * console — is entirely the caller's concern. + */ + default void respondTo(String conversationId, String question, Consumer events) { + events.accept(new AgentEvent.Token(respondTo(conversationId, question))); + events.accept(new AgentEvent.Done()); } T prompt(String conversationId, String input, Class result); diff --git a/base/src/main/java/ai/javaclaw/agent/AgentEvent.java b/base/src/main/java/ai/javaclaw/agent/AgentEvent.java new file mode 100644 index 00000000..870e4030 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/agent/AgentEvent.java @@ -0,0 +1,24 @@ +package ai.javaclaw.agent; + +/** + * Something the agent produced while answering. Callers that render progress consume these as a + * single stream; the sequence is zero or more {@link Token}/{@link ToolCall}/{@link ToolResult} + * events, terminated by exactly one {@link Done} or {@link Failed}. + */ +public sealed interface AgentEvent { + + /** A piece of the answer, as it arrives from the model. */ + record Token(String text) implements AgentEvent {} + + /** The model asked for a tool; {@code input} is the raw JSON arguments. */ + record ToolCall(String id, String name, String input) implements AgentEvent {} + + /** A tool returned. {@code id} matches the {@link ToolCall} that started it. */ + record ToolResult(String id, String name, String output) implements AgentEvent {} + + /** The answer is complete. Always the last event. */ + record Done() implements AgentEvent {} + + /** The answer failed. Always the last event; tokens already emitted still stand. */ + record Failed(String message) implements AgentEvent {} +} diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index 0e33a091..2b1a057e 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -5,6 +5,7 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.stereotype.Component; +import java.util.function.Consumer; @Component public class DefaultAgent implements Agent { @@ -19,52 +20,40 @@ public DefaultAgent(ChatClient chatClient) { @Override public String respondTo(String conversationId, String question) { - return chatClient - .prompt(question) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId) - .advisors(new ToolCallObservingAdvisor(conversationId))) - .call() - .content(); + return ask(conversationId, question, _ -> {}).call().content(); } @Override - public String respondTo(String conversationId, String question, ResponseListener listener) { - StringBuilder fullResponse = new StringBuilder(); + public void respondTo(String conversationId, String question, Consumer events) { try { - chatClient - .prompt(question) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId) - .advisors(new ToolCallObservingAdvisor(conversationId, listener))) + ask(conversationId, question, events) .stream() .content() - .doOnNext(token -> { - fullResponse.append(token); - listener.onToken(token); - }) + .doOnNext(token -> events.accept(new AgentEvent.Token(token))) .blockLast(); + events.accept(new AgentEvent.Done()); } catch (UnsupportedOperationException e) { // The configured model cannot stream — fall back to the blocking call - String response = respondTo(conversationId, question); - listener.onToken(response); - listener.onComplete(); - return response; + events.accept(new AgentEvent.Token(respondTo(conversationId, question))); + events.accept(new AgentEvent.Done()); } catch (RuntimeException e) { log.warn("Streaming response failed for conversation {}", conversationId, e); - listener.onError(summarizeError(e)); - return fullResponse.toString(); + events.accept(new AgentEvent.Failed(summarizeError(e))); } - listener.onComplete(); - return fullResponse.toString(); } @Override public T prompt(String conversationId, String input, Class result) { + return ask(conversationId, input, _ -> {}).call().entity(result); + } + + /** Every request carries the conversation id and observes its tool calls. */ + private ChatClient.ChatClientRequestSpec ask(String conversationId, String question, + Consumer events) { return chatClient - .prompt(input) + .prompt(question) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId) - .advisors(new ToolCallObservingAdvisor(conversationId))) - .call() - .entity(result); + .advisors(new ToolCallObservingAdvisor(conversationId, events))); } private static String summarizeError(Throwable ex) { diff --git a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java b/base/src/main/java/ai/javaclaw/agent/ResponseListener.java deleted file mode 100644 index bcf71f1a..00000000 --- a/base/src/main/java/ai/javaclaw/agent/ResponseListener.java +++ /dev/null @@ -1,65 +0,0 @@ -package ai.javaclaw.agent; - -import java.util.function.Consumer; - -/** - * Callback for observing an agent response as it is produced. Callers that can - * render progress incrementally (e.g. the web chat) supply an implementation; - * where the tokens go (WebSocket, SSE, console) is entirely the caller's concern. - */ -public interface ResponseListener { - - /** - * Called for every token as it arrives from the model. - */ - void onToken(String token); - - /** - * Called once after the last token; no further callbacks follow. - */ - void onComplete(); - - /** - * Called when the response fails; no further callbacks follow. Tokens already - * delivered via {@link #onToken(String)} may precede the failure. - */ - void onError(String message); - - /** - * Called when the model asks for a tool, before it runs. {@code input} is the raw JSON - * arguments. Default is a no-op, for listeners that only care about text. - */ - default void onToolCall(String id, String name, String input) { - } - - /** - * Called when a tool the model asked for has returned. {@code id} matches the - * {@link #onToolCall} that started it. Default is a no-op. - */ - default void onToolResult(String id, String name, String output) { - } - - /** - * Creates a listener from three lambdas, avoiding an anonymous class at the call site. - * Tool callbacks stay no-ops; implement the interface directly to observe those. - */ - static ResponseListener of(Consumer onToken, Runnable onComplete, Consumer onError) { - return new ResponseListener() { - - @Override - public void onToken(String token) { - onToken.accept(token); - } - - @Override - public void onComplete() { - onComplete.run(); - } - - @Override - public void onError(String message) { - onError.accept(message); - } - }; - } -} diff --git a/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java b/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java index 340a3627..836a9b43 100644 --- a/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java +++ b/base/src/main/java/ai/javaclaw/agent/ToolCallObservingAdvisor.java @@ -15,10 +15,11 @@ import reactor.core.publisher.Flux; import java.util.Set; +import java.util.function.Consumer; import java.util.concurrent.ConcurrentHashMap; /** - * Reports every tool the model calls, to the log and to an optional {@link ResponseListener}. + * Reports every tool the model calls, to the log and to an optional {@link AgentEvent} consumer. *

* {@link ToolCallingAdvisor} runs the tool loop and does not pass the assistant chunks that carry * tool calls on to the outbound stream, so nothing downstream — the agent included — can see them. @@ -34,24 +35,19 @@ public class ToolCallObservingAdvisor implements CallAdvisor, StreamAdvisor { public static final int ORDER = ToolCallingAdvisor.DEFAULT_ORDER + 100; private static final Logger log = LoggerFactory.getLogger(ToolCallObservingAdvisor.class); - private static final ResponseListener LOG_ONLY = ResponseListener.of(_ -> {}, () -> {}, _ -> {}); private final String conversationId; - private final ResponseListener listener; + private final Consumer events; /** * Calls seen but not yet answered. Doubles as de-duplication: arguments stream in as partial * JSON across many chunks, so the same call is seen repeatedly. */ private final Set pending = ConcurrentHashMap.newKeySet(); - public ToolCallObservingAdvisor(String conversationId) { - this(conversationId, LOG_ONLY); - } - - /** @param listener also notified of each call and result, so a UI can render them. */ - public ToolCallObservingAdvisor(String conversationId, ResponseListener listener) { + /** @param events receives each call and result, so a UI can render them; may be a no-op. */ + public ToolCallObservingAdvisor(String conversationId, Consumer events) { this.conversationId = conversationId; - this.listener = listener; + this.events = events; } @Override @@ -83,7 +79,7 @@ private void reportCalls(ChatClientResponse response) { // Arguments carry whatever the user typed and can be large — keep them off INFO. log.debug("Conversation {} tool {} arguments: {}", conversationId, call.name(), call.arguments()); - listener.onToolCall(call.id(), call.name(), call.arguments()); + events.accept(new AgentEvent.ToolCall(call.id(), call.name(), call.arguments())); }); } @@ -96,7 +92,7 @@ private void reportResults(ChatClientRequest request) { .filter(result -> pending.remove(result.id())) .forEach(result -> { log.info("Conversation {} finished tool {}", conversationId, result.name()); - listener.onToolResult(result.id(), result.name(), result.responseData()); + events.accept(new AgentEvent.ToolResult(result.id(), result.name(), result.responseData())); }); } diff --git a/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java index 4652fe33..8749de54 100644 --- a/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java +++ b/base/src/test/java/ai/javaclaw/agent/DefaultAgentTest.java @@ -8,13 +8,12 @@ import org.springframework.ai.chat.client.ChatClient; import reactor.core.publisher.Flux; +import java.util.ArrayList; +import java.util.List; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -24,7 +23,9 @@ class DefaultAgentTest { @Mock ChatClient.ChatClientRequestSpec requestSpec; @Mock ChatClient.StreamResponseSpec streamSpec; @Mock ChatClient.CallResponseSpec callSpec; - @Mock ResponseListener listener; + + /** Events are a stream, so record and assert the sequence rather than mocking callbacks. */ + final List events = new ArrayList<>(); DefaultAgent agent; @@ -41,14 +42,12 @@ void setUp() { void reportsEachTokenAndCompletionToListener() { when(streamSpec.content()).thenReturn(Flux.just("Hello ", "world")); - String response = agent.respondTo("web", "hello", listener); + agent.respondTo("web", "hello", events::add); - assertThat(response).isEqualTo("Hello world"); - var inOrder = inOrder(listener); - inOrder.verify(listener).onToken("Hello "); - inOrder.verify(listener).onToken("world"); - inOrder.verify(listener).onComplete(); - verify(listener, never()).onError(any()); + assertThat(events).containsExactly( + new AgentEvent.Token("Hello "), + new AgentEvent.Token("world"), + new AgentEvent.Done()); } @Test @@ -57,13 +56,11 @@ void reportsErrorAndReturnsPartialResponseWhenStreamFails() { Flux.just("Hello "), Flux.error(new RuntimeException("boom")))); - String response = agent.respondTo("web", "hello", listener); + agent.respondTo("web", "hello", events::add); - assertThat(response).isEqualTo("Hello "); - var inOrder = inOrder(listener); - inOrder.verify(listener).onToken("Hello "); - inOrder.verify(listener).onError("boom"); - verify(listener, never()).onComplete(); + assertThat(events).containsExactly( + new AgentEvent.Token("Hello "), + new AgentEvent.Failed("boom")); } @Test @@ -72,12 +69,10 @@ void fallsBackToBlockingCallWhenModelDoesNotSupportStreaming() { when(requestSpec.call()).thenReturn(callSpec); when(callSpec.content()).thenReturn("full response"); - String response = agent.respondTo("web", "hello", listener); + agent.respondTo("web", "hello", events::add); - assertThat(response).isEqualTo("full response"); - var inOrder = inOrder(listener); - inOrder.verify(listener).onToken("full response"); - inOrder.verify(listener).onComplete(); - verify(listener, never()).onError(any()); + assertThat(events).containsExactly( + new AgentEvent.Token("full response"), + new AgentEvent.Done()); } } diff --git a/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java index 0f47b749..36578c8a 100644 --- a/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java +++ b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java @@ -38,7 +38,7 @@ class ToolCallObservingAdvisorTest { @BeforeEach void setUp() { - advisor = new ToolCallObservingAdvisor("web"); + advisor = new ToolCallObservingAdvisor("web", event -> {}); logs = captureLogsOf(ToolCallObservingAdvisor.class); } @@ -86,30 +86,8 @@ void logsCompletionWhenTheNextRoundCarriesTheToolResponse() { @Test void reportsToolNameInputAndOutputToTheListener() { - List seen = new ArrayList<>(); - ToolCallObservingAdvisor observed = new ToolCallObservingAdvisor("web", new ResponseListener() { - @Override - public void onToken(String token) { - } - - @Override - public void onToolCall(String id, String name, String input) { - seen.add("call " + id + " " + name + " " + input); - } - - @Override - public void onToolResult(String id, String name, String output) { - seen.add("result " + id + " " + name + " " + output); - } - - @Override - public void onComplete() { - } - - @Override - public void onError(String message) { - } - }); + List seen = new ArrayList<>(); + ToolCallObservingAdvisor observed = new ToolCallObservingAdvisor("web", seen::add); when(chain.nextStream(any())).thenReturn(Flux.just(responseWith(toolCall("call-1", "readFile")))); observed.adviseStream(request(new UserMessage("hi")), chain).blockLast(); @@ -121,8 +99,8 @@ public void onError(String message) { observed.adviseStream(request(new UserMessage("hi"), toolResponse), chain).blockLast(); assertThat(seen).containsExactly( - "call call-1 readFile {\"path\":\"pom.xml\"}", - "result call-1 readFile file contents"); + new AgentEvent.ToolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}"), + new AgentEvent.ToolResult("call-1", "readFile", "file contents")); } @Test From eba16d887136da5ec5a541c1785131e43aefc1d9 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Mon, 14 Sep 2026 20:17:33 +0300 Subject: [PATCH 3/4] Polish Tests --- .../test/java/ai/javaclaw/chat/ChatChannelTest.java | 9 +++------ .../ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java | 10 +++------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index ac9335f9..8278c1f9 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -22,10 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ChatChannelTest { @@ -217,7 +214,7 @@ void sendMessagePushesOobHtmlToActiveSession() throws IOException { void flushPendingMessagesDeliversMessagesBufferedWhileSendFailed() throws IOException { WebSocketSession failingSession = mock(WebSocketSession.class); when(failingSession.isOpen()).thenReturn(true); - org.mockito.Mockito.doThrow(new IOException("connection gone")).when(failingSession).sendMessage(any()); + doThrow(new IOException("connection gone")).when(failingSession).sendMessage(any()); chatChannel.setWsSession(failingSession); chatChannel.sendMessage("Background result"); @@ -328,7 +325,7 @@ private WebSocketSession openSession() { } private void agentStreams(java.util.function.Consumer> progress) { - org.mockito.Mockito.doAnswer(invocation -> { + doAnswer(invocation -> { progress.accept(invocation.getArgument(2)); return null; }).when(agent).respondTo(eq("web"), eq("hello"), anyConsumer()); diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java index 68d3a017..704924be 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatWebSocketHandlerTest.java @@ -12,11 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class ChatWebSocketHandlerTest { @@ -26,7 +22,7 @@ void handleUserMessageShowsNonTransientErrorAndClearsTypingIndicatorWhenAgentFai WebSocketSession session = mock(WebSocketSession.class); ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); - org.mockito.Mockito.doThrow(new RuntimeException(""" + doThrow(new RuntimeException(""" HTTP 401 - { "error": { "message": "Incorrect API key provided: Test.", @@ -66,7 +62,7 @@ void handleUserMessageShowsGenericProviderErrorForUnexpectedFailures() throws Ex WebSocketSession session = mock(WebSocketSession.class); ChatWebSocketHandler handler = new ChatWebSocketHandler(chatChannel, new ObjectMapper()); - org.mockito.Mockito.doThrow(new RuntimeException("boom")).when(chatChannel).chat(anyString(), anyString()); + doThrow(new RuntimeException("boom")).when(chatChannel).chat(anyString(), anyString()); handler.handleTextMessage(session, new TextMessage(new ObjectMapper().writeValueAsString(Map.of( "type", "userMessage", From 5538e0af2979d4489fcf1ac3921f10550be9a890 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sun, 25 Oct 2026 10:09:15 +0200 Subject: [PATCH 4/4] Harden tool-call reporting in the web chat - Report each round's tool calls once the round completes, with the last arguments seen, so a provider streaming partial JSON never shows a fragment - Keep reporting tool calls and results when the model cannot stream - Settle tool cards left open when the answer ends ("no result"/"failed"), and append errors instead of wiping cards already rendered - Test the whole tool loop through a real ChatClient and ToolCallingAdvisor --- .../main/resources/templates/chat.html.peb | 36 ++++-- .../java/ai/javaclaw/agent/DefaultAgent.java | 4 +- .../agent/ToolCallObservingAdvisor.java | 48 +++++--- .../agent/DefaultAgentToolLoopTest.java | 110 ++++++++++++++++++ .../agent/ToolCallObservingAdvisorTest.java | 33 ++++-- 5 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 base/src/test/java/ai/javaclaw/agent/DefaultAgentToolLoopTest.java diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 3ce7f33c..e8707392 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -259,6 +259,17 @@ opacity: 1; background: var(--bulma-success); } +/* The answer ended without this tool reporting back (returnDirect, or it threw). */ +.ar-tool--unanswered .ar-tool__dot { + animation: none; + opacity: .5; + background: rgba(200, 212, 240, .6); +} +.ar-tool--failed .ar-tool__dot { + animation: none; + opacity: 1; + background: var(--bulma-danger); +} .ar-tool__body { padding: .6rem .7rem .7rem; @@ -469,19 +480,20 @@ renderToolResult(frame.data); } else if (frame.type === 'done') { clearTypingIndicator(); - endStream(id); + endStream(id, 'ar-tool--unanswered', 'no result'); } else if (frame.type === 'error') { - var bubble = streamBubble(id); - bubble.textContent = frame.data; - bubble.closest('.ar-msg').classList.add('ar-msg--error'); - endStream(id); + // Append rather than replace: text and tool cards already shown still stand. + delete streamTexts[id]; + appendToken(id, frame.data); + streamBubble(id).closest('.ar-msg').classList.add('ar-msg--error'); + endStream(id, 'ar-tool--failed', 'failed'); } } // Tool cards render inside the agent bubble, between runs of text. Each run of // text therefore needs its own span — assigning to the bubble's textContent would // wipe out any card already in it. - var toolCards = {}; // tool call id -> { card, startedAt } + var toolCards = {}; // tool call id -> { card, conversationId, startedAt } var streamTexts = {}; // conversationId -> trailing text span of the open bubble var TOOL_ICON = @@ -518,7 +530,7 @@ bubble.appendChild(card); // Text arriving after this call starts a new run below the card. delete streamTexts[conversationId]; - if (data.id) toolCards[data.id] = { card: card, startedAt: Date.now() }; + if (data.id) toolCards[data.id] = { card: card, conversationId: conversationId, startedAt: Date.now() }; } function renderToolResult(data) { @@ -603,7 +615,15 @@ run.textContent += token; } - function endStream(conversationId) { + // Cards still waiting when the answer ends will never get a result — settle them. + function endStream(conversationId, unfinishedClass, unfinishedLabel) { + Object.keys(toolCards).forEach(function (callId) { + var entry = toolCards[callId]; + if (entry.conversationId !== conversationId) return; + entry.card.classList.add(unfinishedClass); + entry.card.querySelector('.ar-tool__time').textContent = unfinishedLabel; + delete toolCards[callId]; + }); delete streamBubbles[conversationId]; delete streamTexts[conversationId]; } diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index 2b1a057e..622cced2 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -33,8 +33,8 @@ public void respondTo(String conversationId, String question, Consumer events; - /** - * Calls seen but not yet answered. Doubles as de-duplication: arguments stream in as partial - * JSON across many chunks, so the same call is seen repeatedly. - */ + /** Calls reported but not yet answered, so each result is reported once. */ private final Set pending = ConcurrentHashMap.newKeySet(); /** @param events receives each call and result, so a UI can render them; may be a no-op. */ @@ -53,34 +54,43 @@ public ToolCallObservingAdvisor(String conversationId, Consumer even @Override public Flux adviseStream(ChatClientRequest request, StreamAdvisorChain chain) { reportResults(request); - return chain.nextStream(request).doOnNext(this::reportCalls); + // The bundled providers deliver each call whole, but nothing in the advisor contract + // promises that. Report once the round ends — still before the loop runs the tools — + // keeping the last arguments seen, so a provider that grows them chunk by chunk shows + // the final ones rather than a fragment. + Map round = new LinkedHashMap<>(); + return chain.nextStream(request) + .doOnNext(response -> toolCalls(response).forEach(call -> round.put(call.id(), call))) + .doOnComplete(() -> round.values().forEach(this::reportCall)); } @Override public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { reportResults(request); ChatClientResponse response = chain.nextCall(request); - reportCalls(response); + toolCalls(response).forEach(this::reportCall); return response; } /** Calls ride on the responses coming back from the model, before the loop executes them. */ - private void reportCalls(ChatClientResponse response) { + private static Stream toolCalls(ChatClientResponse response) { ChatResponse chatResponse = response.chatResponse(); if (chatResponse == null) { - return; + return Stream.empty(); } - chatResponse.getResults().stream() + return chatResponse.getResults().stream() .flatMap(generation -> generation.getOutput().getToolCalls().stream()) - .filter(call -> StringUtils.hasText(call.id()) && StringUtils.hasText(call.name())) - .filter(call -> pending.add(call.id())) - .forEach(call -> { - log.info("Conversation {} calls tool {}", conversationId, call.name()); - // Arguments carry whatever the user typed and can be large — keep them off INFO. - log.debug("Conversation {} tool {} arguments: {}", - conversationId, call.name(), call.arguments()); - events.accept(new AgentEvent.ToolCall(call.id(), call.name(), call.arguments())); - }); + .filter(call -> StringUtils.hasText(call.id()) && StringUtils.hasText(call.name())); + } + + private void reportCall(AssistantMessage.ToolCall call) { + if (!pending.add(call.id())) { + return; + } + log.info("Conversation {} calls tool {}", conversationId, call.name()); + // Arguments carry whatever the user typed and can be large — keep them off INFO. + log.debug("Conversation {} tool {} arguments: {}", conversationId, call.name(), call.arguments()); + events.accept(new AgentEvent.ToolCall(call.id(), call.name(), call.arguments())); } /** Their results arrive as message history on the next round of the loop. */ diff --git a/base/src/test/java/ai/javaclaw/agent/DefaultAgentToolLoopTest.java b/base/src/test/java/ai/javaclaw/agent/DefaultAgentToolLoopTest.java new file mode 100644 index 00000000..a56a08c5 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/DefaultAgentToolLoopTest.java @@ -0,0 +1,110 @@ +package ai.javaclaw.agent; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +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.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.tool.annotation.Tool; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Runs {@link DefaultAgent} through a real {@link ChatClient} and {@link ToolCallingAdvisor}, with + * only the model faked. {@link ToolCallObservingAdvisor} sees tool calls only from inside that + * loop, so its unit test cannot prove the events actually come out — this does. + */ +class DefaultAgentToolLoopTest { + + static final String WEATHER_ARGS = "{\"city\":\"Paris\"}"; + + final List events = new ArrayList<>(); + + @Test + void reportsToolCallAndResultBetweenTheTokensOfAStreamedAnswer() { + agentFor(new WeatherModel(true)).respondTo("web", "Weather in Paris?", events::add); + + assertThat(events).containsExactly( + new AgentEvent.Token("Let me check. "), + new AgentEvent.ToolCall("call-1", "weather", WEATHER_ARGS), + new AgentEvent.ToolResult("call-1", "weather", "\"sunny\""), + new AgentEvent.Token("It is "), + new AgentEvent.Token("sunny."), + new AgentEvent.Done()); + } + + @Test + void reportsToolCallAndResultWhenTheModelCannotStream() { + agentFor(new WeatherModel(false)).respondTo("web", "Weather in Paris?", events::add); + + assertThat(events).containsExactly( + new AgentEvent.ToolCall("call-1", "weather", WEATHER_ARGS), + new AgentEvent.ToolResult("call-1", "weather", "\"sunny\""), + new AgentEvent.Token("It is sunny."), + new AgentEvent.Done()); + } + + private static DefaultAgent agentFor(ChatModel model) { + return new DefaultAgent(ChatClient.builder(model) + .defaultTools(new WeatherTools()) + .defaultAdvisors(ToolCallingAdvisor.builder().build()) + .build()); + } + + static class WeatherTools { + @Tool(description = "Current weather for a city") + String weather(String city) { + return "sunny"; + } + } + + /** Asks for the weather tool first, then answers once the tool's response is in the prompt. */ + record WeatherModel(boolean streams) implements ChatModel { + + @Override + public ChatResponse call(Prompt prompt) { + return answered(prompt) ? text("It is sunny.") : toolCall(""); + } + + @Override + public Flux stream(Prompt prompt) { + if (!streams) { + return ChatModel.super.stream(prompt); + } + return answered(prompt) + ? Flux.just(text("It is "), text("sunny.")) + : Flux.just(text("Let me check. "), toolCall("")); + } + + @Override + public ChatOptions getOptions() { + return ToolCallingChatOptions.builder().build(); + } + + private static boolean answered(Prompt prompt) { + return prompt.getInstructions().stream().anyMatch(ToolResponseMessage.class::isInstance); + } + + private static ChatResponse text(String text) { + return new ChatResponse(List.of(new Generation(new AssistantMessage(text)))); + } + + private static ChatResponse toolCall(String text) { + AssistantMessage message = AssistantMessage.builder() + .content(text) + .toolCalls(List.of(new AssistantMessage.ToolCall("call-1", "function", "weather", WEATHER_ARGS))) + .build(); + return new ChatResponse(List.of(new Generation(message))); + } + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java index 36578c8a..c2fe54ce 100644 --- a/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java +++ b/base/src/test/java/ai/javaclaw/agent/ToolCallObservingAdvisorTest.java @@ -57,15 +57,30 @@ void logsEachToolTheModelCalls() { } @Test - void logsAToolCallOnceEvenThoughArgumentsArriveAcrossChunks() { + void reportsACallOnceWithItsFinalArgumentsWhenTheyGrowAcrossChunks() { + List seen = new ArrayList<>(); + ToolCallObservingAdvisor observed = new ToolCallObservingAdvisor("web", seen::add); when(chain.nextStream(any())).thenReturn(Flux.just( - responseWith(toolCall("call-1", "readFile")), - responseWith(toolCall("call-1", "readFile")), - responseWith(toolCall("call-1", "readFile")))); + responseWith(toolCall("call-1", "readFile", "{\"pa")), + responseWith(toolCall("call-1", "readFile", "{\"path\":\"pom")), + responseWith(toolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}")))); - advisor.adviseStream(request(new UserMessage("hi")), chain).blockLast(); + observed.adviseStream(request(new UserMessage("hi")), chain).blockLast(); - assertThat(infoMessages()).containsExactly("Conversation web calls tool readFile"); + assertThat(seen).containsExactly(new AgentEvent.ToolCall("call-1", "readFile", "{\"path\":\"pom.xml\"}")); + } + + @Test + void reportsNoCallWhenTheRoundFailsBeforeItsArgumentsAreComplete() { + List seen = new ArrayList<>(); + ToolCallObservingAdvisor observed = new ToolCallObservingAdvisor("web", seen::add); + when(chain.nextStream(any())).thenReturn(Flux.concat( + Flux.just(responseWith(toolCall("call-1", "readFile", "{\"pa"))), + Flux.error(new RuntimeException("connection reset")))); + + observed.adviseStream(request(new UserMessage("hi")), chain).onErrorComplete().blockLast(); + + assertThat(seen).isEmpty(); } @Test @@ -127,7 +142,11 @@ private static ChatClientRequest request(org.springframework.ai.chat.messages.Me } private static AssistantMessage.ToolCall toolCall(String id, String name) { - return new AssistantMessage.ToolCall(id, "function", name, "{\"path\":\"pom.xml\"}"); + return toolCall(id, name, "{\"path\":\"pom.xml\"}"); + } + + private static AssistantMessage.ToolCall toolCall(String id, String name, String arguments) { + return new AssistantMessage.ToolCall(id, "function", name, arguments); } private static ChatClientResponse responseWith(AssistantMessage.ToolCall call) {