diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIResponseParser.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIResponseParser.java index 1c70958215..26ca3ee788 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIResponseParser.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIResponseParser.java @@ -51,6 +51,21 @@ public class OpenAIResponseParser { /** Placeholder name for tool call argument fragments in streaming responses. */ protected static final String FRAGMENT_PLACEHOLDER = "__fragment__"; + /** + * Some OpenAI-compatible providers occasionally emit a malformed trailing argument fragment + * with a non-null tool name but no tool call id. That chunk should still be treated as a + * fragment so it can merge back into the previously started tool call instead of becoming a + * new synthetic call. + */ + private boolean isMalformedNamedStreamingFragment( + String toolCallId, String toolName, String arguments) { + return toolCallId == null + && toolName != null + && !toolName.isEmpty() + && arguments != null + && !arguments.isEmpty(); + } + /** * Safely get prompt token count from usage, returning 0 if null or invalid. * @@ -461,9 +476,6 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start } } - if (toolCallId == null) { - toolCallId = "streaming_" + System.currentTimeMillis(); - } if (toolName == null) { toolName = ""; } @@ -471,6 +483,14 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start arguments = ""; } + boolean malformedNamedFragment = + isMalformedNamedStreamingFragment( + toolCallId, toolName, arguments); + + if (!malformedNamedFragment && toolCallId == null) { + toolCallId = "streaming_" + System.currentTimeMillis(); + } + log.debug( "Streaming tool call chunk: id={}, name={}," + " arguments={}, signature={}", @@ -481,7 +501,7 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start // For streaming, we get partial tool calls that need to be // accumulated - if (!toolName.isEmpty()) { + if (!toolName.isEmpty() && !malformedNamedFragment) { // First chunk with complete metadata (has tool name) Map argsMap = new HashMap<>(); @@ -529,6 +549,17 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start } else if (!arguments.isEmpty() || thoughtSignature != null) { // Subsequent chunks with only argument fragments or just // signature + if (malformedNamedFragment) { + log.debug( + "Treating malformed named streaming tool call" + + " chunk as fragment: name={}," + + " arguments={}", + toolName, + arguments.length() > 50 + ? arguments.substring(0, 50) + "..." + : arguments); + } + Map metadata = new HashMap<>(); if (thoughtSignature != null) { metadata.put( diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIChatFormatterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIChatFormatterTest.java index 2bcbafa215..e7f63ca8bb 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIChatFormatterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIChatFormatterTest.java @@ -26,16 +26,19 @@ import io.agentscope.core.formatter.ResponseFormat; import io.agentscope.core.formatter.openai.dto.JsonSchema; import io.agentscope.core.formatter.openai.dto.OpenAIChoice; +import io.agentscope.core.formatter.openai.dto.OpenAIFunction; import io.agentscope.core.formatter.openai.dto.OpenAIMessage; import io.agentscope.core.formatter.openai.dto.OpenAIRequest; import io.agentscope.core.formatter.openai.dto.OpenAIResponse; import io.agentscope.core.formatter.openai.dto.OpenAITool; +import io.agentscope.core.formatter.openai.dto.OpenAIToolCall; import io.agentscope.core.formatter.openai.dto.OpenAIToolFunction; import io.agentscope.core.formatter.openai.dto.OpenAIUsage; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; @@ -316,6 +319,46 @@ void testParseResponse() { assertEquals(20, chatResponse.getUsage().getOutputTokens()); } + @Test + @DisplayName("Should parse malformed named streaming tool fragment through formatter") + void testParseMalformedNamedStreamingToolFragment() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-123"); + response.setObject("chat.completion.chunk"); + + OpenAIFunction function = new OpenAIFunction(); + function.setName("retrieveFromMemory"); + function.setArguments("}"); + + OpenAIToolCall toolCall = new OpenAIToolCall(); + toolCall.setId(null); + toolCall.setIndex(0); + toolCall.setType("function"); + toolCall.setFunction(function); + + OpenAIMessage delta = new OpenAIMessage(); + delta.setRole("assistant"); + delta.setToolCalls(List.of(toolCall)); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + choice.setDelta(delta); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = formatter.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + assertNotNull(chatResponse.getContent()); + assertEquals(1, chatResponse.getContent().size()); + ContentBlock contentBlock = chatResponse.getContent().get(0); + assertInstanceOf(ToolUseBlock.class, contentBlock); + + ToolUseBlock toolUseBlock = (ToolUseBlock) contentBlock; + assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolUseBlock.getName()); + assertEquals("", toolUseBlock.getId()); + assertEquals("}", toolUseBlock.getContent()); + } + @Test @DisplayName("Should handle null tool choice gracefully") void testBuildRequestWithNullToolChoice() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIResponseParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIResponseParserTest.java index 737875574a..33cf73538f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIResponseParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIResponseParserTest.java @@ -709,6 +709,48 @@ void testStreamingToolCallFragment() { assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolBlock.getName()); } + @Test + @DisplayName("Should treat malformed named trailing chunk as fragment") + void testStreamingToolCallMalformedNamedTrailingFragment() { + OpenAIResponse response = new OpenAIResponse(); + response.setObject("chat.completion.chunk"); + + OpenAIFunction function = new OpenAIFunction(); + function.setName("retrieveFromMemory"); + function.setArguments("}"); + + OpenAIToolCall toolCall = new OpenAIToolCall(); + toolCall.setId(null); + toolCall.setIndex(0); + toolCall.setType("function"); + toolCall.setFunction(function); + + OpenAIMessage delta = new OpenAIMessage(); + delta.setToolCalls(List.of(toolCall)); + delta.setRole("assistant"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setDelta(delta); + choice.setIndex(0); + + response.setChoices(List.of(choice)); + + ChatResponse result = parser.parseResponse(response, startTime); + + assertNotNull(result); + ToolUseBlock toolBlock = + result.getContent().stream() + .filter(block -> block instanceof ToolUseBlock) + .map(block -> (ToolUseBlock) block) + .findFirst() + .orElse(null); + + assertNotNull(toolBlock); + assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolBlock.getName()); + assertEquals("", toolBlock.getId()); + assertEquals("}", toolBlock.getContent()); + } + @Test @DisplayName("Should parse chunk with reasoning content") void testChunkWithReasoningContent() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIStreamingToolCallTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIStreamingToolCallTest.java index 76f631c3f3..add134450c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIStreamingToolCallTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIStreamingToolCallTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.accumulator.ToolCallsAccumulator; import io.agentscope.core.formatter.openai.dto.OpenAIChoice; import io.agentscope.core.formatter.openai.dto.OpenAIFunction; import io.agentscope.core.formatter.openai.dto.OpenAIMessage; @@ -250,6 +251,86 @@ void testToolCallWithEmptyArguments() { assertTrue(toolUse.getInput().isEmpty()); } + @Test + @DisplayName("Should merge malformed trailing chunk with non-null name") + void testMalformedTrailingChunkWithNameStillAccumulates() { + ToolCallsAccumulator accumulator = new ToolCallsAccumulator(); + + OpenAIResponse firstChunkResponse = new OpenAIResponse(); + firstChunkResponse.setId("chatcmpl-tool"); + firstChunkResponse.setObject("chat.completion.chunk"); + + OpenAIChoice firstChoice = new OpenAIChoice(); + firstChoice.setIndex(0); + + OpenAIMessage firstDelta = new OpenAIMessage(); + List firstToolCalls = new ArrayList<>(); + + OpenAIToolCall firstToolCall = new OpenAIToolCall(); + firstToolCall.setId("call_abc123"); + firstToolCall.setIndex(0); + firstToolCall.setType("function"); + + OpenAIFunction firstFunction = new OpenAIFunction(); + firstFunction.setName("retrieveFromMemory"); + firstFunction.setArguments("{\"keywords\":[\"关注\"]"); + firstToolCall.setFunction(firstFunction); + firstToolCalls.add(firstToolCall); + + firstDelta.setToolCalls(firstToolCalls); + firstChoice.setDelta(firstDelta); + firstChunkResponse.setChoices(List.of(firstChoice)); + + ChatResponse firstChunk = parser.parseResponse(firstChunkResponse, Instant.now()); + firstChunk.getContent().stream() + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .forEach(accumulator::add); + + OpenAIResponse malformedTrailingResponse = new OpenAIResponse(); + malformedTrailingResponse.setId("chatcmpl-tool"); + malformedTrailingResponse.setObject("chat.completion.chunk"); + + OpenAIChoice trailingChoice = new OpenAIChoice(); + trailingChoice.setIndex(0); + + OpenAIMessage trailingDelta = new OpenAIMessage(); + List trailingToolCalls = new ArrayList<>(); + + OpenAIToolCall trailingToolCall = new OpenAIToolCall(); + trailingToolCall.setId(null); + trailingToolCall.setIndex(0); + trailingToolCall.setType("function"); + + OpenAIFunction trailingFunction = new OpenAIFunction(); + trailingFunction.setName("retrieveFromMemory"); + trailingFunction.setArguments("}"); + trailingToolCall.setFunction(trailingFunction); + trailingToolCalls.add(trailingToolCall); + + trailingDelta.setToolCalls(trailingToolCalls); + trailingChoice.setDelta(trailingDelta); + malformedTrailingResponse.setChoices(List.of(trailingChoice)); + + ChatResponse trailingChunk = parser.parseResponse(malformedTrailingResponse, Instant.now()); + trailingChunk.getContent().stream() + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .forEach(accumulator::add); + + List accumulatedToolCalls = accumulator.buildAllToolCalls(); + + assertEquals(1, accumulatedToolCalls.size()); + ToolUseBlock accumulated = accumulatedToolCalls.get(0); + assertEquals("call_abc123", accumulated.getId()); + assertEquals("retrieveFromMemory", accumulated.getName()); + assertEquals("{\"keywords\":[\"关注\"]}", accumulated.getContent()); + assertNotNull(accumulated.getInput()); + assertEquals(1, accumulated.getInput().size()); + assertTrue(accumulated.getInput().containsKey("keywords")); + assertEquals(List.of("关注"), accumulated.getInput().get("keywords")); + } + @Test @DisplayName("Should handle tool call with null arguments") void testToolCallWithNullArguments() { @@ -290,4 +371,110 @@ void testToolCallWithNullArguments() { assertEquals("call_null", toolUse.getId()); assertEquals("null_args_tool", toolUse.getName()); } + + @Test + @DisplayName("Should generate streaming ID when first chunk has no ID") + void testStreamingToolCallGeneratesIdWhenMissing() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-tool"); + response.setObject("chat.completion.chunk"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + + OpenAIMessage delta = new OpenAIMessage(); + OpenAIToolCall toolCall = new OpenAIToolCall(); + toolCall.setId(null); + toolCall.setIndex(0); + toolCall.setType("function"); + + OpenAIFunction function = new OpenAIFunction(); + function.setName("lookup_weather"); + function.setArguments(""); + toolCall.setFunction(function); + + delta.setToolCalls(List.of(toolCall)); + choice.setDelta(delta); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = parser.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + ToolUseBlock toolUse = (ToolUseBlock) chatResponse.getContent().get(0); + assertTrue(toolUse.getId().startsWith("streaming_")); + assertEquals("lookup_weather", toolUse.getName()); + assertTrue(toolUse.getInput().isEmpty()); + } + + @Test + @DisplayName("Should keep raw content when complete streaming JSON is malformed") + void testStreamingToolCallMalformedCompleteJsonKeepsRawContent() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-tool"); + response.setObject("chat.completion.chunk"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + + OpenAIMessage delta = new OpenAIMessage(); + OpenAIToolCall toolCall = new OpenAIToolCall(); + toolCall.setId("call_bad_json"); + toolCall.setIndex(0); + toolCall.setType("function"); + + OpenAIFunction function = new OpenAIFunction(); + function.setName("broken_tool"); + function.setArguments("{\"city\":}"); + toolCall.setFunction(function); + + delta.setToolCalls(List.of(toolCall)); + choice.setDelta(delta); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = parser.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + ToolUseBlock toolUse = (ToolUseBlock) chatResponse.getContent().get(0); + assertEquals("call_bad_json", toolUse.getId()); + assertEquals("broken_tool", toolUse.getName()); + assertEquals("{\"city\":}", toolUse.getContent()); + assertTrue(toolUse.getInput().isEmpty()); + } + + @Test + @DisplayName("Should emit placeholder fragment when chunk only carries thought signature") + void testStreamingToolCallThoughtSignatureOnlyFragment() { + OpenAIResponse response = new OpenAIResponse(); + response.setId("chatcmpl-tool"); + response.setObject("chat.completion.chunk"); + + OpenAIChoice choice = new OpenAIChoice(); + choice.setIndex(0); + + OpenAIMessage delta = new OpenAIMessage(); + OpenAIToolCall toolCall = new OpenAIToolCall(); + toolCall.setId("call_sig_only"); + toolCall.setIndex(0); + toolCall.setType("function"); + toolCall.setThoughtSignature("signature_only"); + + OpenAIFunction function = new OpenAIFunction(); + function.setName(null); + function.setArguments(""); + toolCall.setFunction(function); + + delta.setToolCalls(List.of(toolCall)); + choice.setDelta(delta); + response.setChoices(List.of(choice)); + + ChatResponse chatResponse = parser.parseResponse(response, Instant.now()); + + assertNotNull(chatResponse); + ToolUseBlock toolUse = (ToolUseBlock) chatResponse.getContent().get(0); + assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolUse.getName()); + assertEquals("", toolUse.getId()); + assertEquals( + "signature_only", + toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)); + } }