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
2 changes: 1 addition & 1 deletion agentscope-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.util.JsonUtils;
import io.agentscope.core.util.JsonParseHelper;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
Expand Down Expand Up @@ -87,17 +87,13 @@ ToolUseBlock build() {
Map<String, Object> finalArgs = new HashMap<>(args);
String rawContentStr = this.rawContent.toString();

// If no parsed arguments but has raw JSON content, try to parse
// If no parsed arguments but has raw JSON content, try to parse (with fallback
// for literal newlines inside strings, e.g. HTML content from LLM)
if (finalArgs.isEmpty() && rawContentStr.length() > 0) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed =
JsonUtils.getJsonCodec().fromJson(rawContentStr, Map.class);
if (parsed != null) {
finalArgs.putAll(parsed);
}
} catch (Exception ignored) {
// Parsing failed, keep empty args
Map<String, Object> parsed =
JsonParseHelper.parseMapWithNewlineFallback(rawContentStr);
if (parsed != null) {
finalArgs.putAll(parsed);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.tracing.TracerRegistry;
import io.agentscope.core.util.ExceptionUtils;
import io.agentscope.core.util.JsonParseHelper;
import io.agentscope.core.util.JsonUtils;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -141,9 +143,33 @@ private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
return Mono.just(ToolResultBlock.error(errorMsg));
}

// Validate input against schema
String validationError =
ToolValidator.validateInput(toolCall.getContent(), tool.getParameters());
// Merge preset parameters with input (before validation so we validate the same
// structure we will pass to the tool; supports content fallback for newlines in strings)
Map<String, Object> mergedInput = new HashMap<>();
if (registered != null) {
mergedInput.putAll(registered.getPresetParameters());
}
if (param.getInput() != null && !param.getInput().isEmpty()) {
mergedInput.putAll(param.getInput());
} else if (toolCall.getInput() != null && !toolCall.getInput().isEmpty()) {
mergedInput.putAll(toolCall.getInput());
} else {
// Fallback: parse raw content when input map is empty (e.g. streaming with
// newlines in string values caused parse failure in accumulator)
String content = toolCall.getContent();
if (content != null && !content.isEmpty()) {
Map<String, Object> fromContent =
JsonParseHelper.parseMapWithNewlineFallback(content);
if (fromContent != null && !fromContent.isEmpty()) {
mergedInput.putAll(fromContent);
}
}
}

// Validate merged input against schema (validates the actual input we will use)
String inputJson =
mergedInput.isEmpty() ? "{}" : JsonUtils.getJsonCodec().toJson(mergedInput);
String validationError = ToolValidator.validateInput(inputJson, tool.getParameters());
if (validationError != null) {
String errorMsg =
String.format(
Expand All @@ -162,17 +188,6 @@ private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
// Create emitter for streaming
ToolEmitter toolEmitter = new DefaultToolEmitter(toolCall, chunkCallback);

// Merge preset parameters with input
Map<String, Object> mergedInput = new HashMap<>();
if (registered != null) {
mergedInput.putAll(registered.getPresetParameters());
}
if (param.getInput() != null && !param.getInput().isEmpty()) {
mergedInput.putAll(param.getInput());
} else if (toolCall.getInput() != null) {
mergedInput.putAll(toolCall.getInput());
}

// Build final execution param
ToolCallParam executionParam =
ToolCallParam.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.agentscope.core.util;

import java.util.Map;

/**
* Helper for parsing JSON that may contain invalid characters (e.g. literal newlines inside
* string values). LLMs sometimes output pretty-printed or multi-line strings which are not
* valid JSON; this helper allows best-effort recovery.
*/
public final class JsonParseHelper {

private JsonParseHelper() {}

/**
* Parse a JSON string into a map, with fallback to sanitizing literal newlines inside
* strings when the first attempt fails. Use for tool input JSON that may contain
* multi-line content (e.g. HTML).
*
* @param json JSON string (e.g. tool call arguments)
* @return parsed map, or null if parsing failed even after sanitization
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> parseMapWithNewlineFallback(String json) {
if (json == null || json.isEmpty()) {
return null;
}
try {
Object parsed = JsonUtils.getJsonCodec().fromJson(json, Map.class);
return parsed != null ? (Map<String, Object>) parsed : null;
} catch (Exception e) {
String sanitized = escapeNewlinesInJsonStrings(json);
try {
Object parsed = JsonUtils.getJsonCodec().fromJson(sanitized, Map.class);
return parsed != null ? (Map<String, Object>) parsed : null;
} catch (Exception e2) {
return null;
}
}
}
Comment on lines +26 to +55

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While the fallback logic is great for robustness, swallowing exceptions without logging can make it very difficult to debug issues where JSON parsing fails for unexpected reasons. It's a good practice to log these exceptions, at least at a debug or warn level, to provide visibility into why parsing is failing. This helps in identifying if the sanitization logic needs to be improved or if the input is fundamentally malformed.

public final class JsonParseHelper {

    private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(JsonParseHelper.class);

    private JsonParseHelper() {}

    /**
     * Parse a JSON string into a map, with fallback to sanitizing literal newlines inside
     * strings when the first attempt fails. Use for tool input JSON that may contain
     * multi-line content (e.g. HTML).
     *
     * @param json JSON string (e.g. tool call arguments)
     * @return parsed map, or null if parsing failed even after sanitization
     */
    @SuppressWarnings("unchecked")
    public static Map<String, Object> parseMapWithNewlineFallback(String json) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        try {
            Object parsed = JsonUtils.getJsonCodec().fromJson(json, Map.class);
            return parsed != null ? (Map<String, Object>) parsed : null;
        } catch (Exception e) {
            logger.debug("Failed to parse JSON, trying to sanitize newlines. Error: {}", e.getMessage());
            String sanitized = escapeNewlinesInJsonStrings(json);
            try {
                Object parsed = JsonUtils.getJsonCodec().fromJson(sanitized, Map.class);
                return parsed != null ? (Map<String, Object>) parsed : null;
            } catch (Exception e2) {
                logger.warn("Failed to parse JSON even after sanitizing newlines. Error: {}", e2.getMessage());
                return null;
            }
        }
    }


/**
* Sanitize a JSON string by escaping literal newlines and carriage returns inside
* double-quoted string values. Standard JSON does not allow unescaped newlines in strings;
* when an LLM returns tool arguments with multi-line content (e.g. HTML with newlines),
* parsing fails. This method makes such content parseable.
*
* @param raw non-null JSON-like string
* @return new string with \n and \r inside quoted strings replaced by \\n and \\r
*/
public static String escapeNewlinesInJsonStrings(String raw) {
if (raw == null || raw.isEmpty()) {
return raw;
}
StringBuilder sb = new StringBuilder(raw.length() * 2);
boolean inString = false;
boolean escape = false;
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (escape) {
sb.append(c);
escape = false;
continue;
}
if (c == '\\') {
sb.append(c);
escape = true;
continue;
}
if (c == '"') {
inString = !inString;
sb.append(c);
continue;
}
if (inString) {
if (c == '\n') {
sb.append("\\n");
} else if (c == '\r') {
sb.append("\\r");
} else {
sb.append(c);
}
} else {
sb.append(c);
}
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,32 @@ void testMultipleParallelToolCallsWithStreamingChunks() {
List<ToolUseBlock> allCalls = accumulator.getAllAccumulatedToolCalls();
assertEquals(2, allCalls.size());
}

@Test
@DisplayName("Should parse raw content with literal newlines in string (e.g. HTML from LLM)")
void testRawContentWithNewlinesInStringParsedViaFallback() {
// Simulate LLM returning tool args as JSON with unescaped newlines inside string
// (invalid JSON; parse fails without fallback, leaving input empty)
String rawContent =
"{\"file_path\":\"out.html\",\"content\":\"<html>\n"
+ " <body>\n"
+ " <p>Hi</p>\n"
+ " </body>\n"
+ "</html>\"}";
ToolUseBlock chunk =
ToolUseBlock.builder().id("call_1").name("write_file").content(rawContent).build();

accumulator.add(chunk);

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

assertEquals("call_1", toolCall.getId());
assertEquals("write_file", toolCall.getName());
Map<String, Object> input = toolCall.getInput();
assertNotNull(input);
assertEquals("out.html", input.get("file_path"));
assertEquals("<html>\n <body>\n <p>Hi</p>\n </body>\n</html>", input.get("content"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.agentscope.core.tool;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -359,6 +360,39 @@ public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
}
}

@Test
@DisplayName(
"Should run tool when input is empty but content has JSON with newlines (executor"
+ " fallback)")
void shouldRunToolWhenInputEmptyButContentHasNewlines() {
// Simulate issue #768: ToolUseBlock has empty input (e.g. parse failed in accumulator)
// but content contains JSON with literal newlines in string values (e.g. HTML)
String contentWithLiteralNewlines =
"{\"str1\":\"<html>\n <body>\",\"str2\":\"</body>\n</html>\"}";

ToolUseBlock toolCall =
ToolUseBlock.builder()
.id("call-concat")
.name("concat")
.input(new HashMap<>())
.content(contentWithLiteralNewlines)
.build();

ToolResultBlock result =
toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build())
.block(TIMEOUT);

assertNotNull(result, "Tool should be invoked via content fallback");
assertTrue(ToolTestUtils.isValidToolResultBlock(result), "Tool should return valid result");
String text = extractFirstText(result);
assertFalse(text.startsWith("Error:"), "Tool should succeed, not error: " + text);
assertNotNull(text);
// concat(str1, str2) -> str1 + str2 with newlines preserved
assertTrue(
text.contains("<html>") && text.contains("</html>"),
"Result should contain concatenated strings: " + text);
}

private String extractFirstText(ToolResultBlock response) {
assertTrue(
ToolTestUtils.isValidToolResultBlock(response),
Expand Down
Loading
Loading