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 289e02382a..3d85ed9ad3 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 @@ -39,6 +39,8 @@ */ public class ToolCallsAccumulator implements ContentAccumulator { + private static final String UNKNOWN_TOOL_NAME = "unknown_tool"; + // Map to support multiple parallel tool calls // Key: tool identifier (ID, name, or index) private final Map builders = new LinkedHashMap<>(); @@ -103,7 +105,7 @@ ToolUseBlock build() { return ToolUseBlock.builder() .id(toolId != null ? toolId : generateId()) - .name(name) + .name(name != null ? name : UNKNOWN_TOOL_NAME) .input(finalArgs) .content(rawContentStr.isEmpty() ? "{}" : rawContentStr) .metadata(metadata.isEmpty() ? null : metadata) 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..69ccb5c18d 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 @@ -461,8 +461,15 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start } } - if (toolCallId == null) { - toolCallId = "streaming_" + System.currentTimeMillis(); + if (toolCallId == null || toolCallId.isEmpty()) { + // Use index as stable synthetic id so chunks from the same + // tool call can be consistently merged across stream + // updates. + if (toolIndex != null) { + toolCallId = "streaming_idx_" + toolIndex; + } else { + toolCallId = "streaming_" + System.currentTimeMillis(); + } } if (toolName == null) { toolName = ""; @@ -538,7 +545,7 @@ protected ChatResponse parseChunkResponse(OpenAIResponse response, Instant start contentBlocks.add( ToolUseBlock.builder() - .id("") + .id(toolCallId) .name(FRAGMENT_PLACEHOLDER) .input(new HashMap<>()) .content(arguments) 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 eb197d5748..594720b3a0 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 @@ -323,4 +323,33 @@ void testMultipleParallelToolCallsWithStreamingChunks() { List allCalls = accumulator.getAllAccumulatedToolCalls(); assertEquals(2, allCalls.size()); } + + @Test + @DisplayName("Should fallback to non-null name when only fragments are received") + void testOnlyFragmentsFallbackName() { + ToolUseBlock fragment1 = + ToolUseBlock.builder() + .id("streaming_idx_0") + .name("__fragment__") + .content("{\"city\":") + .build(); + ToolUseBlock fragment2 = + ToolUseBlock.builder() + .id("streaming_idx_0") + .name("__fragment__") + .content("\"Beijing\"}") + .build(); + + accumulator.add(fragment1); + accumulator.add(fragment2); + + List result = accumulator.buildAllToolCalls(); + assertEquals(1, result.size()); + ToolUseBlock toolCall = result.get(0); + + assertNotNull(toolCall.getName()); + assertEquals("unknown_tool", toolCall.getName()); + assertEquals("{\"city\":\"Beijing\"}", toolCall.getContent()); + assertEquals("Beijing", toolCall.getInput().get("city")); + } } 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..53e9da3af9 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,78 @@ void testStreamingToolCallFragment() { assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, toolBlock.getName()); } + @Test + @DisplayName("Should use stable synthetic id for streaming tool calls by index") + void testStreamingToolCallUsesStableSyntheticIdByIndex() { + // First chunk: fragment only (no id, no name), has index + OpenAIResponse firstChunk = new OpenAIResponse(); + firstChunk.setObject("chat.completion.chunk"); + + OpenAIFunction firstFunction = new OpenAIFunction(); + firstFunction.setName(null); + firstFunction.setArguments("{\"city\":"); + + OpenAIToolCall firstToolCall = new OpenAIToolCall(); + firstToolCall.setId(null); + firstToolCall.setIndex(0); + firstToolCall.setType("function"); + firstToolCall.setFunction(firstFunction); + + OpenAIMessage firstDelta = new OpenAIMessage(); + firstDelta.setToolCalls(List.of(firstToolCall)); + firstDelta.setRole("assistant"); + + OpenAIChoice firstChoice = new OpenAIChoice(); + firstChoice.setDelta(firstDelta); + firstChoice.setIndex(0); + firstChunk.setChoices(List.of(firstChoice)); + + ChatResponse firstResult = parser.parseResponse(firstChunk, startTime); + ToolUseBlock firstBlock = + firstResult.getContent().stream() + .filter(block -> block instanceof ToolUseBlock) + .map(block -> (ToolUseBlock) block) + .findFirst() + .orElse(null); + assertNotNull(firstBlock); + assertEquals("streaming_idx_0", firstBlock.getId()); + assertEquals(OpenAIResponseParser.FRAGMENT_PLACEHOLDER, firstBlock.getName()); + + // Second chunk: same index, now with tool name, still no id + OpenAIResponse secondChunk = new OpenAIResponse(); + secondChunk.setObject("chat.completion.chunk"); + + OpenAIFunction secondFunction = new OpenAIFunction(); + secondFunction.setName("get_weather"); + secondFunction.setArguments("\"Beijing\"}"); + + OpenAIToolCall secondToolCall = new OpenAIToolCall(); + secondToolCall.setId(null); + secondToolCall.setIndex(0); + secondToolCall.setType("function"); + secondToolCall.setFunction(secondFunction); + + OpenAIMessage secondDelta = new OpenAIMessage(); + secondDelta.setToolCalls(List.of(secondToolCall)); + secondDelta.setRole("assistant"); + + OpenAIChoice secondChoice = new OpenAIChoice(); + secondChoice.setDelta(secondDelta); + secondChoice.setIndex(0); + secondChunk.setChoices(List.of(secondChoice)); + + ChatResponse secondResult = parser.parseResponse(secondChunk, startTime); + ToolUseBlock secondBlock = + secondResult.getContent().stream() + .filter(block -> block instanceof ToolUseBlock) + .map(block -> (ToolUseBlock) block) + .findFirst() + .orElse(null); + assertNotNull(secondBlock); + assertEquals("streaming_idx_0", secondBlock.getId()); + assertEquals("get_weather", secondBlock.getName()); + } + @Test @DisplayName("Should parse chunk with reasoning content") void testChunkWithReasoningContent() {