Skip to content

Commit ba83f93

Browse files
committed
fix(model): normalize malformed streaming tool call fragments
1 parent de01c66 commit ba83f93

4 files changed

Lines changed: 201 additions & 4 deletions

File tree

agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIResponseParser.java

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ public class OpenAIResponseParser {
5151
/** Placeholder name for tool call argument fragments in streaming responses. */
5252
protected static final String FRAGMENT_PLACEHOLDER = "__fragment__";
5353

54+
/**
55+
* Some OpenAI-compatible providers occasionally emit a malformed trailing argument fragment
56+
* with a non-null tool name but no tool call id. That chunk should still be treated as a
57+
* fragment so it can merge back into the previously started tool call instead of becoming a
58+
* new synthetic call.
59+
*/
60+
private boolean isMalformedNamedStreamingFragment(
61+
String toolCallId, String toolName, String arguments) {
62+
return toolCallId == null
63+
&& toolName != null
64+
&& !toolName.isEmpty()
65+
&& arguments != null
66+
&& !arguments.isEmpty();
67+
}
68+
5469
/**
5570
* Safely get prompt token count from usage, returning 0 if null or invalid.
5671
*
@@ -461,16 +476,21 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start
461476
}
462477
}
463478

464-
if (toolCallId == null) {
465-
toolCallId = "streaming_" + System.currentTimeMillis();
466-
}
467479
if (toolName == null) {
468480
toolName = "";
469481
}
470482
if (arguments == null) {
471483
arguments = "";
472484
}
473485

486+
boolean malformedNamedFragment =
487+
isMalformedNamedStreamingFragment(
488+
toolCallId, toolName, arguments);
489+
490+
if (!malformedNamedFragment && toolCallId == null) {
491+
toolCallId = "streaming_" + System.currentTimeMillis();
492+
}
493+
474494
log.debug(
475495
"Streaming tool call chunk: id={}, name={},"
476496
+ " arguments={}, signature={}",
@@ -481,7 +501,7 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start
481501

482502
// For streaming, we get partial tool calls that need to be
483503
// accumulated
484-
if (!toolName.isEmpty()) {
504+
if (!toolName.isEmpty() && !malformedNamedFragment) {
485505
// First chunk with complete metadata (has tool name)
486506
Map<String, Object> argsMap = new HashMap<>();
487507

@@ -529,6 +549,17 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start
529549
} else if (!arguments.isEmpty() || thoughtSignature != null) {
530550
// Subsequent chunks with only argument fragments or just
531551
// signature
552+
if (malformedNamedFragment) {
553+
log.debug(
554+
"Treating malformed named streaming tool call"
555+
+ " chunk as fragment: name={},"
556+
+ " arguments={}",
557+
toolName,
558+
arguments.length() > 50
559+
? arguments.substring(0, 50) + "..."
560+
: arguments);
561+
}
562+
532563
Map<String, Object> metadata = new HashMap<>();
533564
if (thoughtSignature != null) {
534565
metadata.put(

agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIChatFormatterTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,19 @@
2626
import io.agentscope.core.formatter.ResponseFormat;
2727
import io.agentscope.core.formatter.openai.dto.JsonSchema;
2828
import io.agentscope.core.formatter.openai.dto.OpenAIChoice;
29+
import io.agentscope.core.formatter.openai.dto.OpenAIFunction;
2930
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
3031
import io.agentscope.core.formatter.openai.dto.OpenAIRequest;
3132
import io.agentscope.core.formatter.openai.dto.OpenAIResponse;
3233
import io.agentscope.core.formatter.openai.dto.OpenAITool;
34+
import io.agentscope.core.formatter.openai.dto.OpenAIToolCall;
3335
import io.agentscope.core.formatter.openai.dto.OpenAIToolFunction;
3436
import io.agentscope.core.formatter.openai.dto.OpenAIUsage;
3537
import io.agentscope.core.message.ContentBlock;
3638
import io.agentscope.core.message.Msg;
3739
import io.agentscope.core.message.MsgRole;
3840
import io.agentscope.core.message.TextBlock;
41+
import io.agentscope.core.message.ToolUseBlock;
3942
import io.agentscope.core.model.ChatResponse;
4043
import io.agentscope.core.model.GenerateOptions;
4144
import io.agentscope.core.model.ToolChoice;
@@ -316,6 +319,46 @@ void testParseResponse() {
316319
assertEquals(20, chatResponse.getUsage().getOutputTokens());
317320
}
318321

322+
@Test
323+
@DisplayName("Should parse malformed named streaming tool fragment through formatter")
324+
void testParseMalformedNamedStreamingToolFragment() {
325+
OpenAIResponse response = new OpenAIResponse();
326+
response.setId("chatcmpl-123");
327+
response.setObject("chat.completion.chunk");
328+
329+
OpenAIFunction function = new OpenAIFunction();
330+
function.setName("retrieveFromMemory");
331+
function.setArguments("}");
332+
333+
OpenAIToolCall toolCall = new OpenAIToolCall();
334+
toolCall.setId(null);
335+
toolCall.setIndex(0);
336+
toolCall.setType("function");
337+
toolCall.setFunction(function);
338+
339+
OpenAIMessage delta = new OpenAIMessage();
340+
delta.setRole("assistant");
341+
delta.setToolCalls(List.of(toolCall));
342+
343+
OpenAIChoice choice = new OpenAIChoice();
344+
choice.setIndex(0);
345+
choice.setDelta(delta);
346+
response.setChoices(List.of(choice));
347+
348+
ChatResponse chatResponse = formatter.parseResponse(response, Instant.now());
349+
350+
assertNotNull(chatResponse);
351+
assertNotNull(chatResponse.getContent());
352+
assertEquals(1, chatResponse.getContent().size());
353+
ContentBlock contentBlock = chatResponse.getContent().get(0);
354+
assertInstanceOf(ToolUseBlock.class, contentBlock);
355+
356+
ToolUseBlock toolUseBlock = (ToolUseBlock) contentBlock;
357+
assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolUseBlock.getName());
358+
assertEquals("", toolUseBlock.getId());
359+
assertEquals("}", toolUseBlock.getContent());
360+
}
361+
319362
@Test
320363
@DisplayName("Should handle null tool choice gracefully")
321364
void testBuildRequestWithNullToolChoice() {

agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIResponseParserTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,48 @@ void testStreamingToolCallFragment() {
709709
assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolBlock.getName());
710710
}
711711

712+
@Test
713+
@DisplayName("Should treat malformed named trailing chunk as fragment")
714+
void testStreamingToolCallMalformedNamedTrailingFragment() {
715+
OpenAIResponse response = new OpenAIResponse();
716+
response.setObject("chat.completion.chunk");
717+
718+
OpenAIFunction function = new OpenAIFunction();
719+
function.setName("retrieveFromMemory");
720+
function.setArguments("}");
721+
722+
OpenAIToolCall toolCall = new OpenAIToolCall();
723+
toolCall.setId(null);
724+
toolCall.setIndex(0);
725+
toolCall.setType("function");
726+
toolCall.setFunction(function);
727+
728+
OpenAIMessage delta = new OpenAIMessage();
729+
delta.setToolCalls(List.of(toolCall));
730+
delta.setRole("assistant");
731+
732+
OpenAIChoice choice = new OpenAIChoice();
733+
choice.setDelta(delta);
734+
choice.setIndex(0);
735+
736+
response.setChoices(List.of(choice));
737+
738+
ChatResponse result = parser.parseResponse(response, startTime);
739+
740+
assertNotNull(result);
741+
ToolUseBlock toolBlock =
742+
result.getContent().stream()
743+
.filter(block -> block instanceof ToolUseBlock)
744+
.map(block -> (ToolUseBlock) block)
745+
.findFirst()
746+
.orElse(null);
747+
748+
assertNotNull(toolBlock);
749+
assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolBlock.getName());
750+
assertEquals("", toolBlock.getId());
751+
assertEquals("}", toolBlock.getContent());
752+
}
753+
712754
@Test
713755
@DisplayName("Should parse chunk with reasoning content")
714756
void testChunkWithReasoningContent() {

agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAIStreamingToolCallTest.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.junit.jupiter.api.Assertions.assertNotNull;
2020
import static org.junit.jupiter.api.Assertions.assertTrue;
2121

22+
import io.agentscope.core.agent.accumulator.ToolCallsAccumulator;
2223
import io.agentscope.core.formatter.openai.dto.OpenAIChoice;
2324
import io.agentscope.core.formatter.openai.dto.OpenAIFunction;
2425
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
@@ -250,6 +251,86 @@ void testToolCallWithEmptyArguments() {
250251
assertTrue(toolUse.getInput().isEmpty());
251252
}
252253

254+
@Test
255+
@DisplayName("Should merge malformed trailing chunk with non-null name")
256+
void testMalformedTrailingChunkWithNameStillAccumulates() {
257+
ToolCallsAccumulator accumulator = new ToolCallsAccumulator();
258+
259+
OpenAIResponse firstChunkResponse = new OpenAIResponse();
260+
firstChunkResponse.setId("chatcmpl-tool");
261+
firstChunkResponse.setObject("chat.completion.chunk");
262+
263+
OpenAIChoice firstChoice = new OpenAIChoice();
264+
firstChoice.setIndex(0);
265+
266+
OpenAIMessage firstDelta = new OpenAIMessage();
267+
List<OpenAIToolCall> firstToolCalls = new ArrayList<>();
268+
269+
OpenAIToolCall firstToolCall = new OpenAIToolCall();
270+
firstToolCall.setId("call_abc123");
271+
firstToolCall.setIndex(0);
272+
firstToolCall.setType("function");
273+
274+
OpenAIFunction firstFunction = new OpenAIFunction();
275+
firstFunction.setName("retrieveFromMemory");
276+
firstFunction.setArguments("{\"keywords\":[\"关注\"]");
277+
firstToolCall.setFunction(firstFunction);
278+
firstToolCalls.add(firstToolCall);
279+
280+
firstDelta.setToolCalls(firstToolCalls);
281+
firstChoice.setDelta(firstDelta);
282+
firstChunkResponse.setChoices(List.of(firstChoice));
283+
284+
ChatResponse firstChunk = parser.parseResponse(firstChunkResponse, Instant.now());
285+
firstChunk.getContent().stream()
286+
.filter(ToolUseBlock.class::isInstance)
287+
.map(ToolUseBlock.class::cast)
288+
.forEach(accumulator::add);
289+
290+
OpenAIResponse malformedTrailingResponse = new OpenAIResponse();
291+
malformedTrailingResponse.setId("chatcmpl-tool");
292+
malformedTrailingResponse.setObject("chat.completion.chunk");
293+
294+
OpenAIChoice trailingChoice = new OpenAIChoice();
295+
trailingChoice.setIndex(0);
296+
297+
OpenAIMessage trailingDelta = new OpenAIMessage();
298+
List<OpenAIToolCall> trailingToolCalls = new ArrayList<>();
299+
300+
OpenAIToolCall trailingToolCall = new OpenAIToolCall();
301+
trailingToolCall.setId(null);
302+
trailingToolCall.setIndex(0);
303+
trailingToolCall.setType("function");
304+
305+
OpenAIFunction trailingFunction = new OpenAIFunction();
306+
trailingFunction.setName("retrieveFromMemory");
307+
trailingFunction.setArguments("}");
308+
trailingToolCall.setFunction(trailingFunction);
309+
trailingToolCalls.add(trailingToolCall);
310+
311+
trailingDelta.setToolCalls(trailingToolCalls);
312+
trailingChoice.setDelta(trailingDelta);
313+
malformedTrailingResponse.setChoices(List.of(trailingChoice));
314+
315+
ChatResponse trailingChunk = parser.parseResponse(malformedTrailingResponse, Instant.now());
316+
trailingChunk.getContent().stream()
317+
.filter(ToolUseBlock.class::isInstance)
318+
.map(ToolUseBlock.class::cast)
319+
.forEach(accumulator::add);
320+
321+
List<ToolUseBlock> accumulatedToolCalls = accumulator.buildAllToolCalls();
322+
323+
assertEquals(1, accumulatedToolCalls.size());
324+
ToolUseBlock accumulated = accumulatedToolCalls.get(0);
325+
assertEquals("call_abc123", accumulated.getId());
326+
assertEquals("retrieveFromMemory", accumulated.getName());
327+
assertEquals("{\"keywords\":[\"关注\"]}", accumulated.getContent());
328+
assertNotNull(accumulated.getInput());
329+
assertEquals(1, accumulated.getInput().size());
330+
assertTrue(accumulated.getInput().containsKey("keywords"));
331+
assertEquals(List.of("关注"), accumulated.getInput().get("keywords"));
332+
}
333+
253334
@Test
254335
@DisplayName("Should handle tool call with null arguments")
255336
void testToolCallWithNullArguments() {

0 commit comments

Comments
 (0)