From 20534c69ffd16cf80b26ddb82214b6ef7cc9f89e Mon Sep 17 00:00:00 2001 From: nicholas1485 <3309028585@qq.com> Date: Mon, 30 Mar 2026 11:32:42 +0800 Subject: [PATCH 1/2] fix(extensions): prevent AutoContextMemory compression from hanging --- .../memory/autocontext/AutoContextConfig.java | 25 ++++ .../memory/autocontext/AutoContextMemory.java | 125 ++++++++++++------ .../autocontext/AutoContextMemoryTest.java | 68 ++++++++++ 3 files changed, 180 insertions(+), 38 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextConfig.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextConfig.java index 906638669..a0f94a523 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextConfig.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextConfig.java @@ -64,6 +64,13 @@ public class AutoContextConfig { */ int minCompressionTokenThreshold = 5000; + /** + * Timeout in milliseconds for a single LLM-based compression attempt. + * If the timeout is reached, AutoContext skips that compression candidate + * so the agent can continue reasoning. + */ + long compressionTimeoutMillis = 30_000; + /** * Optional custom prompt configuration. * If null, default prompts from {@link Prompts} will be used. @@ -106,6 +113,10 @@ public int getMinCompressionTokenThreshold() { return minCompressionTokenThreshold; } + public long getCompressionTimeoutMillis() { + return compressionTimeoutMillis; + } + /** * Gets the custom prompt configuration. * @@ -149,6 +160,7 @@ public static class Builder { private int minConsecutiveToolMessages = 6; private double currentRoundCompressionRatio = 0.3; private int minCompressionTokenThreshold = 5000; + private long compressionTimeoutMillis = 30_000; private PromptConfig customPrompt; /** @@ -254,6 +266,18 @@ public Builder minCompressionTokenThreshold(int minCompressionTokenThreshold) { return this; } + /** + * Sets the timeout in milliseconds for a single LLM-based compression attempt. + * If the timeout is reached, AutoContext skips that compression candidate. + * + * @param compressionTimeoutMillis the timeout in milliseconds + * @return this builder instance for method chaining + */ + public Builder compressionTimeoutMillis(long compressionTimeoutMillis) { + this.compressionTimeoutMillis = compressionTimeoutMillis; + return this; + } + /** * Sets custom prompt configuration. * @@ -284,6 +308,7 @@ public AutoContextConfig build() { config.minConsecutiveToolMessages = this.minConsecutiveToolMessages; config.currentRoundCompressionRatio = this.currentRoundCompressionRatio; config.minCompressionTokenThreshold = this.minCompressionTokenThreshold; + config.compressionTimeoutMillis = this.compressionTimeoutMillis; config.customPrompt = this.customPrompt; return config; } diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java index 4c450821a..d01c252bc 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java @@ -33,11 +33,13 @@ import io.agentscope.core.session.Session; import io.agentscope.core.state.SessionKey; import io.agentscope.core.state.StateModule; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -418,6 +420,10 @@ private boolean summaryCurrentRoundMessages(List rawMessages) { // Step 4: Merge and compress messages (typically tool calls and results) Msg compressedMsg = mergeAndCompressCurrentRoundMessages(messagesToCompress); + if (compressedMsg == null) { + log.warn("Skipping current round compression because the compression model timed out"); + return false; + } // Build metadata for compression event Map metadata = new HashMap<>(); @@ -526,6 +532,14 @@ private boolean summaryCurrentRoundLargeMessages(List rawMessages) { // Step 5: Generate summary using LLM Msg summaryMsg = generateLargeMessageSummary(msg, uuid); + if (summaryMsg == null) { + clear(uuid); + log.warn( + "Skipping current round large message summary at index {} because the" + + " compression model did not finish in time", + i); + continue; + } // Build metadata for compression event Map metadata = new HashMap<>(); @@ -599,13 +613,12 @@ private Msg generateLargeMessageSummary(Msg message, String offloadUuid) { addPlanAwareHintIfNeeded(newMessages); Msg block = - model.stream(newMessages, null, options) - .concatMap(chunk -> processChunk(chunk, context)) - .then(Mono.defer(() -> Mono.just(context.buildFinalMessage()))) - .onErrorResume(InterruptedException.class, Mono::error) - .block(); + executeCompressionModelCall(newMessages, options, context, "large message summary"); + if (block == null) { + return null; + } - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { log.info( "Large message summary completed, input tokens: {}, output tokens: {}", block.getChatUsage().getInputTokens(), @@ -622,12 +635,12 @@ private Msg generateLargeMessageSummary(Msg message, String offloadUuid) { metadata.put("_compress_meta", compressMeta); // Preserve _chat_usage from the block if available - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { metadata.put(MessageMetadataKeys.CHAT_USAGE, block.getChatUsage()); } // Create summary message preserving original role and name - String summaryContent = block != null ? block.getTextContent() : ""; + String summaryContent = block.getTextContent(); String finalContent = summaryContent; if (!offloadHint.isEmpty()) { finalContent = summaryContent + "\n" + offloadHint; @@ -658,7 +671,11 @@ private Msg mergeAndCompressCurrentRoundMessages(List messages) { offload(uuid, originalMessages); // Use model to generate a compressed summary from message list - return generateCurrentRoundSummaryFromMessages(messages, uuid); + Msg summary = generateCurrentRoundSummaryFromMessages(messages, uuid); + if (summary == null) { + clear(uuid); + } + return summary; } @Override @@ -755,22 +772,21 @@ private Msg generateCurrentRoundSummaryFromMessages(List messages, String o addPlanAwareHintIfNeeded(newMessages); Msg block = - model.stream(newMessages, null, options) - .concatMap(chunk -> processChunk(chunk, context)) - .then(Mono.defer(() -> Mono.just(context.buildFinalMessage()))) - .onErrorResume(InterruptedException.class, Mono::error) - .block(); + executeCompressionModelCall(newMessages, options, context, "current round summary"); + if (block == null) { + return null; + } // Extract token usage information int inputTokens = 0; int outputTokens = 0; - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { inputTokens = block.getChatUsage().getInputTokens(); outputTokens = block.getChatUsage().getOutputTokens(); } // Calculate actual output character count (including all content blocks) - int actualCharCount = block != null ? MsgUtils.calculateMessageCharCount(block) : 0; + int actualCharCount = MsgUtils.calculateMessageCharCount(block); log.info( "Current round summary completed - original: {} chars, target: {} chars ({}%)," @@ -792,7 +808,7 @@ private Msg generateCurrentRoundSummaryFromMessages(List messages, String o compressMeta.put("compressed_current_round", true); Map metadata = new HashMap<>(); metadata.put("_compress_meta", compressMeta); - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { metadata.put(MessageMetadataKeys.CHAT_USAGE, block.getChatUsage()); } @@ -800,10 +816,7 @@ private Msg generateCurrentRoundSummaryFromMessages(List messages, String o return Msg.builder() .role(MsgRole.ASSISTANT) .name("assistant") - .content( - TextBlock.builder() - .text((block != null ? block.getTextContent() : "") + offloadHint) - .build()) + .content(TextBlock.builder().text(block.getTextContent() + offloadHint).build()) .metadata(metadata) .build(); } @@ -854,6 +867,12 @@ private boolean summaryToolsMessages( offload(uuid, toolsMsg); Msg toolsSummary = compressToolsInvocation(toolsMsg, uuid); + if (toolsSummary == null) { + clear(uuid); + log.warn( + "Skipping tool invocation compression because the compression model timed out"); + return false; + } // Build metadata for compression event Map metadata = new HashMap<>(); @@ -1015,6 +1034,14 @@ private boolean summaryPreviousRoundMessages(List rawMessages) { // Step 6: Generate summary Msg summaryMsg = summaryPreviousRoundConversation(messagesToSummarize, uuid); + if (summaryMsg == null) { + clear(uuid); + log.warn( + "Skipping previous round conversation summary for round {} because the" + + " compression model did not finish in time", + pairIdx + 1); + continue; + } // Build metadata for compression event Map metadata = new HashMap<>(); @@ -1112,16 +1139,16 @@ private Msg summaryPreviousRoundConversation(List messages, String offloadU addPlanAwareHintIfNeeded(newMessages); Msg block = - model.stream(newMessages, null, options) - .concatMap(chunk -> processChunk(chunk, context)) - .then(Mono.defer(() -> Mono.just(context.buildFinalMessage()))) - .onErrorResume(InterruptedException.class, Mono::error) - .block(); + executeCompressionModelCall( + newMessages, options, context, "previous round conversation summary"); + if (block == null) { + return null; + } // Extract token usage information int inputTokens = 0; int outputTokens = 0; - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { inputTokens = block.getChatUsage().getInputTokens(); outputTokens = block.getChatUsage().getOutputTokens(); log.info( @@ -1140,14 +1167,14 @@ private Msg summaryPreviousRoundConversation(List messages, String offloadU metadata.put("_compress_meta", compressMeta); // Preserve _chat_usage from the block if available - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { metadata.put(MessageMetadataKeys.CHAT_USAGE, block.getChatUsage()); } // Build the final message content: // 1. LLM generated summary (contains ASSISTANT summary + tool compression) // 2. Context offload tag with UUID at the end - String summaryContent = block != null ? block.getTextContent() : ""; + String summaryContent = block.getTextContent(); String offloadTag = offloadUuid != null ? String.format(Prompts.CONTEXT_OFFLOAD_TAG_FORMAT, offloadUuid) @@ -1614,17 +1641,15 @@ private Msg compressToolsInvocation(List messages, String offloadUUid) { .build()); // Insert plan-aware hint message at the end to leverage recency effect addPlanAwareHintIfNeeded(newMessages); - Msg block = - model.stream(newMessages, null, options) - .concatMap(chunk -> processChunk(chunk, context)) - .then(Mono.defer(() -> Mono.just(context.buildFinalMessage()))) - .onErrorResume(InterruptedException.class, Mono::error) - .block(); + Msg block = executeCompressionModelCall(newMessages, options, context, "tool compression"); + if (block == null) { + return null; + } // Extract token usage information int inputTokens = 0; int outputTokens = 0; - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { inputTokens = block.getChatUsage().getInputTokens(); outputTokens = block.getChatUsage().getOutputTokens(); log.info( @@ -1643,14 +1668,14 @@ private Msg compressToolsInvocation(List messages, String offloadUUid) { metadata.put("_compress_meta", compressMeta); // Preserve _chat_usage from the block if available - if (block != null && block.getChatUsage() != null) { + if (block.getChatUsage() != null) { metadata.put(MessageMetadataKeys.CHAT_USAGE, block.getChatUsage()); } // Build the final message content: // 1. LLM generated compressed tool invocation content // 2. Context offload tag with UUID at the end - String compressedContent = block != null ? block.getTextContent() : ""; + String compressedContent = block.getTextContent(); String offloadTag = offloadUUid != null ? String.format(Prompts.CONTEXT_OFFLOAD_TAG_FORMAT, offloadUUid) @@ -1670,6 +1695,30 @@ private Msg compressToolsInvocation(List messages, String offloadUUid) { .build(); } + private Msg executeCompressionModelCall( + List messages, + GenerateOptions options, + ReasoningContext context, + String operationName) { + long timeoutMillis = Math.max(1L, autoContextConfig.getCompressionTimeoutMillis()); + + return model.stream(messages, null, options) + .concatMap(chunk -> processChunk(chunk, context)) + .then(Mono.defer(() -> Mono.justOrEmpty(context.buildFinalMessage()))) + .timeout(Duration.ofMillis(timeoutMillis)) + .doOnError( + TimeoutException.class, + error -> + log.warn( + "AutoContext {} timed out after {} ms; skipping this" + + " compression candidate", + operationName, + timeoutMillis)) + .onErrorResume(TimeoutException.class, error -> Mono.empty()) + .onErrorResume(InterruptedException.class, Mono::error) + .block(); + } + private Mono processChunk(ChatResponse chunk, ReasoningContext context) { return Mono.just(chunk).doOnNext(context::processChunk).then(Mono.empty()); } diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java index 15d7fa930..a84c24f5b 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.message.Msg; @@ -38,6 +39,7 @@ import io.agentscope.core.plan.model.SubTaskState; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -715,6 +717,52 @@ void testMergeAndCompressCurrentRoundMessages() { "Should have at least 1 offloaded entry. Got " + offloadContext.size()); } + @Test + @DisplayName("Should skip timed out current round compression without hanging") + void testCurrentRoundCompressionTimeoutDoesNotHang() { + NeverCompletingModel neverCompletingModel = new NeverCompletingModel(); + AutoContextConfig timeoutConfig = + AutoContextConfig.builder() + .msgThreshold(10) + .maxToken(10000) + .tokenRatio(0.9) + .lastKeep(5) + .minConsecutiveToolMessages(10) + .largePayloadThreshold(10000) + .minCompressionTokenThreshold(0) + .compressionTimeoutMillis(50) + .build(); + AutoContextMemory timeoutMemory = + new AutoContextMemory(timeoutConfig, neverCompletingModel); + + for (int i = 0; i < 8; i++) { + timeoutMemory.addMessage(createTextMessage("Initial message " + i, MsgRole.USER)); + } + + timeoutMemory.addMessage(createTextMessage("User query with tools", MsgRole.USER)); + for (int i = 0; i < 2; i++) { + timeoutMemory.addMessage(createToolUseMessage("test_tool", "call_" + i)); + timeoutMemory.addMessage( + createToolResultMessage("test_tool", "call_" + i, "Result " + i)); + } + + boolean compressed = + assertTimeoutPreemptively( + Duration.ofSeconds(1), + timeoutMemory::compressIfNeeded, + "Timed out compression should not block the caller indefinitely"); + + assertFalse(compressed, "Compression should be skipped after timeout"); + assertEquals(1, neverCompletingModel.getCallCount(), "Should attempt compression once"); + assertEquals( + 13, + timeoutMemory.getMessages().size(), + "Working memory should remain unchanged after timeout"); + assertTrue( + timeoutMemory.getOffloadContext().isEmpty(), + "Timed out compression should clean up temporary offloaded messages"); + } + @Test @DisplayName( "Should skip tool message compression when token count is below" @@ -1039,6 +1087,26 @@ void reset() { } } + private static class NeverCompletingModel implements Model { + private int callCount = 0; + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + callCount++; + return Flux.never(); + } + + @Override + public String getModelName() { + return "never-completing-model"; + } + + int getCallCount() { + return callCount; + } + } + // ==================== PlanNotebook Integration Tests ==================== @Test From 5d3d69e22bb17e7f8ca11d3a8ffc83825d65c785 Mon Sep 17 00:00:00 2001 From: nicholas1485 <3309028585@qq.com> Date: Mon, 30 Mar 2026 14:36:50 +0800 Subject: [PATCH 2/2] test(extensions): cover AutoContextMemory timeout fallback paths --- .../autocontext/AutoContextMemoryTest.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java index a84c24f5b..525c4927d 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java @@ -763,6 +763,148 @@ void testCurrentRoundCompressionTimeoutDoesNotHang() { "Timed out compression should clean up temporary offloaded messages"); } + @Test + @DisplayName("Should skip timed out current round large message summary without leaks") + void testCurrentRoundLargeMessageTimeoutDoesNotLeaveOffloadedMessages() { + NeverCompletingModel neverCompletingModel = new NeverCompletingModel(); + AutoContextConfig timeoutConfig = + AutoContextConfig.builder() + .msgThreshold(10) + .maxToken(10000) + .tokenRatio(0.9) + .lastKeep(5) + .minConsecutiveToolMessages(10) + .largePayloadThreshold(100) + .minCompressionTokenThreshold(0) + .compressionTimeoutMillis(50) + .build(); + AutoContextMemory timeoutMemory = + new AutoContextMemory(timeoutConfig, neverCompletingModel); + + List rawMessages = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + rawMessages.add(createTextMessage("Initial message " + i, MsgRole.USER)); + } + rawMessages.add(createTextMessage("User query with large response", MsgRole.USER)); + rawMessages.add(createTextMessage("x".repeat(200), MsgRole.ASSISTANT)); + + boolean summarized = + assertTimeoutPreemptively( + Duration.ofSeconds(1), + () -> + invokePrivateBooleanMethod( + timeoutMemory, + "summaryCurrentRoundLargeMessages", + new Class[] {List.class}, + rawMessages), + "Timed out large-message compression should not block the caller"); + + assertFalse(summarized, "Large message summary should be skipped after timeout"); + assertEquals(1, neverCompletingModel.getCallCount(), "Should attempt summary once"); + assertEquals(10, rawMessages.size(), "Raw messages should remain unchanged after timeout"); + assertTrue( + timeoutMemory.getOffloadContext().isEmpty(), + "Timed out large-message summary should clean up temporary offloaded messages"); + } + + @Test + @DisplayName("Should skip timed out tool compression without leaving offloaded messages") + void testToolCompressionTimeoutDoesNotLeaveOffloadedMessages() { + NeverCompletingModel neverCompletingModel = new NeverCompletingModel(); + AutoContextConfig timeoutConfig = + AutoContextConfig.builder() + .msgThreshold(10) + .maxToken(10000) + .tokenRatio(0.9) + .lastKeep(5) + .minConsecutiveToolMessages(3) + .minCompressionTokenThreshold(0) + .compressionTimeoutMillis(50) + .build(); + AutoContextMemory timeoutMemory = + new AutoContextMemory(timeoutConfig, neverCompletingModel); + + List rawMessages = new ArrayList<>(); + rawMessages.add(createTextMessage("User query", MsgRole.USER)); + for (int i = 0; i < 5; i++) { + rawMessages.add(createToolUseMessage("test_tool", "call_" + i)); + rawMessages.add(createToolResultMessage("test_tool", "call_" + i, "Result " + i)); + } + rawMessages.add(createTextMessage("Assistant response", MsgRole.ASSISTANT)); + + boolean compressed = + assertTimeoutPreemptively( + Duration.ofSeconds(1), + () -> + invokePrivateBooleanMethod( + timeoutMemory, + "summaryToolsMessages", + new Class[] {List.class, Pair.class}, + rawMessages, + new Pair<>(1, 10)), + "Timed out tool compression should not block the caller"); + + assertFalse(compressed, "Tool compression should be skipped after timeout"); + assertEquals( + 1, neverCompletingModel.getCallCount(), "Should attempt tool compression once"); + assertEquals(12, rawMessages.size(), "Tool messages should remain unchanged after timeout"); + assertTrue( + timeoutMemory.getOffloadContext().isEmpty(), + "Timed out tool compression should clean up temporary offloaded messages"); + } + + @Test + @DisplayName( + "Should skip timed out previous round summaries without leaving offloaded messages") + void testPreviousRoundSummaryTimeoutDoesNotLeaveOffloadedMessages() { + NeverCompletingModel neverCompletingModel = new NeverCompletingModel(); + AutoContextConfig timeoutConfig = + AutoContextConfig.builder() + .msgThreshold(10) + .maxToken(10000) + .tokenRatio(0.9) + .lastKeep(2) + .minConsecutiveToolMessages(10) + .largePayloadThreshold(10000) + .minCompressionTokenThreshold(0) + .compressionTimeoutMillis(50) + .build(); + AutoContextMemory timeoutMemory = + new AutoContextMemory(timeoutConfig, neverCompletingModel); + + List rawMessages = new ArrayList<>(); + for (int round = 0; round < 5; round++) { + rawMessages.add(createTextMessage("User query round " + round, MsgRole.USER)); + rawMessages.add(createToolUseMessage("tool_" + round, "call_" + round)); + rawMessages.add( + createToolResultMessage("tool_" + round, "call_" + round, "Result " + round)); + rawMessages.add( + createTextMessage("Assistant response round " + round, MsgRole.ASSISTANT)); + } + rawMessages.add(createTextMessage("Final user query", MsgRole.USER)); + + boolean summarized = + assertTimeoutPreemptively( + Duration.ofSeconds(1), + () -> + invokePrivateBooleanMethod( + timeoutMemory, + "summaryPreviousRoundMessages", + new Class[] {List.class}, + rawMessages), + "Timed out previous-round summaries should not block the caller"); + + assertFalse(summarized, "Previous round summaries should be skipped after timeout"); + assertEquals( + 4, + neverCompletingModel.getCallCount(), + "Should attempt each eligible previous round summary"); + assertEquals(21, rawMessages.size(), "Previous-round messages should remain unchanged"); + assertTrue( + timeoutMemory.getOffloadContext().isEmpty(), + "Timed out previous-round summaries should clean up temporary offloaded messages"); + } + @Test @DisplayName( "Should skip tool message compression when token count is below" @@ -1016,6 +1158,20 @@ void testCompressToolsInvocationFullCoverage() { // Helper methods + private boolean invokePrivateBooleanMethod( + AutoContextMemory target, + String methodName, + Class[] parameterTypes, + Object... args) { + try { + Method method = AutoContextMemory.class.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return (boolean) method.invoke(target, args); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private Msg createTextMessage(String text, MsgRole role) { return Msg.builder() .role(role)