diff --git a/agentscope-core/pom.xml b/agentscope-core/pom.xml index 54bf500a7..68741dfd7 100644 --- a/agentscope-core/pom.xml +++ b/agentscope-core/pom.xml @@ -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, 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 289e02382..30da60f87 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 @@ -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; @@ -87,17 +87,13 @@ ToolUseBlock build() { Map 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 parsed = - JsonUtils.getJsonCodec().fromJson(rawContentStr, Map.class); - if (parsed != null) { - finalArgs.putAll(parsed); - } - } catch (Exception ignored) { - // Parsing failed, keep empty args + Map parsed = + JsonParseHelper.parseMapWithNewlineFallback(rawContentStr); + if (parsed != null) { + finalArgs.putAll(parsed); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java index 8e0eb0794..afcf73192 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java @@ -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; @@ -141,9 +143,33 @@ private Mono 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 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 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( @@ -162,17 +188,6 @@ private Mono executeCore(ToolCallParam param) { // Create emitter for streaming ToolEmitter toolEmitter = new DefaultToolEmitter(toolCall, chunkCallback); - // Merge preset parameters with input - Map 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() diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonParseHelper.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonParseHelper.java new file mode 100644 index 000000000..e1b132ef8 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonParseHelper.java @@ -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 parseMapWithNewlineFallback(String json) { + if (json == null || json.isEmpty()) { + return null; + } + try { + Object parsed = JsonUtils.getJsonCodec().fromJson(json, Map.class); + return parsed != null ? (Map) parsed : null; + } catch (Exception e) { + String sanitized = escapeNewlinesInJsonStrings(json); + try { + Object parsed = JsonUtils.getJsonCodec().fromJson(sanitized, Map.class); + return parsed != null ? (Map) parsed : null; + } catch (Exception e2) { + 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(); + } +} 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 eb197d574..ffff4d984 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,32 @@ void testMultipleParallelToolCallsWithStreamingChunks() { List 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\":\"\n" + + " \n" + + "

Hi

\n" + + " \n" + + "\"}"; + ToolUseBlock chunk = + ToolUseBlock.builder().id("call_1").name("write_file").content(rawContent).build(); + + accumulator.add(chunk); + + List result = accumulator.buildAllToolCalls(); + assertEquals(1, result.size()); + ToolUseBlock toolCall = result.get(0); + + assertEquals("call_1", toolCall.getId()); + assertEquals("write_file", toolCall.getName()); + Map input = toolCall.getInput(); + assertNotNull(input); + assertEquals("out.html", input.get("file_path")); + assertEquals("\n \n

Hi

\n \n", input.get("content")); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java index a084ba136..78a3138ab 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java @@ -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; @@ -359,6 +360,39 @@ public Mono 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\":\"\n \",\"str2\":\"\n\"}"; + + 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("") && text.contains(""), + "Result should contain concatenated strings: " + text); + } + private String extractFirstText(ToolResultBlock response) { assertTrue( ToolTestUtils.isValidToolResultBlock(response), diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonParseHelperTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonParseHelperTest.java new file mode 100644 index 000000000..2a2596dec --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonParseHelperTest.java @@ -0,0 +1,146 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for JsonParseHelper (tool input JSON with newlines in strings). */ +@DisplayName("JsonParseHelper Tests") +class JsonParseHelperTest { + + @Nested + @DisplayName("escapeNewlinesInJsonStrings") + class EscapeNewlinesTest { + + @Test + @DisplayName("Should leave valid JSON unchanged") + void validJsonUnchanged() { + String json = "{\"a\":1,\"b\":\"hello\"}"; + assertEquals(json, JsonParseHelper.escapeNewlinesInJsonStrings(json)); + } + + @Test + @DisplayName("Should escape newlines inside double-quoted strings") + void escapeNewlinesInStrings() { + String raw = + "{\"file_path\":\"a.html\",\"content\":\"\n \n\n\"}"; + String sanitized = JsonParseHelper.escapeNewlinesInJsonStrings(raw); + assertEquals( + "{\"file_path\":\"a.html\",\"content\":\"\\n" + + " \\n" + + "\\n" + + "\"}", + sanitized); + } + + @Test + @DisplayName("Should escape carriage returns inside strings") + void escapeCarriageReturns() { + String raw = "{\"text\":\"line1\r\nline2\"}"; + String sanitized = JsonParseHelper.escapeNewlinesInJsonStrings(raw); + assertEquals("{\"text\":\"line1\\r\\nline2\"}", sanitized); + } + + @Test + @DisplayName("Should not escape newlines outside strings") + void noEscapeOutsideStrings() { + String raw = "{\n \"key\": \"value\"\n}"; + assertEquals(raw, JsonParseHelper.escapeNewlinesInJsonStrings(raw)); + } + + @Test + @DisplayName("Should handle null and empty") + void nullAndEmpty() { + assertNull(JsonParseHelper.escapeNewlinesInJsonStrings(null)); + assertEquals("", JsonParseHelper.escapeNewlinesInJsonStrings("")); + } + + @Test + @DisplayName("Should handle escaped quotes inside string") + void escapedQuotesInString() { + String raw = "{\"msg\":\"say \\\"hi\\\"\"}"; + assertEquals(raw, JsonParseHelper.escapeNewlinesInJsonStrings(raw)); + } + } + + @Nested + @DisplayName("parseMapWithNewlineFallback") + class ParseMapWithNewlineFallbackTest { + + @Test + @DisplayName("Should parse valid JSON") + void parseValidJson() { + String json = "{\"file_path\":\"out.html\",\"content\":\"

hi

\"}"; + Map map = JsonParseHelper.parseMapWithNewlineFallback(json); + assertNotNull(map); + assertEquals("out.html", map.get("file_path")); + assertEquals("

hi

", map.get("content")); + } + + @Test + @DisplayName("Should parse JSON with literal newlines in string via fallback") + void parseJsonWithNewlinesInString() { + // Invalid JSON: newlines inside string value + String invalidJson = + "{\"file_path\":\"a.html\",\"content\":\"\n" + + " Hello\n" + + "\"}"; + Map map = JsonParseHelper.parseMapWithNewlineFallback(invalidJson); + assertNotNull(map); + assertEquals("a.html", map.get("file_path")); + assertEquals("\n Hello\n", map.get("content")); + } + + @Test + @DisplayName("Should return null for null or empty string") + void nullAndEmptyReturnsNull() { + assertNull(JsonParseHelper.parseMapWithNewlineFallback(null)); + assertNull(JsonParseHelper.parseMapWithNewlineFallback("")); + } + + @Test + @DisplayName("Should return null for invalid JSON that cannot be recovered") + void invalidJsonReturnsNull() { + assertNull(JsonParseHelper.parseMapWithNewlineFallback("not json at all")); + assertNull(JsonParseHelper.parseMapWithNewlineFallback("{")); + } + + @Test + @DisplayName("Should handle HTML-like content with spaces and newlines") + void htmlContentWithSpacesAndNewlines() { + String raw = + "{\"path\":\"report.html\",\"body\":\"\n" + + "\n" + + " \n" + + " \n" + + "\"}"; + Map map = JsonParseHelper.parseMapWithNewlineFallback(raw); + assertNotNull(map); + assertEquals("report.html", map.get("path")); + assertEquals( + "\n\n \n \n", + map.get("body")); + } + } +}