Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -461,16 +476,21 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start
}
}

if (toolCallId == null) {
toolCallId = "streaming_" + System.currentTimeMillis();
}
if (toolName == null) {
toolName = "";
}
if (arguments == null) {
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={}",
Expand All @@ -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<String, Object> argsMap = new HashMap<>();

Expand Down Expand Up @@ -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<String, Object> metadata = new HashMap<>();
if (thoughtSignature != null) {
metadata.put(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OpenAIToolCall> 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<OpenAIToolCall> 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<ToolUseBlock> 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() {
Expand Down Expand Up @@ -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));
}
}
Loading