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 @@ -101,11 +101,23 @@ ToolUseBlock build() {
}
}

// Always validate rawContent is a legal JSON object before using it
// as content. This prevents persisting malformed JSON fragments
// (e.g. when streaming was interrupted mid-arguments).
String contentStr;
if (rawContentStr.isEmpty()) {
contentStr = "{}";
} else if (JsonUtils.isValidJsonObject(rawContentStr)) {
contentStr = rawContentStr;
} else {
contentStr = "{}";
}

return ToolUseBlock.builder()
.id(toolId != null ? toolId : generateId())
.name(name)
.input(finalArgs)
.content(rawContentStr.isEmpty() ? "{}" : rawContentStr)
.content(contentStr)
.metadata(metadata.isEmpty() ? null : metadata)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,19 +236,7 @@ public List<DashScopeToolCall> convertToolCalls(List<ToolUseBlock> toolBlocks) {
continue;
}

// Prioritize using content field (raw arguments string), fallback to input map
// serialization
String argsJson;
if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) {
argsJson = toolUse.getContent();
} else {
try {
argsJson = JsonUtils.getJsonCodec().toJson(toolUse.getInput());
} catch (Exception e) {
log.warn("Failed to serialize tool call arguments: {}", e.getMessage());
argsJson = "{}";
}
}
String argsJson = JsonUtils.resolveToolCallArgsJson(toolUse);

DashScopeFunction function = DashScopeFunction.of(toolUse.getName(), argsJson);
DashScopeToolCall toolCall =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,23 +330,7 @@ private OpenAIMessage convertAssistantMessage(Msg msg) {
continue;
}

// Prioritize using content field (raw arguments string), fallback to input map
// serialization
String argsJson;
if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) {
argsJson = toolUse.getContent();
} else {
try {
argsJson = JsonUtils.getJsonCodec().toJson(toolUse.getInput());
} catch (Exception e) {
String errorMsg =
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName();
log.warn("Failed to serialize tool call arguments: {}", errorMsg);
argsJson = "{}";
}
}
String argsJson = JsonUtils.resolveToolCallArgsJson(toolUse);

// Add thought signature if present in metadata (required for Gemini)
String signature = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@

package io.agentscope.core.util;

import io.agentscope.core.message.ToolUseBlock;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Utility class for accessing the global {@link JsonCodec} instance.
*
Expand Down Expand Up @@ -43,6 +48,8 @@
*/
public final class JsonUtils {

private static final Logger log = LoggerFactory.getLogger(JsonUtils.class);

private static volatile JsonCodec codec = new JacksonJsonCodec();

private JsonUtils() {
Expand Down Expand Up @@ -82,4 +89,64 @@ public static void setJsonCodec(JsonCodec jsonCodec) {
public static void resetToDefault() {
codec = new JacksonJsonCodec();
}

/**
* Check whether the given string is a valid JSON object (i.e. starts with '{' and
* can be parsed into a {@link Map}).
*
* <p>Tool call {@code arguments} must be JSON objects, so plain JSON values like
* {@code null}, arrays, or strings are rejected.
*
* @param str the string to validate
* @return {@code true} if {@code str} is a non-null, parseable JSON object
*/
@SuppressWarnings("unchecked")
public static boolean isValidJsonObject(String str) {
if (str == null || str.isEmpty()) {
return false;
}
try {
Map<String, Object> parsed = codec.fromJson(str, Map.class);
return parsed != null;
} catch (Exception e) {
return false;
}
}

/**
* Resolve the arguments JSON string from a {@link ToolUseBlock}, ensuring the
* result is always a valid JSON object.
*
* <p>Resolution order:
* <ol>
* <li>Use {@link ToolUseBlock#getContent()} if it is a valid JSON object</li>
* <li>Serialize {@link ToolUseBlock#getInput()} via {@link JsonCodec#toJson}</li>
* <li>Fall back to {@code "{}"}</li>
* </ol>
*
* <p>This prevents sending malformed JSON (e.g. from interrupted streaming) as
* tool call arguments, which would cause model APIs to reject the request.
*
* @param toolUse the tool use block
* @return a valid JSON object string representing the tool call arguments
*/
public static String resolveToolCallArgsJson(ToolUseBlock toolUse) {
String content = toolUse.getContent();
if (content != null && !content.isEmpty()) {
if (isValidJsonObject(content)) {
return content;
}
log.warn(
"Invalid JSON in tool call content for '{}', falling back to input"
+ " serialization",
toolUse.getName());
}

try {
return codec.toJson(toolUse.getInput());
} catch (Exception e) {
log.warn("Failed to serialize tool call arguments: {}", e.getMessage());
return "{}";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,93 @@ void testMultipleParallelToolCallsWithStreamingChunks() {
List<ToolUseBlock> allCalls = accumulator.getAllAccumulatedToolCalls();
assertEquals(2, allCalls.size());
}

@Test
@DisplayName("Should produce valid JSON content when streaming is interrupted mid-arguments")
void testInterruptedStreamingProducesValidJsonContent() {
// Simulate streaming that gets interrupted mid-arguments:
// Model was outputting {"query": "hello wor... but got cut off
ToolUseBlock chunk1 =
ToolUseBlock.builder()
.id("call_1")
.name("search")
.content("{\"query\": \"hello wor")
.build();

accumulator.add(chunk1);

List<ToolUseBlock> result = accumulator.buildAllToolCalls();
assertEquals(1, result.size());

ToolUseBlock toolCall = result.get(0);
// Content should fall back to "{}" since the raw content is invalid JSON
assertEquals("{}", toolCall.getContent());
// Input should be empty since parsing failed
assertTrue(toolCall.getInput().isEmpty());
}

@Test
@DisplayName("Should produce valid JSON content when multiple chunks are interrupted")
void testInterruptedMultiChunkStreamingProducesValidJsonContent() {
// First chunk starts the arguments
ToolUseBlock chunk1 =
ToolUseBlock.builder()
.id("call_1")
.name("get_weather")
.content("{\"city\":")
.build();

// Second chunk is a partial value — streaming interrupted here
ToolUseBlock chunk2 =
ToolUseBlock.builder().id("call_1").name("__fragment__").content("\"Bei").build();

accumulator.add(chunk1);
accumulator.add(chunk2);

List<ToolUseBlock> result = accumulator.buildAllToolCalls();
assertEquals(1, result.size());

ToolUseBlock toolCall = result.get(0);
assertEquals("{}", toolCall.getContent());
assertTrue(toolCall.getInput().isEmpty());
}

@Test
@DisplayName("Should handle non-object JSON content like arrays or null")
void testNonObjectJsonContentFallsBackToEmpty() {
ToolUseBlock chunk =
ToolUseBlock.builder().id("call_1").name("tool").content("[1, 2, 3]").build();

accumulator.add(chunk);

List<ToolUseBlock> result = accumulator.buildAllToolCalls();
assertEquals(1, result.size());
// Arrays are not valid JSON objects for tool call arguments
assertEquals("{}", result.get(0).getContent());
}

@Test
@DisplayName("Should preserve valid content even when input was populated via merge")
void testValidContentPreservedWithMergedInput() {
// First chunk: input populated via parsed args
Map<String, Object> args = new HashMap<>();
args.put("city", "Tokyo");
ToolUseBlock chunk1 =
ToolUseBlock.builder()
.id("call_1")
.name("weather")
.input(args)
.content("{\"city\": \"Tokyo\"}")
.build();

accumulator.add(chunk1);

List<ToolUseBlock> result = accumulator.buildAllToolCalls();
assertEquals(1, result.size());

ToolUseBlock toolCall = result.get(0);
// Valid JSON content should be preserved
assertEquals("{\"city\": \"Tokyo\"}", toolCall.getContent());
assertEquals("Tokyo", toolCall.getInput().get("city"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,46 @@ void testConvertToolCallsWithEmptyInput() {
assertEquals("{}", result.get(0).getFunction().getArguments());
}

@Test
void testConvertToolCallsWithInvalidJsonContent() {
// Simulate interrupted streaming: content is incomplete JSON
ToolUseBlock block =
ToolUseBlock.builder()
.id("call_broken")
.name("search")
.input(Map.of("query", "test"))
.content("{\"query\": \"hel")
.build();

List<DashScopeToolCall> result = helper.convertToolCalls(List.of(block));

assertEquals(1, result.size());
String argsJson = result.get(0).getFunction().getArguments();
// Should fall back to input serialization, not the broken content
assertTrue(argsJson.contains("query"));
assertTrue(argsJson.contains("test"));
}

@Test
void testConvertToolCallsWithNonObjectJsonContent() {
// Content is valid JSON but not an object (array)
ToolUseBlock block =
ToolUseBlock.builder()
.id("call_array")
.name("tool")
.input(Map.of("key", "value"))
.content("[1, 2, 3]")
.build();

List<DashScopeToolCall> result = helper.convertToolCalls(List.of(block));

assertEquals(1, result.size());
String argsJson = result.get(0).getFunction().getArguments();
// Should fall back to input since content is not a JSON object
assertTrue(argsJson.contains("key"));
assertTrue(argsJson.contains("value"));
}

@Test
void testConvertToolCallsWithComplexArgs() {
Map<String, Object> complexArgs = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,54 @@ void testToolCallFallbackToInputMapWhenContentEmpty() {
assertTrue(args.contains("city"));
assertTrue(args.contains("Shanghai"));
}

@Test
@DisplayName(
"Should fallback to input when content is invalid JSON (interrupted streaming)")
void testToolCallFallbackWhenContentIsInvalidJson() {
ToolUseBlock toolBlock =
ToolUseBlock.builder()
.id("call_broken")
.name("search")
.input(Map.of("query", "hello"))
.content("{\"query\": \"hel")
.build();

Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();

OpenAIMessage result = converter.convertToMessage(msg, false);

assertNotNull(result);
assertNotNull(result.getToolCalls());
assertEquals(1, result.getToolCalls().size());
String args = result.getToolCalls().get(0).getFunction().getArguments();
// Should fall back to input serialization
assertTrue(args.contains("query"));
assertTrue(args.contains("hello"));
}

@Test
@DisplayName("Should fallback to input when content is non-object JSON like array")
void testToolCallFallbackWhenContentIsNonObjectJson() {
ToolUseBlock toolBlock =
ToolUseBlock.builder()
.id("call_array")
.name("tool")
.input(Map.of("key", "value"))
.content("[1, 2, 3]")
.build();

Msg msg = Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolBlock)).build();

OpenAIMessage result = converter.convertToMessage(msg, false);

assertNotNull(result);
assertNotNull(result.getToolCalls());
assertEquals(1, result.getToolCalls().size());
String args = result.getToolCalls().get(0).getFunction().getArguments();
assertTrue(args.contains("key"));
assertTrue(args.contains("value"));
}
}

@Nested
Expand Down
Loading
Loading