spring-projects / spring-projects/spring-ai

Bedrock Converse: tool-call arguments serialized via Document.toString() produce invalid JSON, breaking tool execution when arguments contain newlines

Open
#6,962 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
Dominant language
Java
Stars
9.5k
Forks
2.9k
Avg merge
1d 7h
Merged PRs (30d)
6

Description

Bug description

When using the Bedrock Converse chat model with tool calling, BedrockProxyChatModel serializes the tool-use input by calling toString() on the AWS SDK Document:

https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModel.java#L676

var functionArguments = toolUseContentBlock.toolUse().input().toString();

Document.toString() is not a JSON serializer. It renders string values without escaping control characters (e.g. \n, \t). The resulting string is stored verbatim as AssistantMessage.ToolCall#arguments.

When the tool is executed, the arguments string is parsed back into a Map with a strict Jackson 3 mapper via the static JsonHelper.fromJsonToMap(...) (e.g. in SyncMcpToolCallback/AsyncMcpToolCallback). Jackson 3 rejects unescaped control characters inside string values, so the call fails:

tools.jackson.core.exc.StreamReadException: Illegal unquoted character
((CTRL-CHAR, code 10)): has to be escaped using backslash to be included in string value
 ... (through reference chain: java.util.LinkedHashMap["text"])
      at org.springframework.ai.util.JsonHelper.fromJsonToMap(JsonHelper.java)
      at org.springframework.ai.mcp.SyncMcpToolCallback.call(SyncMcpToolCallback.java)
      at org.springframework.ai.model.tool.DefaultToolCallingManager.executeToolCall(...)

The bug is latent for single-line tool arguments and only surfaces when the model puts a newline (or other control character) into a tool argument — which is common and natural, e.g. a multi-line message body passed to a send-style tool.

Because JsonHelper is a static final field on the tool-callback classes, there is no injection point to relax parsing on the consuming side — the defect must be fixed where the malformed string is produced.

Environment
  • Spring AI: 2.0.1 (also present on main)
  • Module: spring-ai-bedrock-converse
  • Jackson: 3.x (tools.jackson.*), strict about unescaped control characters by default
  • Model: Anthropic Claude via Bedrock Converse (channel-agnostic; any tool with a string
    argument containing a newline reproduces it)
Steps to reproduce
  1. Configure a ChatClient with the Bedrock Converse model and any tool that accepts a string parameter.
  2. Prompt the model so it calls the tool with a multi-line string argument (containing \n).
  3. Tool execution fails with the StreamReadException above; the request returns a 500.
Expected behavior

Tool-call arguments produced by BedrockProxyChatModel should be valid JSON regardless of the content of string values, so they round-trip through JsonHelper.fromJsonToMap(...) during tool execution.

Root cause

Document.toString() is used as if it were a JSON serializer. The Document is a structured value (map/list/string/number/boolean/null) and should be serialized with a real JSON writer. Note the request path already does the correct inverse:
ConverseApiUtils.convertObjectToDocument(jsonHelper.fromJsonToMap(toolCall.arguments())).

Proposed fix

Add the inverse converter ConverseApiUtils.convertDocumentToObject(Document) and serialize with the existing jsonHelper:

var functionArguments = jsonHelper
    .toJson(ConverseApiUtils.convertDocumentToObject(toolUseContentBlock.toolUse().input()));

This escapes control characters correctly, is symmetric with the request path, and fixes the issue for all tools (not just MCP).

Alternatives considered
  • Relax JSON parsing to allow unescaped control characters (e.g. enable JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS on the mapper used by JsonHelper, or leniently re-parse the arguments before execution). Rejected as the upstream fix: it treats the symptom rather than the cause, weakens strict JSON parsing framework-wide (hiding malformed data elsewhere), and assumes Document.toString() is otherwise valid JSON, which is not a documented contract. It is only appropriate as a downstream workaround where the producing code cannot be changed (the static JsonHelper leaves no seam in the callback layer).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in the spring-ai-bedrock-converse module at BedrockProxyChatModel.java around line 676, then inspect ConverseApiUtils and the existing jsonHelper conversion used on the request path. Verify that tool-use Documents containing newlines and other control characters produce valid JSON that round-trips through JsonHelper.fromJsonToMap during tool execution.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, java
Domain
ai, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.