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:
+ *
+ * - parsedArguments cannot be null
+ * - stage cannot be null
+ * - errorMessage must be null for success results
+ * - errorMessage must be non-null for failure results
+ *
+ *
+ * @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:
+ *
+ *
+ * - Stage 0 (DIRECT): Standard JSON, parse directly
+ *
- Stage 1 (MARKDOWN_CLEAN): Remove ```json code blocks
+ *
- Stage 2 (COMMENT_STRIP): Strip // and /* *\/ comments
+ *
- Stage 3 (QUOTE_FIX): Convert single quotes to double quotes
+ *
- Stage 4 (JSON_REPAIR): Fix missing brackets and trailing commas
+ *
+ *
+ * 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:
+ *
+ *
+ * - Remove Markdown code blocks (triple-backtick json ... triple-backtick)
+ *
- Strip JavaScript-style comments (double-slash and slash-star ... star-slash)
+ *
- Convert single quotes to double quotes (via Jackson lenient mode)
+ *
- Fix missing brackets and trailing commas
+ *
- 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