From cdc06cba7ba7eff4e7170bfa27e070ba49204e5b Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 10:41:27 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(parser):=20=E6=B7=BB=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现多阶段JSON清理策略,支持Markdown代码块、注释、单引号等格式修复 - 创建ParseResult记录类和ParseStage枚举,提供解析状态和错误信息 - 集成Hook机制,自动拦截PreActingEvent并修正ToolUseBlock中的参数 - 包含完整的单元测试 - 提供详细的README文档和使用示例 --- .../agentscope-extensions-tool-parser/pom.xml | 93 ++++ .../core/tool/parser/ParseResult.java | 113 +++++ .../core/tool/parser/ParseStage.java | 60 +++ .../core/tool/parser/ToolArgumentParser.java | 419 ++++++++++++++++ .../tool/parser/ToolArgumentParserHook.java | 180 +++++++ .../parser/ToolArgumentParserHookTest.java | 408 ++++++++++++++++ .../tool/parser/ToolArgumentParserTest.java | 450 ++++++++++++++++++ 7 files changed, 1723 insertions(+) create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/pom.xml create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseResult.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseStage.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParser.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParserHook.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/pom.xml b/agentscope-extensions/agentscope-extensions-tool-parser/pom.xml new file mode 100644 index 000000000..47e2aefd8 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/pom.xml @@ -0,0 +1,93 @@ + + + + + 4.0.0 + + + io.agentscope + agentscope-extensions + ${revision} + ../pom.xml + + agentscope-extensions-tool-parser + + AgentScope Java - Extensions - Tool Parser + AgentScope Extensions - Robust Tool Call Argument Parser with Multi-Stage Cleanup + + + UTF-8 + 17 + 17 + + + 2.18.2 + 2.0.16 + 5.11.4 + 1.0.10-SNAPSHOT + 3.4.2 + 3.7.2 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + + io.projectreactor + reactor-core + ${reactor.version} + + + + + io.agentscope + agentscope-core + ${agentscope.version} + + + + + org.mockito + mockito-core + 5.14.2 + test + + + + org.mockito + mockito-junit-jupiter + 5.14.2 + test + + + + diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseResult.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseResult.java new file mode 100644 index 000000000..a36cbc293 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseResult.java @@ -0,0 +1,113 @@ +/* + * 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.tool.parser; + +/** + * Result of parsing tool call arguments. + * + *

This record contains the parsed JSON string and the stage at which parsing succeeded. + * + * @param parsedArguments the parsed JSON string (may be empty if parsing failed) + * @param stage the parsing stage that succeeded + * @param errorMessage error message if parsing failed, null otherwise + * @since 0.7.0 + */ +public record ParseResult(String parsedArguments, ParseStage stage, String errorMessage) { + + /** + * Compact constructor for validation. + * + *

Validates the consistency of the record fields: + *

+ * + * @throws IllegalArgumentException if validation fails + */ + public ParseResult { + if (parsedArguments == null) { + throw new IllegalArgumentException("parsedArguments cannot be null"); + } + if (stage == null) { + throw new IllegalArgumentException("stage cannot be null"); + } + // Success results must have null errorMessage + if (errorMessage == null && stage == ParseStage.ORIGINAL) { + throw new IllegalArgumentException("Success result cannot have ORIGINAL stage"); + } + // Failure results must have non-null errorMessage + if (errorMessage != null && stage != ParseStage.ORIGINAL) { + throw new IllegalArgumentException("Failed result must have ORIGINAL stage"); + } + } + + /** + * Creates a successful parse result. + * + * @param parsedArguments the parsed JSON string + * @param stage the parsing stage that succeeded + * @return a successful ParseResult + * @throws IllegalArgumentException if parsedArguments is null or stage is ORIGINAL + */ + public static ParseResult success(String parsedArguments, ParseStage stage) { + return new ParseResult(parsedArguments, stage, null); + } + + /** + * Creates a failed parse result. + * + * @param original the original input string + * @param errorMessage error message describing the failure + * @return a failed ParseResult + * @throws IllegalArgumentException if original is null or errorMessage is null + */ + public static ParseResult failure(String original, String errorMessage) { + if (errorMessage == null) { + throw new IllegalArgumentException("errorMessage cannot be null for failure result"); + } + return new ParseResult(original, ParseStage.ORIGINAL, errorMessage); + } + + /** + * Checks if parsing was successful. + * + * @return true if parsing succeeded, false otherwise + */ + public boolean isSuccess() { + return errorMessage == null; + } + + /** + * Checks if parsing succeeded at the first stage (DIRECT). + * + * @return true if parsing succeeded without any cleanup + */ + public boolean isDirectSuccess() { + return isSuccess() && stage == ParseStage.DIRECT; + } + + /** + * Checks if parsing required multiple cleanup stages. + * + * @return true if parsing succeeded after one or more cleanup stages + */ + public boolean requiredMultipleStages() { + return isSuccess() && stage != ParseStage.DIRECT; + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseStage.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseStage.java new file mode 100644 index 000000000..73007a5b6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ParseStage.java @@ -0,0 +1,60 @@ +/* + * 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.tool.parser; + +/** + * Parsing stage enumeration for tool argument cleanup. + * + *

Each stage represents a progressively more aggressive cleanup strategy for malformed JSON + * output from LLMs. + * + * @since 0.7.0 + */ +public enum ParseStage { + + /** Direct parsing - JSON is already valid, no cleanup needed. */ + DIRECT("Direct parsing"), + + /** Markdown cleanup - removed ```json code blocks. */ + MARKDOWN_CLEAN("Markdown code block cleanup"), + + /** Comment stripping - removed // and /* *\/ comments. */ + COMMENT_STRIP("JSON comment removal"), + + /** Quote fixing - converted single quotes to double quotes. */ + QUOTE_FIX("Single quote to double quote conversion"), + + /** JSON repair - fixed missing brackets and trailing commas. */ + JSON_REPAIR("JSON structure repair"), + + /** Original - failed to parse, returning original string. */ + ORIGINAL("Parsing failed, using original"); + + private final String description; + + ParseStage(String description) { + this.description = description; + } + + /** + * Gets the description of this parse stage. + * + * @return the description + */ + public String getDescription() { + return description; + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParser.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParser.java new file mode 100644 index 000000000..84b08fa19 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParser.java @@ -0,0 +1,419 @@ +/* + * 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.tool.parser; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Robust parser for tool call arguments from LLM outputs. + * + *

This parser implements a multi-stage cleanup strategy to handle various malformed JSON formats + * that LLMs may produce: + * + *

+ * + *

Usage Example: + * + *

{@code
+ * ParseResult result = ToolArgumentParser.parse(rawJson, "searchTool");
+ * if (result.isSuccess()) {
+ *     Map args = objectMapper.readValue(result.parsedArguments(),
+ *         new TypeReference>() {});
+ * }
+ * }
+ * + */ +public class ToolArgumentParser { + + private static final Logger log = LoggerFactory.getLogger(ToolArgumentParser.class); + + /** + * ObjectMapper configured with lenient parsing settings to handle common LLM output issues. + * This reduces the need for manual text manipulation. + */ + private static final ObjectMapper objectMapper = + new ObjectMapper() + .enable(JsonParser.Feature.ALLOW_COMMENTS) // Allow // and /* */ comments + .enable( + JsonParser.Feature + .ALLOW_SINGLE_QUOTES) // Allow single quotes for strings/keys + .enable( + JsonParser.Feature + .ALLOW_UNQUOTED_FIELD_NAMES) // Allow unquoted field names + .enable( + JsonParser.Feature + .ALLOW_UNQUOTED_CONTROL_CHARS); // Allow control chars in + + // strings + + // Precompiled regex patterns for performance + // Maximum size limits to prevent ReDoS attacks + private static final int MAX_CODE_BLOCK_SIZE = 50_000; + private static final int MAX_COMMENT_BLOCK_SIZE = 10_000; + + private static final Pattern MARKDOWN_CODE_BLOCK_PATTERN = + Pattern.compile("```json\\s*([\\s\\S]{0," + MAX_CODE_BLOCK_SIZE + "})\\s*```"); + private static final Pattern MARKDOWN_CODE_BLOCK_GENERIC_PATTERN = + Pattern.compile("```\\s*([\\s\\S]{0," + MAX_CODE_BLOCK_SIZE + "})\\s*```"); + private static final Pattern SINGLE_LINE_COMMENT_PATTERN = Pattern.compile("//.*?(?:\\n|$)"); + private static final Pattern MULTI_LINE_COMMENT_PATTERN = + Pattern.compile("/\\*[\\s\\S]{0," + MAX_COMMENT_BLOCK_SIZE + "}\\*/"); + + // Maximum argument size to prevent DoS attacks (100KB) + private static final int MAX_ARGUMENT_SIZE = 100_000; + + private ToolArgumentParser() { + // Utility class, prevent instantiation + } + + /** + * Parse tool call arguments with multi-stage cleanup. + * + *

This method tries progressively more aggressive cleanup strategies until parsing succeeds + * or all stages are exhausted. + * + * @param rawArguments the raw JSON string from LLM output + * @param toolName the name of the tool being called (for logging) + * @return ParseResult containing the parsed JSON and the stage that succeeded + */ + public static ParseResult parse(String rawArguments, String toolName) { + // Handle null/empty input - return failure to avoid masking upstream errors + if (rawArguments == null || rawArguments.isBlank()) { + String errorMsg = + String.format( + "Tool argument is null or empty for tool: %s. This indicates a" + + " potential upstream error.", + toolName); + log.error("AGENT-TOOL-ERROR-001 - {}", errorMsg); + return ParseResult.failure(rawArguments != null ? rawArguments : "null", errorMsg); + } + + String trimmed = rawArguments.trim(); + + // Validate size + if (trimmed.length() > MAX_ARGUMENT_SIZE) { + log.warn( + "Tool argument exceeds size limit: {} bytes for tool: {}, max: {}", + trimmed.length(), + toolName, + MAX_ARGUMENT_SIZE); + return ParseResult.failure( + rawArguments, + String.format( + "Argument size %d exceeds limit %d", + trimmed.length(), MAX_ARGUMENT_SIZE)); + } + + // Stage 0: Direct parsing + ParseResult result = tryDirectParse(trimmed); + if (result.isSuccess()) { + return result; + } + + // Stage 1: Markdown code block cleanup + result = tryAfterMarkdownCleanup(trimmed); + if (result.isSuccess()) { + log.debug( + "AGENT-TOOL-001 - Tool argument enhanced after Markdown cleanup: tool={}", + toolName); + return result; + } + + // Stage 2: Comment stripping + result = tryAfterCommentStripping(trimmed); + if (result.isSuccess()) { + log.debug( + "AGENT-TOOL-002 - Tool argument enhanced after comment stripping: tool={}", + toolName); + return result; + } + + // Stage 3: Quote fixing + result = tryAfterQuoteFixing(trimmed); + if (result.isSuccess()) { + log.debug( + "AGENT-TOOL-003 - Tool argument enhanced after quote fixing: tool={}", + toolName); + return result; + } + + // Stage 4: JSON repair + result = tryAfterJsonRepair(trimmed); + if (result.isSuccess()) { + log.debug( + "AGENT-TOOL-004 - Tool argument enhanced after JSON repair: tool={}", toolName); + return result; + } + + // All stages failed + log.debug( + "AGENT-TOOL-005 - Failed to parse tool argument for tool: {}, error: {}", + toolName, + result.errorMessage()); + return result; + } + + /** + * Stage 0: Try direct JSON parsing. + */ + private static ParseResult tryDirectParse(String json) { + return tryParseJson(json, ParseStage.DIRECT); + } + + /** + * Stage 1: Remove Markdown code blocks and try parsing. + */ + private static ParseResult tryAfterMarkdownCleanup(String json) { + String cleaned = json; + + // Try ```json ... ``` + var matcher = MARKDOWN_CODE_BLOCK_PATTERN.matcher(json); + if (matcher.find()) { + cleaned = matcher.group(1).trim(); + } else { + // Try generic ``` ... ``` + matcher = MARKDOWN_CODE_BLOCK_GENERIC_PATTERN.matcher(json); + if (matcher.find()) { + cleaned = matcher.group(1).trim(); + } else { + // No markdown blocks found, use original as fallback + log.debug("AGENT-TOOL-WARN-001 - No markdown code block found for tool input"); + cleaned = json; + } + } + + return tryParseJson(cleaned, ParseStage.MARKDOWN_CLEAN); + } + + /** + * Stage 2: Strip JSON comments and try parsing. + * + *

Note: Jackson is configured to ALLOW_COMMENTS, so this stage primarily handles + * cases where comment stripping is combined with other cleanup (markdown blocks). + */ + private static ParseResult tryAfterCommentStripping(String json) { + // First try markdown cleanup if needed + String cleaned = json; + if (json.startsWith("```")) { + var mdResult = tryAfterMarkdownCleanup(json); + if (mdResult.isSuccess()) return mdResult; + cleaned = mdResult.parsedArguments(); + } + + // Strip comments (redundant with Jackson's ALLOW_COMMENTS, but kept for consistency) + String withoutComments = MULTI_LINE_COMMENT_PATTERN.matcher(cleaned).replaceAll(""); + withoutComments = SINGLE_LINE_COMMENT_PATTERN.matcher(withoutComments).replaceAll(""); + + return tryParseJson(withoutComments.trim(), ParseStage.COMMENT_STRIP); + } + + /** + * Stage 3: Convert single quotes to double quotes and try parsing. + * + *

Note: Jackson is configured with ALLOW_SINGLE_QUOTES, so this stage is kept + * for backward compatibility and complex cases requiring multiple cleanup steps. + */ + private static ParseResult tryAfterQuoteFixing(String json) { + // Apply previous stages if needed + String cleaned = json; + if (json.contains("//") || json.contains("/*")) { + var commentResult = tryAfterCommentStripping(json); + if (commentResult.isSuccess()) return commentResult; + cleaned = commentResult.parsedArguments(); + } else if (json.startsWith("```")) { + var mdResult = tryAfterMarkdownCleanup(json); + if (mdResult.isSuccess()) return mdResult; + cleaned = mdResult.parsedArguments(); + } + + // NOTE: Jackson's ALLOW_SINGLE_QUOTES feature handles most single-quote cases. + // Manual replacement is kept as fallback for edge cases. + // However, we skip manual replacement to avoid breaking strings containing single quotes + // (e.g., {"text": "It's a test"} would break with simple replacement). + + return tryParseJson(cleaned, ParseStage.QUOTE_FIX); + } + + /** + * Stage 4: Attempt JSON repair by fixing common structural issues. + */ + private static ParseResult tryAfterJsonRepair(String json) { + // Apply previous stages if needed + String cleaned = json; + + // Try markdown cleanup first + if (json.startsWith("```")) { + var mdResult = tryAfterMarkdownCleanup(json); + if (mdResult.isSuccess()) return mdResult; + cleaned = mdResult.parsedArguments(); + } + + // Try comment stripping + if (cleaned.contains("//") || cleaned.contains("/*")) { + var commentResult = tryAfterCommentStripping(cleaned); + if (commentResult.isSuccess()) return commentResult; + cleaned = commentResult.parsedArguments(); + } + + // Try quote fixing + if (cleaned.contains("'") && !cleaned.contains("\"")) { + var quoteResult = tryAfterQuoteFixing(cleaned); + if (quoteResult.isSuccess()) return quoteResult; + cleaned = quoteResult.parsedArguments(); + } + + // Fix structural issues + String repaired = cleanJson(cleaned); + + return tryParseJson(repaired, ParseStage.JSON_REPAIR); + } + + /** + * Fix common JSON structural issues. + */ + private static String cleanJson(String json) { + String cleaned = json.trim(); + + // Count brackets (excluding those inside strings) + int openBraces = countCharOutsideStrings(cleaned, '{'); + int closeBraces = countCharOutsideStrings(cleaned, '}'); + int openBrackets = countCharOutsideStrings(cleaned, '['); + int closeBrackets = countCharOutsideStrings(cleaned, ']'); + + // Log if imbalanced brackets detected + if (openBraces != closeBraces || openBrackets != closeBrackets) { + log.debug( + "AGENT-TOOL-WARN-003 - Imbalanced brackets detected in JSON repair. " + + "openBraces={}, closeBraces={}, openBrackets={}, closeBrackets={}", + openBraces, + closeBraces, + openBrackets, + closeBrackets); + } + + // Add missing closing brackets (with safety limit) + StringBuilder sb = new StringBuilder(cleaned); + int maxAdditions = 10; // Safety limit to prevent infinite loops + + int bracesToAdd = Math.min(Math.max(0, (openBraces - closeBraces)), maxAdditions); + int bracketsToAdd = Math.min(Math.max(0, (openBrackets - closeBrackets)), maxAdditions); + + if (bracesToAdd > 0) { + sb.append("}".repeat(bracesToAdd)); + } + if (bracketsToAdd > 0) { + sb.append("]".repeat(bracketsToAdd)); + } + + // Remove trailing commas (may still affect strings, but acceptable trade-off) + String result = sb.toString().replaceAll(",\\s*([}\\]])", "$1"); + + if (!result.equals(cleaned)) { + log.debug( + "JSON repair applied: original length={}, repaired length={}", + cleaned.length(), + result.length()); + } + + return result; + } + + /** + * Count occurrences of a character in a string, excluding those inside string literals. + * + * @param s the string to search + * @param target the character to count + * @return number of occurrences outside strings + */ + private static int countCharOutsideStrings(String s, char target) { + int count = 0; + boolean inString = false; + char quoteChar = '\0'; + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + // Handle string boundaries + if (!inString && (c == '"' || c == '\'')) { + inString = true; + quoteChar = c; + } else if (inString && c == quoteChar) { + // Check for escaped quotes + if (i > 0 && s.charAt(i - 1) != '\\') { + inString = false; + quoteChar = '\0'; + } + } else if (!inString && c == target) { + count++; + } + } + + return count; + } + + /** + * Count occurrences of a character in a string (simple version, deprecated). + * + * @deprecated Use {@link #countCharOutsideStrings(String, char)} instead for accurate bracket counting. + */ + @Deprecated + private static int countChar(String s, char c) { + int count = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == c) { + count++; + } + } + return count; + } + + /** + * Try parsing a JSON string. + * + * @return ParseResult with success/failure status + */ + private static ParseResult tryParseJson(String json, ParseStage stage) { + try { + objectMapper.readTree(json); + return ParseResult.success(json, stage); + } catch (JsonProcessingException e) { + // Record specific JSON parsing error + log.debug("JSON parsing failed at stage {}: {}", stage, e.getMessage()); + return ParseResult.failure(json, e.getMessage()); + } catch (Exception e) { + // Catch unexpected exceptions (e.g., IOException, memory issues) + log.error( + "AGENT-TOOL-ERROR-002 - Unexpected error during JSON parsing at stage {}: {} -" + + " {}", + stage, + e.getClass().getSimpleName(), + e.getMessage()); + return ParseResult.failure( + json, "Unexpected parsing error: " + e.getClass().getSimpleName()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParserHook.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParserHook.java new file mode 100644 index 000000000..4fb78c938 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/main/java/io/agentscope/core/tool/parser/ToolArgumentParserHook.java @@ -0,0 +1,180 @@ +/* + * 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.tool.parser; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.ToolUseBlock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook for automatically correcting tool call arguments before execution. + * + *

This hook intercepts {@link PreActingEvent} (fired before tool execution) and applies + * a multi-stage cleanup strategy to fix common JSON format issues in LLM-generated tool + * arguments: + * + *

    + *
  1. Remove Markdown code blocks (triple-backtick json ... triple-backtick) + *
  2. Strip JavaScript-style comments (double-slash and slash-star ... star-slash) + *
  3. Convert single quotes to double quotes (via Jackson lenient mode) + *
  4. Fix missing brackets and trailing commas + *
  5. Fallback to original input if all stages fail + *
+ * + *

Usage Example: + *

{@code
+ * // Create hook with SMART mode (default)
+ * ToolArgumentParserHook hook = new ToolArgumentParserHook();
+ *
+ * // Or specify mode explicitly
+ * ToolArgumentParserHook strictHook = new ToolArgumentParserHook(HookMode.STRICT);
+ *
+ * // Register with agent
+ * ReActAgent agent = ReActAgent.builder()
+ *     .name("Assistant")
+ *     .model(model)
+ *     .toolkit(toolkit)
+ *     .hooks(List.of(hook))
+ *     .build();
+ * }
+ * + * + *

Priority: This hook has default priority 100 (normal priority). Hooks with lower + * priority values execute first. + * + * @see Hook + * @see ToolArgumentParser + * @since 1.0.10 + */ +public class ToolArgumentParserHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(ToolArgumentParserHook.class); + + /** + * ObjectMapper for converting Map to JSON string. + * Uses standard settings (not lenient) to avoid double-processing. + */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Handles hook events with tool argument correction. + * + *

This method only processes {@link PreActingEvent} (before tool execution). + * All other event types are passed through unchanged. + * + * @param event The hook event + * @param The concrete event type + * @return Mono containing the potentially modified event + */ + @Override + public Mono onEvent(T event) { + + // Only process PreActingEvent + if (!(event instanceof PreActingEvent preActingEvent)) { + return Mono.just(event); + } + + // Process PreActingEvent + return Mono.fromCallable(() -> processPreActingEvent(preActingEvent)) + .onErrorResume( + ex -> { + log.error( + "Hook execution failed for event type: {}, tool: {}," + + "Returning original event. Error: {}", + event.getClass().getSimpleName(), + preActingEvent.getToolUse().getName(), + ex.getMessage(), + ex); + return Mono.just(preActingEvent); // Return original event on error + }) + .map(processedEvent -> (T) processedEvent); + } + + /** + * Processes PreActingEvent to correct tool arguments. + * + * @param event The PreActingEvent + * @return The processed event (potentially modified) + */ + @SuppressWarnings("unchecked") + private PreActingEvent processPreActingEvent(PreActingEvent event) { + ToolUseBlock toolUse = event.getToolUse(); + String toolName = toolUse.getName(); + // the raw content for streaming tool calls + String rawContent = toolUse.getContent(); + + // Validate input before processing + if (rawContent == null) { + return event; + } + + ParseResult result = ToolArgumentParser.parse(rawContent, toolName); + + if (!result.isSuccess()) { + // Parsing failed, log and return original + log.error( + "Tool argument parsing failed for tool '{}': " + + "stage={}, error={}, jsonLength={}, mode=SMART", + toolName, + result.stage(), + result.errorMessage(), + rawContent.length()); + return event; + } + + // Check if correction was applied + if (result.isDirectSuccess()) { + // No correction needed + return event; + } + + // Correction was applied, log it + log.info("Tool argument corrected for tool '{}': stage={}", toolName, result.stage()); + + // Update ToolUseBlock with corrected arguments + String correctedContent = result.parsedArguments(); + + ToolUseBlock correctedToolUse = + ToolUseBlock.builder() + .id(toolUse.getId()) + .name(toolUse.getName()) + .input(toolUse.getInput()) + .content(correctedContent) + .metadata(toolUse.getMetadata()) + .build(); + + // Update event with corrected ToolUseBlock + event.setToolUse(correctedToolUse); + return event; + } + + /** + * Returns the priority of this hook (100 = normal priority). + * + *

Lower values execute first. Hooks with the same priority execute in registration order. + * + * @return The priority value (default: 100) + */ + @Override + public int priority() { + return 100; // Normal priority, executes after high-priority hooks + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java new file mode 100644 index 000000000..d3a9238f0 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java @@ -0,0 +1,408 @@ +/* + * 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.tool.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.Toolkit; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Unit tests for {@link ToolArgumentParserHook}. + * + *

Tests cover automatic tool argument correction via content field processing. + * + * @since 1.0.10 + */ +@DisplayName("Tool Argument Parser Hook Tests") +class ToolArgumentParserHookTest { + + private ToolArgumentParserHook hook; + private PreActingEvent testEvent; + + @BeforeEach + void setUp() { + hook = new ToolArgumentParserHook(); + // Create event with valid JSON content + testEvent = createTestEventWithContent("{\"query\":\"test\",\"limit\":10}"); + } + + /** + * Creates a test PreActingEvent with the given content string. + */ + private PreActingEvent createTestEventWithContent(String content) { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("test-id") + .name("searchTool") + .input( + Map.of( + "query", "test", "limit", + 10)) // Provide input for compatibility + .content(content) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + return new PreActingEvent(mockAgent, mockToolkit, toolUse); + } + + @Nested + @DisplayName("Basic Hook Functionality Tests") + class BasicFunctionalityTests { + + @Test + @DisplayName("Should create hook with default constructor") + void shouldCreateHookWithDefaultConstructor() { + ToolArgumentParserHook defaultHook = new ToolArgumentParserHook(); + + assertNotNull(defaultHook); + assertEquals(100, defaultHook.priority()); + } + + @Test + @DisplayName("Should return priority as 100") + void shouldReturnPriority100() { + assertEquals(100, hook.priority()); + } + } + + @Nested + @DisplayName("PreActingEvent Processing Tests") + class PreActingEventProcessingTests { + + @Test + @DisplayName("Should pass through valid JSON content unchanged") + void shouldPassThroughValidJson() { + PreActingEvent event = createTestEventWithContent("{\"query\":\"test\",\"limit\":10}"); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertEquals("searchTool", processedEvent.getToolUse().getName()); + // Valid JSON should pass through unchanged + assertEquals( + "{\"query\":\"test\",\"limit\":10}", processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should correct markdown-wrapped JSON content") + void shouldCorrectMarkdownWrappedJson() { + String markdownJson = "```json\n{\"query\":\"test\"}\n```"; + PreActingEvent event = createTestEventWithContent(markdownJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + // Markdown wrapper should be removed + String correctedContent = processedEvent.getToolUse().getContent(); + assertNotNull(correctedContent); + // The corrected content should not contain markdown wrapper + assertEquals(false, correctedContent.startsWith("```")); + } + + @Test + @DisplayName("Should handle JSON with comments") + void shouldHandleJsonWithComments() { + String jsonWithComment = "{\"query\":\"test\", // search query\n\"limit\":10}"; + PreActingEvent event = createTestEventWithContent(jsonWithComment); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should handle single-quoted JSON") + void shouldHandleSingleQuotedJson() { + String singleQuotedJson = "{'query':'test','limit':10}"; + PreActingEvent event = createTestEventWithContent(singleQuotedJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should handle JSON with trailing comma") + void shouldHandleTrailingComma() { + String trailingCommaJson = "{\"query\":\"test\",}"; + PreActingEvent event = createTestEventWithContent(trailingCommaJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + } + + @Nested + @DisplayName("Error Handling Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle invalid JSON gracefully") + void shouldHandleInvalidJson() { + PreActingEvent event = createTestEventWithContent("definitely not json"); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + // Hook should not throw exception, return original event + assertNotNull(processedEvent); + assertEquals("searchTool", processedEvent.getToolUse().getName()); + } + + @Test + @DisplayName("Should handle null content gracefully") + void shouldHandleNullContent() { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("test-id") + .name("searchTool") + .input(Map.of()) + .content(null) // Null content + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + // Should return original event without processing + assertNotNull(processedEvent); + assertSame(event, processedEvent); + } + + @Test + @DisplayName("Should handle empty content") + void shouldHandleEmptyContent() { + PreActingEvent event = createTestEventWithContent(""); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + } + } + + @Nested + @DisplayName("Event Type Filtering Tests") + class EventTypeFilteringTests { + + @Test + @DisplayName("Should pass through PostActingEvent unchanged") + void shouldPassThroughPostActingEvent() { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("test-id") + .name("searchTool") + .input(Map.of("query", "test")) + .content("{\"query\":\"test\"}") + .build(); + + ToolResultBlock toolResult = mock(ToolResultBlock.class); + + PostActingEvent postEvent = + new PostActingEvent( + mock(Agent.class), mock(Toolkit.class), toolUse, toolResult); + + // Process event + Mono result = hook.onEvent(postEvent); + PostActingEvent processedEvent = result.block(); + + // Verify the event is returned unchanged (same instance) + assertNotNull(processedEvent); + assertSame(postEvent, processedEvent, "PostActingEvent should pass through unchanged"); + } + + @Test + @DisplayName("Should process PreActingEvent") + void shouldProcessPreActingEvent() { + Mono result = hook.onEvent(testEvent); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertEquals("searchTool", processedEvent.getToolUse().getName()); + } + } + + @Nested + @DisplayName("Special Content Tests") + class SpecialContentTests { + + @Test + @DisplayName("Should handle unicode and special characters") + void shouldHandleUnicodeAndSpecialCharacters() { + String specialContent = + "{\"text\":\"测试\\n\\t\\\"quoted\\\" 'single' \\\\escaped /slash\"}"; + PreActingEvent event = createTestEventWithContent(specialContent); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should handle nested JSON structures") + void shouldHandleNestedJsonStructures() { + String nestedJson = "{\"data\":{\"items\":[1,2,3],\"meta\":{\"count\":3}}}"; + PreActingEvent event = createTestEventWithContent(nestedJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should handle array content") + void shouldHandleArrayContent() { + String arrayJson = "{\"items\":[1,2,3,4,5],\"tags\":[\"java\",\"test\",\"parser\"]}"; + PreActingEvent event = createTestEventWithContent(arrayJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + + @Test + @DisplayName("Should handle different data types") + void shouldHandleDifferentDataTypes() { + String mixedTypesJson = + "{\"enabled\":true,\"count\":42,\"price\":19.99,\"ratio\":0.75,\"name\":\"test\"}"; + PreActingEvent event = createTestEventWithContent(mixedTypesJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getContent()); + } + } + + @Nested + @DisplayName("Reactive Programming Tests") + class ReactiveProgrammingTests { + + @Test + @DisplayName("Should return Mono that completes successfully") + void shouldReturnCompletingMono() { + Mono result = hook.onEvent(testEvent); + + assertNotNull(result); + assertNotNull(result.block()); + } + + @Test + @DisplayName("Should handle multiple sequential events") + void shouldHandleSequentialEvents() { + PreActingEvent event1 = createTestEventWithContent("{\"query\":\"test1\"}"); + PreActingEvent event2 = createTestEventWithContent("{\"query\":\"test2\"}"); + + Mono result1 = hook.onEvent(event1); + Mono result2 = hook.onEvent(event2); + + assertNotNull(result1.block()); + assertNotNull(result2.block()); + } + } + + @Nested + @DisplayName("ToolUseBlock Update Tests") + class ToolUseBlockUpdateTests { + + @Test + @DisplayName("Should preserve ToolUseBlock metadata after correction") + void shouldPreserveMetadataAfterCorrection() { + ToolUseBlock originalToolUse = + ToolUseBlock.builder() + .id("test-id") + .name("searchTool") + .input(Map.of()) + .content("```json\n{\"query\":\"test\"}\n```") + .metadata(Map.of("key1", "value1", "key2", "value2")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, originalToolUse); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + ToolUseBlock correctedToolUse = processedEvent.getToolUse(); + + // Verify metadata is preserved + assertNotNull(correctedToolUse.getMetadata()); + assertEquals("value1", correctedToolUse.getMetadata().get("key1")); + assertEquals("value2", correctedToolUse.getMetadata().get("key2")); + + // Verify ID and name are preserved + assertEquals("test-id", correctedToolUse.getId()); + assertEquals("searchTool", correctedToolUse.getName()); + } + + @Test + @DisplayName("Should update only content field when correction applied") + void shouldUpdateOnlyContentField() { + String originalContent = "```json\n{\"query\":\"test\"}\n```"; + PreActingEvent event = createTestEventWithContent(originalContent); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + ToolUseBlock correctedToolUse = processedEvent.getToolUse(); + + // Verify fields are preserved or updated correctly + assertEquals("test-id", correctedToolUse.getId()); + assertEquals("searchTool", correctedToolUse.getName()); + assertNotNull(correctedToolUse.getContent()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java new file mode 100644 index 000000000..ac3f555b5 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java @@ -0,0 +1,450 @@ +/* + * 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.tool.parser; + +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 static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ToolArgumentParser}. + * + *

Tests cover all parsing stages and edge cases. + * + * @since 0.7.0 + */ +@DisplayName("Tool Argument Parser Tests") +class ToolArgumentParserTest { + + @Nested + @DisplayName("Stage 0: Direct Parsing (Standard JSON)") + class DirectParsingTests { + + @Test + @DisplayName("Should parse valid JSON directly") + void shouldParseValidJsonDirectly() { + String json = "{\"query\":\"test\",\"limit\":10}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + assertEquals(json, result.parsedArguments()); + } + + @Test + @DisplayName("Should parse nested objects") + void shouldParseNestedObjects() { + String json = "{\"user\":{\"name\":\"test\",\"age\":30}}"; + + ParseResult result = ToolArgumentParser.parse(json, "userTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should parse arrays") + void shouldParseArrays() { + String json = "{\"items\":[\"a\",\"b\",\"c\"]}"; + + ParseResult result = ToolArgumentParser.parse(json, "arrayTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + } + + @Nested + @DisplayName("Stage 1: Markdown Code Block Cleanup") + class MarkdownCleanupTests { + + @Test + @DisplayName("Should strip ```json code blocks") + void shouldStripJsonCodeBlocks() { + String json = "```json\n{\"query\":\"test\"}\n```"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.MARKDOWN_CLEAN, result.stage()); + assertEquals("{\"query\":\"test\"}", result.parsedArguments()); + } + + @Test + @DisplayName("Should strip generic ``` code blocks") + void shouldStripGenericCodeBlocks() { + String json = "```\n{\"query\":\"test\"}\n```"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.MARKDOWN_CLEAN, result.stage()); + } + + @Test + @DisplayName("Should handle Markdown with extra whitespace") + void shouldHandleMarkdownWithExtraWhitespace() { + String json = "```json\n\n {\"query\":\"test\"}\n\n```"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.MARKDOWN_CLEAN, result.stage()); + } + } + + @Nested + @DisplayName("Stage 2: Comment Stripping") + class CommentStrippingTests { + + @Test + @DisplayName("Should strip single-line comments") + void shouldStripSingleLineComments() { + String json = "{\"query\":\"test\", // search keyword\n\"limit\":10}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // Jackson's ALLOW_COMMENTS handles this at DIRECT stage + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should strip multi-line comments") + void shouldStripMultiLineComments() { + String json = "{\"data\":[1,2,3], /* data */\n\"count\":3}"; + + ParseResult result = ToolArgumentParser.parse(json, "dataTool"); + + assertTrue(result.isSuccess()); + // Jackson's ALLOW_COMMENTS handles this at DIRECT stage + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should strip multiple comments") + void shouldStripMultipleComments() { + String json = "{\"query\":\"test\", // keyword\n\"limit\":10 /* max items */}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // Jackson's ALLOW_COMMENTS handles this at DIRECT stage + assertEquals(ParseStage.DIRECT, result.stage()); + } + } + + @Nested + @DisplayName("Stage 3: Quote Fixing") + class QuoteFixingTests { + + @Test + @DisplayName("Should convert single quotes to double quotes") + void shouldConvertSingleQuotes() { + String json = "{'query':'test','limit':10}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // Jackson's ALLOW_SINGLE_QUOTES handles this at DIRECT stage + assertEquals(ParseStage.DIRECT, result.stage()); + // The parsed JSON is still valid, Jackson accepts single quotes + } + } + + @Nested + @DisplayName("Stage 4: JSON Repair") + class JsonRepairTests { + + @Test + @DisplayName("Should fix missing closing brace") + void shouldFixMissingClosingBrace() { + String json = "{\"query\":\"test\",\"limit\":10"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.JSON_REPAIR, result.stage()); + assertEquals("{\"query\":\"test\",\"limit\":10}", result.parsedArguments()); + } + + @Test + @DisplayName("Should remove trailing comma") + void shouldRemoveTrailingComma() { + String json = "{\"query\":\"test\",}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.JSON_REPAIR, result.stage()); + assertEquals("{\"query\":\"test\"}", result.parsedArguments()); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCaseTests { + + @Test + @DisplayName("Should return failure for null input") + void shouldHandleNullInput() { + ParseResult result = ToolArgumentParser.parse(null, "testTool"); + + assertTrue(!result.isSuccess()); // Changed: null input should fail + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertNotNull(result.errorMessage()); + assertTrue(result.errorMessage().contains("null or empty")); + } + + @Test + @DisplayName("Should return failure for empty string") + void shouldHandleEmptyString() { + ParseResult result = ToolArgumentParser.parse("", "testTool"); + + assertTrue(!result.isSuccess()); // Changed: empty input should fail + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertNotNull(result.errorMessage()); + } + + @Test + @DisplayName("Should return failure for whitespace-only string") + void shouldHandleWhitespaceOnlyString() { + ParseResult result = ToolArgumentParser.parse(" \n\t\r ", "testTool"); + + assertTrue(!result.isSuccess()); // Changed: whitespace-only should fail + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertNotNull(result.errorMessage()); + } + + @Test + @DisplayName("Should fail for completely invalid JSON") + void shouldFailForInvalidJson() { + String json = "this is definitely not json"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(!result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertNotNull(result.errorMessage()); + } + } + + @Nested + @DisplayName("ParseResult Methods") + class ParseResultMethodTests { + + @Test + @DisplayName("isSuccess should return true for successful parsing") + void isSuccessShouldReturnTrueForSuccess() { + ParseResult result = ToolArgumentParser.parse("{\"valid\":true}", "testTool"); + + assertTrue(result.isSuccess()); + assertNull(result.errorMessage()); + } + + @Test + @DisplayName("isSuccess should return false for failed parsing") + void isSuccessShouldReturnFalseForFailure() { + ParseResult result = ToolArgumentParser.parse("invalid json", "testTool"); + + assertTrue(!result.isSuccess()); + assertNotNull(result.errorMessage()); + } + + @Test + @DisplayName("isDirectSuccess should return true for direct parsing") + void isDirectSuccessShouldReturnTrue() { + ParseResult result = ToolArgumentParser.parse("{\"valid\":true}", "testTool"); + + assertTrue(result.isDirectSuccess()); + assertTrue(result.requiredMultipleStages() == false); + } + + @Test + @DisplayName("isDirectSuccess should return false for multi-stage parsing") + void isDirectSuccessShouldReturnFalseForMultiStage() { + ParseResult result = + ToolArgumentParser.parse("```json\n{\"valid\":true}\n```", "testTool"); + + assertTrue(result.isSuccess()); + assertTrue(result.isDirectSuccess() == false); + assertTrue(result.requiredMultipleStages()); + } + } + + @Nested + @DisplayName("Security Tests") + class SecurityTests { + + @Test + @DisplayName("Should reject input exceeding MAX_ARGUMENT_SIZE") + void shouldRejectOversizedInput() { + // Create input exceeding 100KB limit + String largeJson = "{\"data\":\"" + "x".repeat(101_000) + "\"}"; + + ParseResult result = ToolArgumentParser.parse(largeJson, "testTool"); + + assertTrue(!result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertTrue(result.errorMessage().contains("exceeds limit")); + } + + @Test + @DisplayName("Should accept input exactly at size limit") + void shouldAcceptInputAtSizeLimit() { + // Create input within 100KB limit + String json = "{\"data\":\"" + "x".repeat(99_980) + "\"}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + } + + @Test + @DisplayName("Should handle large but valid input") + void shouldHandleLargeValidInput() { + // Create a large but valid JSON object + StringBuilder sb = new StringBuilder(); + sb.append("{\"items\":["); + for (int i = 0; i < 1000; i++) { + if (i > 0) sb.append(","); + sb.append("\"item").append(i).append("\""); + } + sb.append("],\"count\":1000}"); + + String largeJson = sb.toString(); + + ParseResult result = ToolArgumentParser.parse(largeJson, "testTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + } + + @Nested + @DisplayName("Combined Cleanup Scenarios") + class CombinedCleanupTests { + + @Test + @DisplayName("Should handle Markdown + Comments") + void shouldHandleMarkdownAndComments() { + String json = "```json\n{\"query\":\"test\", // comment\n\"limit\":10}\n```"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // Should succeed at COMMENT_STRIP or MARKDOWN_CLEAN stage + assertTrue( + result.stage() == ParseStage.COMMENT_STRIP + || result.stage() == ParseStage.MARKDOWN_CLEAN); + } + + @Test + @DisplayName("Should handle Comments + Single Quotes") + void shouldHandleCommentsAndSingleQuotes() { + String json = "{'query':'test', // comment\n'limit':10}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // Jackson handles both at DIRECT stage with ALLOW_COMMENTS and ALLOW_SINGLE_QUOTES + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should handle full cleanup pipeline") + void shouldHandleFullPipeline() { + String json = "```json\n{'items':[1,2,], /* comment */'count':3,}\n```"; + + ParseResult result = ToolArgumentParser.parse(json, "complexTool"); + + assertTrue(result.isSuccess()); + // Should succeed after multiple stages + assertTrue(result.requiredMultipleStages()); + } + + @Test + @DisplayName("Should handle nested brackets with strings") + void shouldHandleNestedBracketsWithStrings() { + // Test that brackets inside strings are not counted + String json = "{\"text\":\"This has } and ] inside\", \"value\":123}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + } + + @Nested + @DisplayName("Advanced Edge Cases") + class AdvancedEdgeCaseTests { + + @Test + @DisplayName("Should handle escaped characters") + void shouldHandleEscapedCharacters() { + String json = + "{\"text\":\"Line 1\\nLine 2\\tTabbed\",\"path\":\"C:\\\\Users\\\\test\"}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should handle Unicode characters") + void shouldHandleUnicodeCharacters() { + String json = "{\"text\":\"Hello 世界 🌍\",\"emoji\":\"😀\"}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + } + + @Test + @DisplayName("Should handle single quotes with Jackson ALLOW_SINGLE_QUOTES") + void shouldHandleSingleQuotesWithJackson() { + // Jackson's ALLOW_SINGLE_QUOTES feature should handle this + String json = "{'query':'test','limit':10}"; + + ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + + assertTrue(result.isSuccess()); + // May succeed at DIRECT if Jackson handles it, or QUOTE_FIX stage + } + + @Test + @DisplayName("Should not break single quotes inside strings") + void shouldNotBreakSingleQuotesInsideStrings() { + // Test that single quotes inside double-quoted strings are preserved + String json = "{\"text\":\"It's a beautiful day\"}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + assertEquals(ParseStage.DIRECT, result.stage()); + assertEquals(json, result.parsedArguments()); + } + } +} From 11b28e2449b0f218ad23b2882456f2062607d5b4 Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 10:42:08 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(pom):=20=E6=B7=BB=E5=8A=A0=20tool-pars?= =?UTF-8?q?er=20=E6=A8=A1=E5=9D=97=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 pom.xml 中新增 agentscope-extensions-tool-parser 模块配置 --- agentscope-extensions/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/agentscope-extensions/pom.xml b/agentscope-extensions/pom.xml index ddc7f33be..71d43fd6e 100644 --- a/agentscope-extensions/pom.xml +++ b/agentscope-extensions/pom.xml @@ -52,6 +52,7 @@ agentscope-extensions-agui agentscope-extensions-chat-completions-web agentscope-extensions-higress + agentscope-extensions-tool-parser agentscope-extensions-kotlin agentscope-extensions-nacos From 8f0322e8683916db1d350a905371ef4121b85deb Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 11:14:41 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs(tool-parser):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../README.md | 330 ++++++++++++++++++ .../parser/ToolArgumentParserHookTest.java | 2 +- 2 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/README.md diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/README.md b/agentscope-extensions/agentscope-extensions-tool-parser/README.md new file mode 100644 index 000000000..0ff81e337 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/README.md @@ -0,0 +1,330 @@ +# AgentScope Tool Argument Parser +**健壮的工具调用参数解析器**,用于修复LLM输出的各种JSON格式问题。 + +## 🎯 核心特性 + +### 工具参数解析器 (ToolArgumentParser) +- ✅ **5阶段渐进式清理策略**:从标准JSON到完整修复 +- ✅ **Jackson Lenient Mode**:支持注释、单引号、非引号字段名 +- ✅ **ReDoS防护**:所有正则表达式都有大小限制 +- ✅ **智能括号计数**:跳过字符串内容,避免误判 +- ✅ **安全限制**:最大参数大小100KB,防止DoS攻击 + +### Hook 集成 (ToolArgumentParserHook) +- ✅ **自动参数矫正**:通过 Hook 机制自动拦截和修正工具调用参数 +- ✅ **一行代码集成**:手动注册即可使用 +- ✅ **Content 字段处理**:直接处理 ToolUseBlock.content 字符串 + +## 📦 快速开始 + +### Maven依赖 + +```xml + + io.agentscope + agentscope-extensions-tool-parser + +``` + +### 基本使用 + +```java +import io.agentscope.core.tool.parser.ToolArgumentParser; +import io.agentscope.core.tool.parser.ParseResult; + +// 解析工具参数 +String rawJson = "{\"query\":\"test\", \"limit\":10}"; +ParseResult result = ToolArgumentParser.parse(rawJson, "searchTool"); + +if (result.isSuccess()) { + // 使用解析后的JSON + Map args = objectMapper.readValue( + result.parsedArguments(), + new TypeReference>() {} + ); +} else { + // 处理解析失败 + System.err.println("解析失败: " + result.errorMessage()); +} +``` + +## 🔧 Hook 集成(推荐) + +### 手动注册(推荐) + +```java +import io.agentscope.core.tool.parser.ToolArgumentParserHook; + +ReActAgent agent = ReActAgent.builder() + .name("Assistant") + .model(model) + .toolkit(toolkit) + .hooks(List.of( + new ToolArgumentParserHook() // 自动修正工具参数 + )) + .build(); +``` + +### Hook 工作原理 + +1. **事件拦截**:Hook 拦截 `PreActingEvent`(工具执行前的事件) +2. **内容提取**:从 `ToolUseBlock.content` 提取原始 JSON 字符串 +3. **自动修正**:应用 5 阶段清理策略修复格式问题 +4. **内容更新**:将修正后的 JSON 更新回 `ToolUseBlock.content` +5. **元数据保留**:保留 `ToolUseBlock` 的 id、name、input、metadata 等字段 + +## 🔧 解析阶段 + +工具参数解析器采用**多阶段渐进式清理策略**: + +| 阶段 | 说明 | 示例 | +|------|------|------| +| **Stage 0: DIRECT** | 标准JSON,直接解析 | `{"key":"value"}` | +| **Stage 1: MARKDOWN_CLEAN** | 移除```json代码块 | ```json
{"key":"value"}
``` | +| **Stage 2: COMMENT_STRIP** | 移除//和/* */注释 | `{"key":"value" // comment}` | +| **Stage 3: QUOTE_FIX** | 单引号转双引号(Jackson自动处理) | `{'key':'value'}` | +| **Stage 4: JSON_REPAIR** | 修复缺失括号和尾随逗号 | `{"key":"value",` | +| **ORIGINAL** | 所有阶段失败,返回原始输入 | - | + +## 📝 支持的格式 + +解析器可以处理LLM输出的各种格式问题: + +### 1. 标准JSON ✅ +```json +{"query":"test","limit":10} +``` + +### 2. Markdown代码块 ✅ +``` +```json +{"query":"test"} +``` +``` + +### 3. 带注释的JSON ✅ +```json +{"query":"test", // 搜索关键词 + "limit":10 /* 最大结果数 */} +``` + +### 4. 单引号JSON ✅ +```json +{'query':'test','limit':10} +``` + +### 5. 非引号字段名 ✅ +```json +{query:"test",limit:10} +``` + +### 6. 尾随逗号 ✅ +```json +{"query":"test",} +``` + +### 7. 缺失括号 ✅ +```json +{"data":{"items":[1,2 // 缺失的括号会被自动修复 +``` + +## 🛡️ 安全特性 + +### ReDoS防护 +- **代码块大小限制**:50KB +- **注释块大小限制**:10KB +- **总参数大小限制**:100KB + +### 输入验证 +- null和空输入会返回ParseResult.failure() +- 超过大小限制的输入会被拒绝 +- 清晰的错误消息帮助排查问题 + +## 📊 性能特性 + +### Jackson优化 +使用Jackson的lenient模式特性: +- `ALLOW_COMMENTS`:支持//和/* */注释 +- `ALLOW_SINGLE_QUOTES`:支持单引号字符串 +- `ALLOW_UNQUOTED_FIELD_NAMES`:支持非引号字段名 +- `ALLOW_UNQUOTED_CONTROL_CHARS`:支持非转义控制字符 + +## 🔍 API参考 + +### ToolArgumentParser + +主要解析入口类。 + +#### 方法 +```java +public static ParseResult parse(String rawArguments, String toolName) +``` + +**参数**: +- `rawArguments` - 原始JSON字符串 +- `toolName` - 工具名称(用于错误消息) + +**返回**: +- `ParseResult` - 解析结果对象 + +### ParseResult + +解析结果记录类。 + +#### 字段 +```java +public record ParseResult( + String parsedArguments, // 解析后的JSON字符串 + ParseStage stage, // 达到的解析阶段 + String errorMessage // 错误消息(失败时) +) +``` + +#### 方法 +```java +boolean isSuccess() // 是否解析成功 +boolean isDirectSuccess() // 是否直接解析成功(无清理) +boolean requiredMultipleStages() // 是否需要多阶段清理 +``` + +## 🧪 测试 + +运行测试: + +```bash +mvn clean test +``` + +### 测试分类 + +| 测试类别 | 测试数量 | 说明 | +|---------|---------|------| +| **基础功能测试** | 2 | Hook 创建、优先级验证 | +| **PreActingEvent 处理测试** | 5 | 标准JSON、Markdown、注释、单引号、尾随逗号 | +| **错误处理测试** | 3 | 无效JSON、null content、空content | +| **事件类型过滤测试** | 2 | PostActingEvent 过滤、PreActingEvent 处理 | +| **特殊内容测试** | 4 | Unicode、嵌套结构、数组、混合类型 | +| **Reactive 编程测试** | 2 | Mono 响应式流、顺序处理 | +| **ToolUseBlock 更新测试** | 2 | 元数据保留、字段更新 | + +## 📚 使用示例 + +### 示例1:基本解析 + +```java +String json = "{\"query\":\"test\",\"limit\":10}"; +ParseResult result = ToolArgumentParser.parse(json, "searchTool"); + +if (result.isSuccess()) { + System.out.println("解析成功: " + result.parsedArguments()); + System.out.println("阶段: " + result.stage()); +} +``` + +### 示例2:处理Markdown代码块 + +```java +String markdown = """ +```json +{"query":"test","limit":10} +``` +"""; + +ParseResult result = ToolArgumentParser.parse(markdown, "searchTool"); +// 自动移除```json```,解析成功 +``` + +### 示例3:错误处理 + +```java +String invalid = "definitely not json"; +ParseResult result = ToolArgumentParser.parse(invalid, "testTool"); + +if (!result.isSuccess()) { + System.err.println("解析失败: " + result.errorMessage()); + // stage == ParseStage.ORIGINAL +} +``` + +### 示例4:Hook集成 + +```java +// 创建Hook并注册到Agent +ToolArgumentParserHook hook = new ToolArgumentParserHook(); + +ReActAgent agent = ReActAgent.builder() + .name("Assistant") + .model(model) + .toolkit(toolkit) + .hooks(List.of(hook)) + .build(); + +// Agent执行时,Hook会自动修正工具参数的content字段 +AgentResponse response = agent.run("帮我搜索最新AI新闻"); +``` + +## 🚧 高级配置 + +### 自定义ObjectMapper + +如果需要自定义Jackson配置: + +```java +public class CustomToolArgumentParser { + private static final ObjectMapper customMapper = new ObjectMapper() + .enable(JsonParser.Feature.ALLOW_COMMENTS) + .enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // 使用customMapper而不是默认的ObjectMapper +} +``` + +### 大小限制调整 + +如需修改大小限制,可以修改常量: + +```java +// 在ToolArgumentParser中 +private static final int MAX_ARGUMENT_SIZE = 200_000; // 增加到200KB +private static final int MAX_CODE_BLOCK_SIZE = 100_000; // 增加到100KB +``` + +## 📖 最佳实践 + +### 1. 推荐使用方式 + +```java +// 推荐:直接创建 Hook,自动修正参数 +ToolArgumentParserHook hook = new ToolArgumentParserHook(); + +ReActAgent agent = ReActAgent.builder() + .name("Assistant") + .model(model) + .toolkit(toolkit) + .hooks(List.of(hook)) + .build(); +``` + +**优势**: +- ✅ 自动修正 LLM 输出的各种 JSON 格式问题 +- ✅ 保留原始 ToolUseBlock 的元数据 +- ✅ 不修改 input Map,只更新 content 字段 +- ✅ 错误时返回原始事件,不影响正常流程 + +### 2. Hook 优先级配置 + +```java +// 如果有多个 Hook,可以通过 priority() 控制执行顺序 +ToolArgumentParserHook parserHook = new ToolArgumentParserHook(); + +// 自定义 Hook 优先级 +public class CustomHook implements Hook { + @Override + public int priority() { + return 90; // 小于 100 会在 parserHook 之前执行 + } + + // ... 其他方法 +} +``` diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java index d3a9238f0..8c6e0a98a 100644 --- a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java @@ -285,7 +285,7 @@ void shouldHandleUnicodeAndSpecialCharacters() { @Test @DisplayName("Should handle nested JSON structures") void shouldHandleNestedJsonStructures() { - String nestedJson = "{\"data\":{\"items\":[1,2,3],\"meta\":{\"count\":3}}}"; + String nestedJson = "{\"data\":{\"items\":[1,2,3],\"meta\":{\"count\":3}}"; PreActingEvent event = createTestEventWithContent(nestedJson); Mono result = hook.onEvent(event); From 88b56be14d55eb517d98b6bda7353b37e3090189 Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 11:55:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(bom):=20=E6=B7=BB=E5=8A=A0=20AgentScop?= =?UTF-8?q?e=20=E5=B7=A5=E5=85=B7=E8=A7=A3=E6=9E=90=E5=99=A8=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 BOM 中添加 agentscope-extensions-tool-parser 依赖 --- agentscope-distribution/agentscope-bom/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 32913d73e..831f7d608 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -276,6 +276,13 @@ ${project.version} + + + io.agentscope + agentscope-extensions-tool-parser + ${project.version} + + io.agentscope agentscope-extensions-nacos-prompt From 4ac957184d5202f08f2cbf16b952bd2701b7b03a Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 14:15:26 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(tool-parser):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E5=85=A8=E9=9D=A2=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/tool/parser/ParseResultTest.java | 253 ++++++++++++++++++ .../core/tool/parser/ParseStageTest.java | 98 +++++++ .../parser/ToolArgumentParserHookTest.java | 112 ++++++++ .../tool/parser/ToolArgumentParserTest.java | 162 +++++++++++ 4 files changed, 625 insertions(+) create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseResultTest.java create mode 100644 agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseStageTest.java diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseResultTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseResultTest.java new file mode 100644 index 000000000..e02f09867 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseResultTest.java @@ -0,0 +1,253 @@ +/* + * 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.tool.parser; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ParseResult}. + * + *

Tests cover factory methods, validation logic, and utility methods. + * + * @since 1.0.10 + */ +@DisplayName("ParseResult Tests") +class ParseResultTest { + + @Nested + @DisplayName("Factory Method Tests") + class FactoryMethodTests { + + @Test + @DisplayName("success() should create successful ParseResult") + void successShouldCreateSuccessfulResult() { + ParseResult result = ParseResult.success("{\"key\":\"value\"}", ParseStage.DIRECT); + + assertTrue(result.isSuccess()); + assertEquals("{\"key\":\"value\"}", result.parsedArguments()); + assertEquals(ParseStage.DIRECT, result.stage()); + assertEquals(null, result.errorMessage()); + } + + @Test + @DisplayName("success() should throw on null parsedArguments") + void successShouldThrowOnNullParsedArguments() { + assertThrows( + IllegalArgumentException.class, + () -> ParseResult.success(null, ParseStage.DIRECT)); + } + + @Test + @DisplayName("success() should throw on ORIGINAL stage") + void successShouldThrowOnOriginalStage() { + assertThrows( + IllegalArgumentException.class, + () -> ParseResult.success("{\"key\":\"value\"}", ParseStage.ORIGINAL)); + } + + @Test + @DisplayName("failure() should create failed ParseResult") + void failureShouldCreateFailedResult() { + ParseResult result = + ParseResult.failure("invalid json", "Parse error: unexpected token"); + + assertFalse(result.isSuccess()); + assertEquals("invalid json", result.parsedArguments()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertEquals("Parse error: unexpected token", result.errorMessage()); + } + + @Test + @DisplayName("failure() should throw on null errorMessage") + void failureShouldThrowOnNullErrorMessage() { + assertThrows( + IllegalArgumentException.class, () -> ParseResult.failure("invalid", null)); + } + + @Test + @DisplayName("failure() should handle empty original input") + void failureShouldHandleEmptyOriginal() { + ParseResult result = ParseResult.failure("", "Empty input"); + + assertFalse(result.isSuccess()); + assertEquals("", result.parsedArguments()); + assertEquals("Empty input", result.errorMessage()); + } + } + + @Nested + @DisplayName("Compact Constructor Validation Tests") + class ConstructorValidationTests { + + @Test + @DisplayName("Should throw on null parsedArguments") + void shouldThrowOnNullParsedArguments() { + assertThrows( + IllegalArgumentException.class, + () -> new ParseResult(null, ParseStage.DIRECT, null)); + } + + @Test + @DisplayName("Should throw on null stage") + void shouldThrowOnNullStage() { + assertThrows( + IllegalArgumentException.class, + () -> new ParseResult("{\"key\":\"value\"}", null, null)); + } + + @Test + @DisplayName("Should throw on null errorMessage with ORIGINAL stage") + void shouldThrowOnNullErrorMessageWithOriginalStage() { + assertThrows( + IllegalArgumentException.class, + () -> new ParseResult("{\"key\":\"value\"}", ParseStage.ORIGINAL, null)); + } + + @Test + @DisplayName("Should throw on non-null errorMessage with non-ORIGINAL stage") + void shouldThrowOnErrorWithNonOriginalStage() { + assertThrows( + IllegalArgumentException.class, + () -> new ParseResult("{\"key\":\"value\"}", ParseStage.DIRECT, "Some error")); + } + + @Test + @DisplayName("Should accept valid successful result") + void shouldAcceptValidSuccessfulResult() { + ParseResult result = new ParseResult("{\"key\":\"value\"}", ParseStage.DIRECT, null); + + assertNotNull(result); + assertEquals("{\"key\":\"value\"}", result.parsedArguments()); + assertEquals(ParseStage.DIRECT, result.stage()); + assertEquals(null, result.errorMessage()); + } + + @Test + @DisplayName("Should accept valid failed result") + void shouldAcceptValidFailedResult() { + ParseResult result = new ParseResult("invalid", ParseStage.ORIGINAL, "Parse error"); + + assertNotNull(result); + assertEquals("invalid", result.parsedArguments()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertEquals("Parse error", result.errorMessage()); + } + } + + @Nested + @DisplayName("Utility Method Tests") + class UtilityMethodTests { + + @Test + @DisplayName("isSuccess() should return true for successful results") + void isSuccessShouldReturnTrueForSuccessfulResults() { + ParseResult result = ParseResult.success("{\"key\":\"value\"}", ParseStage.DIRECT); + + assertTrue(result.isSuccess()); + } + + @Test + @DisplayName("isSuccess() should return false for failed results") + void isSuccessShouldReturnFalseForFailedResults() { + ParseResult result = ParseResult.failure("invalid", "Error message"); + + assertFalse(result.isSuccess()); + } + + @Test + @DisplayName("isDirectSuccess() should return true for DIRECT stage success") + void isDirectSuccessShouldReturnTrueForDirectStage() { + ParseResult result = ParseResult.success("{\"key\":\"value\"}", ParseStage.DIRECT); + + assertTrue(result.isDirectSuccess()); + } + + @Test + @DisplayName("isDirectSuccess() should return false for non-DIRECT stage") + void isDirectSuccessShouldReturnFalseForNonDirectStage() { + ParseResult result = + ParseResult.success("{\"key\":\"value\"}", ParseStage.MARKDOWN_CLEAN); + + assertFalse(result.isDirectSuccess()); + } + + @Test + @DisplayName("isDirectSuccess() should return false for failed results") + void isDirectSuccessShouldReturnFalseForFailedResults() { + ParseResult result = ParseResult.failure("invalid", "Error"); + + assertFalse(result.isDirectSuccess()); + } + + @Test + @DisplayName("requiredMultipleStages() should return true for non-DIRECT success") + void requiredMultipleStagesShouldReturnTrueForNonDirectSuccess() { + ParseResult result = + ParseResult.success("{\"key\":\"value\"}", ParseStage.MARKDOWN_CLEAN); + + assertTrue(result.requiredMultipleStages()); + } + + @Test + @DisplayName("requiredMultipleStages() should return false for DIRECT success") + void requiredMultipleStagesShouldReturnFalseForDirectSuccess() { + ParseResult result = ParseResult.success("{\"key\":\"value\"}", ParseStage.DIRECT); + + assertFalse(result.requiredMultipleStages()); + } + + @Test + @DisplayName("requiredMultipleStages() should return false for failed results") + void requiredMultipleStagesShouldReturnFalseForFailedResults() { + ParseResult result = ParseResult.failure("invalid", "Error"); + + assertFalse(result.requiredMultipleStages()); + } + } + + @Nested + @DisplayName("Record Component Access Tests") + class RecordComponentAccessTests { + + @Test + @DisplayName("Should access all components correctly") + void shouldAccessAllComponents() { + ParseResult result = ParseResult.success("{\"key\":\"value\"}", ParseStage.DIRECT); + + assertEquals("{\"key\":\"value\"}", result.parsedArguments()); + assertEquals(ParseStage.DIRECT, result.stage()); + assertEquals(null, result.errorMessage()); + } + + @Test + @DisplayName("Should access components from failed result") + void shouldAccessComponentsFromFailedResult() { + ParseResult result = ParseResult.failure("invalid", "Error message"); + + assertEquals("invalid", result.parsedArguments()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertEquals("Error message", result.errorMessage()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseStageTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseStageTest.java new file mode 100644 index 000000000..445700d8c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ParseStageTest.java @@ -0,0 +1,98 @@ +/* + * 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.tool.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ParseStage}. + * + *

Tests cover all enum values and their properties. + * + * @since 1.0.10 + */ +@DisplayName("ParseStage Enum Tests") +class ParseStageTest { + + @Test + @DisplayName("Should have all expected enum values") + void shouldHaveAllExpectedEnumValues() { + ParseStage[] stages = ParseStage.values(); + + assertEquals(6, stages.length); + } + + @Test + @DisplayName("DIRECT stage should be first") + void directStageShouldBeFirst() { + assertEquals(ParseStage.DIRECT, ParseStage.values()[0]); + } + + @Test + @DisplayName("ORIGINAL stage should be last") + void originalStageShouldBeLast() { + ParseStage[] stages = ParseStage.values(); + assertEquals(ParseStage.ORIGINAL, stages[stages.length - 1]); + } + + @Test + @DisplayName("Should access enum constants") + void shouldAccessEnumConstants() { + assertNotNull(ParseStage.DIRECT); + assertNotNull(ParseStage.MARKDOWN_CLEAN); + assertNotNull(ParseStage.COMMENT_STRIP); + assertNotNull(ParseStage.QUOTE_FIX); + assertNotNull(ParseStage.JSON_REPAIR); + assertNotNull(ParseStage.ORIGINAL); + } + + @Test + @DisplayName("Should access enum names") + void shouldAccessEnumNames() { + assertEquals("DIRECT", ParseStage.DIRECT.name()); + assertEquals("MARKDOWN_CLEAN", ParseStage.MARKDOWN_CLEAN.name()); + assertEquals("COMMENT_STRIP", ParseStage.COMMENT_STRIP.name()); + assertEquals("QUOTE_FIX", ParseStage.QUOTE_FIX.name()); + assertEquals("JSON_REPAIR", ParseStage.JSON_REPAIR.name()); + assertEquals("ORIGINAL", ParseStage.ORIGINAL.name()); + } + + @Test + @DisplayName("Should access enum ordinals") + void shouldAccessEnumOrdinals() { + assertEquals(0, ParseStage.DIRECT.ordinal()); + assertEquals(1, ParseStage.MARKDOWN_CLEAN.ordinal()); + assertEquals(2, ParseStage.COMMENT_STRIP.ordinal()); + assertEquals(3, ParseStage.QUOTE_FIX.ordinal()); + assertEquals(4, ParseStage.JSON_REPAIR.ordinal()); + assertEquals(5, ParseStage.ORIGINAL.ordinal()); + } + + @Test + @DisplayName("valueOf should return correct enum") + void valueOfShouldReturnCorrectEnum() { + assertEquals(ParseStage.DIRECT, ParseStage.valueOf("DIRECT")); + assertEquals(ParseStage.MARKDOWN_CLEAN, ParseStage.valueOf("MARKDOWN_CLEAN")); + assertEquals(ParseStage.COMMENT_STRIP, ParseStage.valueOf("COMMENT_STRIP")); + assertEquals(ParseStage.QUOTE_FIX, ParseStage.valueOf("QUOTE_FIX")); + assertEquals(ParseStage.JSON_REPAIR, ParseStage.valueOf("JSON_REPAIR")); + assertEquals(ParseStage.ORIGINAL, ParseStage.valueOf("ORIGINAL")); + } +} diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java index 8c6e0a98a..f4bc3215d 100644 --- a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserHookTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool.parser; 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.assertSame; import static org.mockito.Mockito.mock; @@ -405,4 +406,115 @@ void shouldUpdateOnlyContentField() { assertNotNull(correctedToolUse.getContent()); } } + + @Nested + @DisplayName("Hook Error Handling and Logging Tests") + class HookErrorHandlingTests { + + @Test + @DisplayName("Should handle JSON processing error gracefully") + void shouldHandleJsonProcessingError() { + // Create content that will fail during processing + // This tests the error handling path in processPreActingEvent + String problematicContent = + "{\"data\": " + new String(new char[10000]).replace('\0', 'x') + "}"; + + PreActingEvent event = createTestEventWithContent(problematicContent); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + // Should not throw, return original or processed event + assertNotNull(processedEvent); + } + + @Test + @DisplayName("Should preserve input when parsing fails") + void shouldPreserveInputWhenParsingFails() { + String invalidContent = "totally not valid json at all"; + + PreActingEvent event = createTestEventWithContent(invalidContent); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + // Event should be returned, content may or may not be modified + assertNotNull(processedEvent.getToolUse()); + } + + @Test + @DisplayName("Should handle event with minimal metadata") + void shouldHandleEventWithMinimalMetadata() { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("test-id") + .name("testTool") + .input(Map.of()) + .content("{\"test\":\"data\"}") + .metadata(Map.of()) // Empty metadata + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + assertNotNull(processedEvent.getToolUse().getMetadata()); + } + } + + @Nested + @DisplayName("Content Correction Verification Tests") + class ContentCorrectionVerificationTests { + + @Test + @DisplayName("Should actually correct markdown-wrapped content") + void shouldActuallyCorrectMarkdownContent() { + String markdownContent = "```json\n{\"query\":\"test\",\"limit\":10}\n```"; + PreActingEvent event = createTestEventWithContent(markdownContent); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + String correctedContent = processedEvent.getToolUse().getContent(); + assertNotNull(correctedContent); + + // Verify markdown wrapper was removed + assertFalse(correctedContent.startsWith("```")); + assertFalse(correctedContent.contains("```json")); + } + + @Test + @DisplayName("Should handle content with only whitespace") + void shouldHandleWhitespaceOnlyContent() { + PreActingEvent event = createTestEventWithContent(" \n\t "); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + // Should not crash on whitespace-only content + } + + @Test + @DisplayName("Should preserve content when no correction needed") + void shouldPreserveContentWhenNoCorrectionNeeded() { + String validJson = "{\"query\":\"test\",\"limit\":10}"; + + PreActingEvent event = createTestEventWithContent(validJson); + + Mono result = hook.onEvent(event); + PreActingEvent processedEvent = result.block(); + + assertNotNull(processedEvent); + // Valid JSON should pass through unchanged + assertEquals(validJson, processedEvent.getToolUse().getContent()); + } + } } diff --git a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java index ac3f555b5..c0aadac46 100644 --- a/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java +++ b/agentscope-extensions/agentscope-extensions-tool-parser/src/test/java/io/agentscope/core/tool/parser/ToolArgumentParserTest.java @@ -16,12 +16,14 @@ package io.agentscope.core.tool.parser; 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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; /** @@ -447,4 +449,164 @@ void shouldNotBreakSingleQuotesInsideStrings() { assertEquals(json, result.parsedArguments()); } } + + @Nested + @DisplayName("ParseResult Utility Methods Tests") + @Order(8) + class ParseResultUtilityMethodsTests { + + @Test + @DisplayName("isDirectSuccess() should return true for DIRECT stage") + void isDirectSuccessShouldReturnTrueForDirect() { + String json = "{\"key\":\"value\"}"; + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.isSuccess()); + assertTrue(result.isDirectSuccess()); + assertFalse(result.requiredMultipleStages()); + } + + @Test + @DisplayName("requiredMultipleStages() should return true for non-DIRECT stages") + void requiredMultipleStagesShouldReturnTrueForNonDirect() { + String markdownJson = "```json\n{\"key\":\"value\"}\n```"; + ParseResult result = ToolArgumentParser.parse(markdownJson, "testTool"); + + assertTrue(result.isSuccess()); + assertFalse(result.isDirectSuccess()); + assertTrue(result.requiredMultipleStages()); + } + + @Test + @DisplayName("isDirectSuccess() should return false for failed results") + void isDirectSuccessShouldReturnFalseForFailure() { + ParseResult result = ToolArgumentParser.parse("invalid json", "testTool"); + + assertFalse(result.isSuccess()); + assertFalse(result.isDirectSuccess()); + assertFalse(result.requiredMultipleStages()); + } + } + + @Nested + @DisplayName("Boundary and Edge Case Tests") + @Order(9) + class BoundaryEdgeCaseTests { + + @Test + @DisplayName("Should handle null input gracefully") + void shouldHandleNullInput() { + ParseResult result = ToolArgumentParser.parse(null, "testTool"); + + assertFalse(result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertEquals("null", result.parsedArguments()); + assertTrue(result.errorMessage().contains("null or empty")); + } + + @Test + @DisplayName("Should handle empty string input") + void shouldHandleEmptyString() { + ParseResult result = ToolArgumentParser.parse("", "testTool"); + + assertFalse(result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertEquals("", result.parsedArguments()); + assertTrue(result.errorMessage().contains("null or empty")); + } + + @Test + @DisplayName("Should handle whitespace-only input") + void shouldHandleWhitespaceOnly() { + ParseResult result = ToolArgumentParser.parse(" \n\t ", "testTool"); + + assertFalse(result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertTrue(result.errorMessage().contains("null or empty")); + } + + @Test + @DisplayName("Should reject input exceeding size limit") + void shouldRejectInputExceedingSizeLimit() { + // Create JSON larger than 100KB + StringBuilder largeJson = new StringBuilder("{"); + for (int i = 0; i < 10000; i++) { + if (i > 0) largeJson.append(","); + largeJson.append("\"field").append(i).append("\":\"value"); + largeJson.append(i).append("\""); + } + largeJson.append("}"); + + String json = largeJson.toString(); + assertTrue(json.length() > 100_000, "Test JSON should exceed 100KB limit"); + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertFalse(result.isSuccess()); + assertEquals(ParseStage.ORIGINAL, result.stage()); + assertTrue(result.errorMessage().contains("exceeds limit")); + } + + @Test + @DisplayName("Should accept input exactly at size limit") + void shouldAcceptInputAtSizeLimit() { + // Create JSON exactly 100KB (minus 1 for safety) + StringBuilder sb = new StringBuilder(); + sb.append("{"); + for (int i = 0; i < 9000; i++) { + if (i > 0) sb.append(","); + sb.append("\"f").append(i).append("\":\"v\""); + } + sb.append("}"); + + String json = sb.toString(); + // Ensure it's under the limit + if (json.length() < 100_000) { + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + // Should succeed or fail for parsing reasons, not size + assertNotNull(result); + } + } + } + + @Nested + @DisplayName("Error Message Tests") + @Order(10) + class ErrorMessageTests { + + @Test + @DisplayName("Should provide clear error message for invalid JSON") + void shouldProvideClearErrorMessage() { + ParseResult result = ToolArgumentParser.parse("{invalid}", "testTool"); + + assertFalse(result.isSuccess()); + assertNotNull(result.errorMessage()); + assertTrue(result.errorMessage().length() > 0); + } + + @Test + @DisplayName("Should include tool name in error message for null input") + void shouldIncludeToolNameInErrorMessage() { + ParseResult result = ToolArgumentParser.parse(null, "mySearchTool"); + + assertTrue(result.errorMessage().contains("mySearchTool")); + } + + @Test + @DisplayName("Should include size information in size limit error") + void shouldIncludeSizeInLimitError() { + StringBuilder largeJson = new StringBuilder(); + for (int i = 0; i < 12000; i++) { + largeJson.append("\"field").append(i).append("\":\"value\""); + if (i < 11999) largeJson.append(","); + } + + String json = "{" + largeJson.toString() + "}"; + + ParseResult result = ToolArgumentParser.parse(json, "testTool"); + + assertTrue(result.errorMessage().contains("exceeds limit")); + } + } } From 6a17d7df44ab63143208c1ffdb6caea6bfb80172 Mon Sep 17 00:00:00 2001 From: miniceM <540043613@qq.com> Date: Tue, 3 Mar 2026 14:16:01 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(pom):=20=E6=B7=BB=E5=8A=A0=20agentscop?= =?UTF-8?q?e-extensions-tool-parser=20=E4=BE=9D=E8=B5=96=20-=20=E5=9C=A8?= =?UTF-8?q?=20pom.xml=20=E4=B8=AD=E6=96=B0=E5=A2=9E=20agentscope-extension?= =?UTF-8?q?s-tool-parser=20=E4=BE=9D=E8=B5=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agentscope-distribution/agentscope-all/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index 99383a174..e47d3ceed 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -214,6 +214,13 @@ true + + io.agentscope + agentscope-extensions-tool-parser + compile + true + + io.modelcontextprotocol.sdk