diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index bb480c65c0..a763fec275 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2550,7 +2550,11 @@ private Flux modelCallStream( () -> { List events = new ArrayList<>(); blockLifecycle.flushAll(events); - events.add(new ModelCallEndEvent(replyId, context.getChatUsage())); + events.add( + new ModelCallEndEvent( + replyId, + context.getChatUsage(), + context.getFinishReason())); return Flux.fromIterable(events); }); @@ -3612,7 +3616,11 @@ private Flux summaryModelCallStream( () -> { List events = new ArrayList<>(); blockLifecycle.flushAll(events); - events.add(new ModelCallEndEvent(replyId, context.getChatUsage())); + events.add( + new ModelCallEndEvent( + replyId, + context.getChatUsage(), + context.getFinishReason())); return Flux.fromIterable(events); }); diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ReasoningContext.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ReasoningContext.java index bf08581b4c..5ebc0eb9ec 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ReasoningContext.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ReasoningContext.java @@ -43,6 +43,8 @@ */ public class ReasoningContext { + private static final String FINISH_REASON_LENGTH = "length"; + private final String agentName; private String messageId; @@ -57,6 +59,7 @@ public class ReasoningContext { private int outputTokens = 0; private int cachedTokens = 0; private double time = 0; + private String finishReason; public ReasoningContext(String agentName) { this.agentName = agentName; @@ -78,6 +81,9 @@ public ReasoningContext(String agentName) { */ public List processChunk(ChatResponse chunk) { this.messageId = chunk.getId(); + if (chunk.getFinishReason() != null && !chunk.getFinishReason().isBlank()) { + finishReason = chunk.getFinishReason(); + } // Accumulate ChatUsage ChatUsage usage = chunk.getUsage(); @@ -159,6 +165,9 @@ public Msg buildFinalMessage() { // Add all tool calls List toolCalls = toolCallsAcc.buildAllToolCalls(); + if (FINISH_REASON_LENGTH.equals(finishReason)) { + toolCalls = markLengthLimitedIncompleteToolCalls(toolCalls); + } blocks.addAll(toolCalls); // If no content at all, return null @@ -179,6 +188,9 @@ public Msg buildFinalMessage() { .build(); metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage); } + if (finishReason != null) { + metadata.put(MessageMetadataKeys.MODEL_FINISH_REASON, finishReason); + } return AssistantMessage.builder() .id(messageId) @@ -189,6 +201,42 @@ public Msg buildFinalMessage() { .build(); } + /** + * Marks tool calls whose argument stream was truncated when the model reached its output + * length limit. + * + *

The accumulator already preserves the legacy fallback of replacing invalid raw argument + * JSON with {@code {}}. That fallback must remain available for providers that do not report a + * finish reason, because invalid JSON alone does not prove that the response was truncated. + * A {@code length} finish reason is the additional evidence that lets the executor distinguish + * an incomplete response from a legacy malformed payload and safely avoid invoking the tool. + * + *

Only the affected calls are copied with the internal marker. Complete calls in the same + * response keep their original instance and remain executable. + */ + private List markLengthLimitedIncompleteToolCalls(List toolCalls) { + return toolCalls.stream() + .map( + toolCall -> { + if (!Boolean.TRUE.equals( + toolCall.getMetadata() + .get(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE))) { + return toolCall; + } + Map metadata = new HashMap<>(toolCall.getMetadata()); + metadata.put(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT, true); + return ToolUseBlock.builder() + .id(toolCall.getId()) + .name(toolCall.getName()) + .input(toolCall.getInput()) + .content(toolCall.getContent()) + .metadata(metadata) + .state(toolCall.getState()) + .build(); + }) + .toList(); + } + /** * Build a chunk message from a content block. * @hidden @@ -297,4 +345,11 @@ public ChatUsage getChatUsage() { } return null; } + + /** + * Returns the last non-blank finish reason reported during the current model response. + */ + public String getFinishReason() { + return finishReason; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java index 19d542916a..617488f3a0 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java @@ -93,6 +93,8 @@ void merge(ToolUseBlock block) { ToolUseBlock build() { Map finalArgs = new HashMap<>(args); String rawContentStr = this.rawContent.toString(); + boolean rawContentIncomplete = + !rawContentStr.isEmpty() && !JsonUtils.isValidJsonObject(rawContentStr); // Always attempt to parse the fully accumulated raw JSON. Early stream chunks may // look like complete objects ({...}) but still contain null/incomplete values; @@ -124,18 +126,25 @@ ToolUseBlock build() { String contentStr; if (rawContentStr.isEmpty()) { contentStr = "{}"; - } else if (JsonUtils.isValidJsonObject(rawContentStr)) { + } else if (!rawContentIncomplete) { contentStr = rawContentStr; } else { contentStr = "{}"; } + Map finalMetadata = new HashMap<>(metadata); + if (rawContentIncomplete) { + // Keep the legacy content fallback while preserving enough provenance for the + // response-level finish reason to make a safe execution decision later. + finalMetadata.put(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE, true); + } + return ToolUseBlock.builder() .id(toolId != null ? toolId : generateId()) .name(name) .input(finalArgs) .content(contentStr) - .metadata(metadata.isEmpty() ? null : metadata) + .metadata(finalMetadata.isEmpty() ? null : finalMetadata) .build(); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallEndEvent.java index 686620319a..ba8a71918f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallEndEvent.java @@ -26,21 +26,29 @@ public class ModelCallEndEvent extends AgentEvent { private final String replyId; private final ChatUsage usage; + private final String finishReason; @JsonCreator public ModelCallEndEvent( @JsonProperty("id") String id, @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, - @JsonProperty("usage") ChatUsage usage) { + @JsonProperty("usage") ChatUsage usage, + @JsonProperty("finishReason") String finishReason) { super(id, createdAt); this.replyId = replyId; this.usage = usage; + this.finishReason = finishReason; } public ModelCallEndEvent(String replyId, ChatUsage usage) { + this(replyId, usage, null); + } + + public ModelCallEndEvent(String replyId, ChatUsage usage, String finishReason) { this.replyId = replyId; this.usage = usage; + this.finishReason = finishReason; } @Override @@ -55,4 +63,11 @@ public String getReplyId() { public ChatUsage getUsage() { return usage; } + + /** + * Returns the finish reason reported by the model provider, when available. + */ + public String getFinishReason() { + return finishReason; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java b/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java index d34e72adef..7e5a9045b8 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java @@ -91,6 +91,14 @@ private MessageMetadataKeys() { */ public static final String CHAT_USAGE = "_chat_usage"; + /** + * Metadata key for the final finish reason reported by the model provider. + * + *

Type: {@code String}. This value is optional because some providers do not expose a + * finish reason for streaming responses. + */ + public static final String MODEL_FINISH_REASON = "_model_finish_reason"; + /** * Metadata key for structured output data. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java index eb0fbe7fe9..c693653f23 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java @@ -36,6 +36,12 @@ public final class ToolUseBlock extends ContentBlock { /** Metadata key for Gemini thought signature (byte[] value). */ public static final String METADATA_THOUGHT_SIGNATURE = "thoughtSignature"; + /** Metadata key indicating a non-empty streaming argument payload was not valid JSON. */ + public static final String METADATA_RAW_CONTENT_INCOMPLETE = "_raw_content_incomplete"; + + /** Metadata key indicating a length-limited model response left tool arguments incomplete. */ + public static final String METADATA_OUTPUT_LENGTH_LIMIT = "_output_length_limit"; + private final String id; private final String name; private final Map input; diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java index 7d9c13a681..86d32f67f4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java @@ -183,6 +183,28 @@ Mono execute(ToolCallParam param) { */ private Mono executeCore(ToolCallParam param) { ToolUseBlock toolCall = param.getToolUseBlock(); + + // A model may emit a syntactically partial tool call before reporting "length". The + // accumulator intentionally keeps the historical "{}" fallback for compatibility, so + // schema validation alone cannot distinguish this case from a genuine empty argument + // object. The paired metadata markers provide that evidence and ensure that no tool with + // potentially destructive side effects is invoked from a truncated argument stream. The + // error result is returned through the normal execution path, which preserves the tool + // call ID and prevents the ReAct state from retaining an unmatched pending tool call. + if (Boolean.TRUE.equals( + toolCall.getMetadata().get(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT))) { + String errorMsg = + "Tool arguments were incomplete because the model output reached its length" + + " limit. The tool was not executed. Do not retry this call unchanged." + + " Reduce the argument payload and complete the work with multiple" + + " smaller calls or an available incremental update tool."; + logger.warn( + "Blocked incomplete length-limited tool call: name={}, id={}", + toolCall.getName(), + toolCall.getId()); + return Mono.just(ToolResultBlock.error(errorMsg)); + } + AgentTool tool = toolRegistry.getTool(toolCall.getName()); if (tool == null) { diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentLengthLimitedToolCallTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentLengthLimitedToolCallTest.java new file mode 100644 index 0000000000..20f70e3994 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentLengthLimitedToolCallTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolResultState; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** Tests the complete ReAct path for a length-limited incomplete tool call. */ +class ReActAgentLengthLimitedToolCallTest { + + /** Returns scripted streaming responses in order for the truncated-call end-to-end test. */ + private static final class ScriptedModel extends ChatModelBase { + private final List>> scripts; + private final AtomicInteger index = new AtomicInteger(); + + private ScriptedModel(List>> scripts) { + this.scripts = scripts; + } + + @Override + public String getModelName() { + return "scripted"; + } + + @Override + protected Flux doStream( + List messages, + List tools, + GenerateOptions options) { + int currentIndex = index.getAndIncrement(); + if (currentIndex >= scripts.size()) { + return Flux.just(ChatResponse.builder().content(List.of()).build()); + } + return scripts.get(currentIndex).get(); + } + } + + /** 记录调用次数,确保截断参数在到达工具实现前被拦截。 */ + private static final class CountingTool implements AgentTool { + private final AtomicInteger invocations; + + private CountingTool(AtomicInteger invocations) { + this.invocations = invocations; + } + + @Override + public String getName() { + return "write_file"; + } + + @Override + public String getDescription() { + return "Counts executions for the truncated argument test"; + } + + @Override + public Map getParameters() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public Mono callAsync(ToolCallParam param) { + invocations.incrementAndGet(); + return Mono.just(ToolResultBlock.text("should-not-run")); + } + } + + /** 验证长度截断的工具调用不会执行且会补齐同 ID 的失败结果。 */ + @Test + void lengthLimitedIncompleteToolCallProducesErrorResultWithoutExecutingTool() { + AtomicInteger invocations = new AtomicInteger(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(new CountingTool(invocations)); + + ChatResponse partialToolCall = + ChatResponse.builder() + .id("response-1") + .content( + List.of( + ToolUseBlock.builder() + .id("call-truncated") + .name("write_file") + .input(Map.of("path", "index.html")) + .content("{\"path\":\"index.html\",\"content\":\"") + .build())) + .build(); + ChatResponse lengthLimitedEnd = + ChatResponse.builder() + .id("response-1") + .content(List.of()) + .finishReason("length") + .build(); + ChatResponse completion = + ChatResponse.builder() + .id("response-2") + .content(List.of(TextBlock.builder().text("recovered").build())) + .finishReason("stop") + .build(); + + ReActAgent agent = + ReActAgent.builder() + .name("assistant") + .model( + new ScriptedModel( + List.of( + () -> Flux.just(partialToolCall, lengthLimitedEnd), + () -> Flux.just(completion)))) + .toolkit(toolkit) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals(0, invocations.get()); + assertTrue( + events.stream() + .filter(ModelCallEndEvent.class::isInstance) + .map(ModelCallEndEvent.class::cast) + .anyMatch(event -> "length".equals(event.getFinishReason()))); + + ToolResultBlock result = + agent.getAgentState().getContext().stream() + .flatMap( + message -> message.getContentBlocks(ToolResultBlock.class).stream()) + .filter(toolResult -> "call-truncated".equals(toolResult.getId())) + .findFirst() + .orElseThrow(); + assertEquals(ToolResultState.ERROR, result.getState()); + assertEquals("write_file", result.getName()); + assertTrue( + result.getOutput().stream() + .filter(TextBlock.class::isInstance) + .map(TextBlock.class::cast) + .findFirst() + .orElseThrow() + .getText() + .contains("Do not retry this call unchanged")); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java index 83b00def5e..261c0121d3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.message.MessageMetadataKeys; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolUseBlock; @@ -333,4 +334,71 @@ void testToolCallsDoNotBlockTextEmission() { // Verify text is accumulated correctly assertEquals("Let me check the weather for you.", context.getAccumulatedText()); } + + @Test + @DisplayName("Should mark incomplete tool arguments when the model reaches its length limit") + void testLengthLimitedIncompleteToolCallIsMarked() { + ToolUseBlock partialToolCall = + ToolUseBlock.builder() + .id("call_partial") + .name("write_file") + .content("{\"path\":\"index.html\",\"content\":\"") + .build(); + + context.processChunk( + ChatResponse.builder().id("msg-1").content(List.of(partialToolCall)).build()); + context.processChunk( + ChatResponse.builder() + .id("msg-1") + .content(List.of()) + .finishReason("length") + .build()); + + Msg finalMsg = context.buildFinalMessage(); + assertNotNull(finalMsg); + assertEquals("length", context.getFinishReason()); + assertEquals("length", finalMsg.getMetadata().get(MessageMetadataKeys.MODEL_FINISH_REASON)); + + ToolUseBlock finalToolCall = finalMsg.getFirstContentBlock(ToolUseBlock.class); + assertNotNull(finalToolCall); + assertEquals("{}", finalToolCall.getContent()); + assertTrue( + Boolean.TRUE.equals( + finalToolCall + .getMetadata() + .get(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE))); + assertTrue( + Boolean.TRUE.equals( + finalToolCall + .getMetadata() + .get(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT))); + } + + @Test + @DisplayName("Should preserve the legacy fallback when the finish reason is unavailable") + void testIncompleteToolCallWithoutFinishReasonKeepsLegacyFallback() { + ToolUseBlock partialToolCall = + ToolUseBlock.builder() + .id("call_partial") + .name("write_file") + .content("{\"path\":\"index.html\"") + .build(); + + context.processChunk( + ChatResponse.builder().id("msg-1").content(List.of(partialToolCall)).build()); + + ToolUseBlock finalToolCall = + context.buildFinalMessage().getFirstContentBlock(ToolUseBlock.class); + assertNotNull(finalToolCall); + assertEquals("{}", finalToolCall.getContent()); + assertTrue( + Boolean.TRUE.equals( + finalToolCall + .getMetadata() + .get(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE))); + assertTrue( + !finalToolCall + .getMetadata() + .containsKey(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT)); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java index 9ff384af7c..efdd5b6efa 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java @@ -386,6 +386,11 @@ void testNonObjectJsonContentFallsBackToEmpty() { assertEquals(1, result.size()); // Arrays are not valid JSON objects for tool call arguments assertEquals("{}", result.get(0).getContent()); + assertTrue( + Boolean.TRUE.equals( + result.get(0) + .getMetadata() + .get(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE))); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java index 55ae0cb786..256c14e2d6 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java @@ -127,6 +127,59 @@ void shouldReturnErrorWhenToolThrows() { "Error message should be wrapped by executor"); } + @Test + @DisplayName("Should not execute a tool with length-limited incomplete arguments") + void shouldBlockLengthLimitedIncompleteToolCall() { + AtomicInteger invocations = new AtomicInteger(); + toolkit.registerTool( + new AgentTool() { + @Override + public String getName() { + return "blocked_tool"; + } + + @Override + public String getDescription() { + return "Records executions for a truncation test"; + } + + @Override + public Map getParameters() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public Mono callAsync(ToolCallParam param) { + invocations.incrementAndGet(); + return Mono.just(ToolResultBlock.text("should-not-run")); + } + }); + + ToolUseBlock truncatedCall = + ToolUseBlock.builder() + .id("call-truncated") + .name("blocked_tool") + .input(Map.of()) + .content("{}") + .metadata(Map.of(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT, true)) + .build(); + + List responses = + toolkit.callTools(List.of(truncatedCall), null, null, null).block(TIMEOUT); + + assertNotNull(responses); + assertEquals(0, invocations.get()); + assertEquals(1, responses.size()); + assertEquals("call-truncated", responses.get(0).getId()); + assertEquals("blocked_tool", responses.get(0).getName()); + assertEquals( + "Error: Tool arguments were incomplete because the model output reached its length" + + " limit. The tool was not executed. Do not retry this call unchanged." + + " Reduce the argument payload and complete the work with multiple" + + " smaller calls or an available incremental update tool.", + extractFirstText(responses.get(0))); + } + @Test @DisplayName("Should convert empty tool publishers to error responses") void shouldReturnErrorWhenToolCompletesEmpty() { diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/formatter/OpenAIStreamingToolCallTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/formatter/OpenAIStreamingToolCallTest.java index 2ef95c95e1..fef44bb8d0 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/formatter/OpenAIStreamingToolCallTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/formatter/OpenAIStreamingToolCallTest.java @@ -290,4 +290,42 @@ void testToolCallWithNullArguments() { assertEquals("call_null", toolUse.getId()); assertEquals("null_args_tool", toolUse.getName()); } + + @Test + @DisplayName("Should preserve the streaming finish reason from OpenAI-compatible responses") + void testStreamingFinishReason() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-length"); + response.setObject("chat.completion.chunk"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + choice.setDelta(new OpenAIMessage()); + choice.setFinishReason("length"); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = parser.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + assertEquals("length", chatResponse.getFinishReason()); + } + + @Test + @DisplayName("Should preserve the non-streaming finish reason from OpenAI-compatible responses") + void testCompletionFinishReason() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-length"); + response.setObject("chat.completion"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + choice.setMessage(new OpenAIMessage()); + choice.setFinishReason("length"); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = parser.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + assertEquals("length", chatResponse.getFinishReason()); + } }