spring-projects / spring-projects/spring-ai
GoogleGenAiChatModel.parseJsonToMap() crashes on non-JSON tool responses and silently loses data
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.5k
- Forks
- 2.9k
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 6
Description
Bug description
GoogleGenAiChatModel.parseJsonToMap() has two defects when handling tool response strings:
- Crash: Any tool that returns plain text (not valid JSON) causes an unrecoverable
RuntimeException, terminating the entire agent loop. - Silent data loss: When a tool response happens to start with a JSON-valid token (e.g.,
"3.0 + 5.0 = 8.0"), Jackson parses only the first token (3.0) and silently discards the rest.
The root cause is in messageToGeminiParts() — when converting a ToolResponseMessage into Gemini API format, it calls parseJsonToMap(response.responseData()) which blindly passes the tool result to ObjectMapper.readValue(). This assumes all tool responses are valid JSON, but Spring AI's ToolCallback interface returns String, which is commonly plain text.
Environment
- Spring AI: 1.1.2 (also verified the code is identical in 2.0.0-M1)
- Spring Boot: 3.5.9
- Java: 21 (Kotlin 2.3.10)
- Gemini Model: gemini-2.0-flash
Steps to reproduce
1. Define a tool that returns plain text
@Component
public class DateTimeTool implements ToolCallback {
@Override
public String getName() { return "current_datetime"; }
@Override
public String getDescription() { return "Get the current date and time"; }
@Override
public String getInputSchema() {
return """
{
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "Timezone ID (e.g., Asia/Seoul, UTC)"
}
}
}
""";
}
@Override
public Object call(Map<String, Object> arguments) {
String tz = (String) arguments.getOrDefault("timezone", "UTC");
ZonedDateTime now = ZonedDateTime.now(ZoneId.of(tz));
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss (EEEE)");
return now.format(fmt) + " [" + tz + "]";
// Returns: "2026-02-11 00:14:54 (Tuesday) [UTC]"
}
}
2. Send a prompt that triggers the tool
POST /api/chat
{ "message": "What time is it now? Use the current_datetime tool." }
3. Observe the crash
The first LLM call succeeds — Gemini invokes the tool. The tool executes and returns the datetime string. But the second LLM call (which includes the tool response in conversation history) crashes because messageToGeminiParts passes the plain text result to parseJsonToMap().
Test matrix: parseJsonToMap() behavior on various inputs
| Input | Result | Issue |
|---|---|---|
2026-02-11 00:14:54 (Tuesday) [UTC] |
RuntimeException | Crash — 2026 parsed as number, - unexpected |
Hello, the current time is 3pm |
RuntimeException | Crash — Hello is not a JSON token |
Error: Invalid timezone |
RuntimeException | Crash — Error is not a JSON token |
3.0 + 5.0 = 8.0 |
Returns {"result": 3.0} |
Silent data loss — + 5.0 = 8.0 discarded |
42 is the answer |
Returns {"result": 42} |
Silent data loss — is the answer discarded |
42 |
Returns {"result": 42} |
OK |
true |
Returns {"result": true} |
OK |
{"key": "value"} |
Returns {"key": "value"} |
OK |
9 out of 17 tested inputs fail, and 2 more silently lose data.
Stack trace
java.lang.RuntimeException: Failed to parse JSON: 2026-02-11 00:14:54 (Tuesday) [UTC]
at o.s.ai.google.genai.GoogleGenAiChatModel.parseJsonToMap(GoogleGenAiChatModel.java:397)
at o.s.ai.google.genai.GoogleGenAiChatModel.messageToGeminiParts(GoogleGenAiChatModel.java:337)
at o.s.ai.google.genai.GoogleGenAiChatModel.toGeminiContent(GoogleGenAiChatModel.java:885)
at o.s.ai.google.genai.GoogleGenAiChatModel.createGeminiRequest(GoogleGenAiChatModel.java:830)
at o.s.ai.google.genai.GoogleGenAiChatModel.internalCall(GoogleGenAiChatModel.java:437)
...
Caused by: com.fasterxml.jackson.core.JsonParseException:
Unexpected character ('-' (code 45)): Expected space separating root-level values
at [Source: REDACTED; line: 1, column: 5]
Root cause analysis
In GoogleGenAiChatModel.java, messageToGeminiParts handles ToolResponseMessage:
if (message instanceof ToolResponseMessage toolResponseMessage) {
return toolResponseMessage.getResponses().stream()
.map(response -> Part.builder()
.functionResponse(FunctionResponse.builder()
.name(response.name())
.response(parseJsonToMap(response.responseData())) // ← crashes here
.build())
.build())
.toList();
}
And parseJsonToMap has no fallback:
private static Map<String, Object> parseJsonToMap(String json) {
try {
Object parsed = OBJECT_MAPPER.readValue(json, Object.class);
if (parsed instanceof List) return Map.of("result", parsed);
if (parsed instanceof Map) return (Map) parsed;
return Map.of("result", parsed);
} catch (Exception e) {
throw new RuntimeException("Failed to parse JSON: " + json, e); // no fallback
}
}
Suggested fix
Wrap non-JSON text in the catch block instead of throwing:
private static Map<String, Object> parseJsonToMap(String json) {
try {
Object parsed = OBJECT_MAPPER.readValue(json, Object.class);
if (parsed instanceof List) return Map.of("result", parsed);
if (parsed instanceof Map) return (Map) parsed;
return Map.of("result", parsed);
} catch (Exception e) {
// Gracefully handle non-JSON tool responses by wrapping as {"result": "..."}
Map<String, Object> wrapper = new HashMap<>();
wrapper.put("result", json);
return wrapper;
}
}
This is consistent with:
- The Gemini API's
FunctionResponse, which accepts anyMap<String, Object>as the response - The method's existing behavior for non-Map values (List, primitives), which are already wrapped as
{"result": value}
Impact
- All tools returning plain text are broken when using Google GenAI / Gemini
- This is language-agnostic — English text fails identically to non-English text
- Other model providers (OpenAI, Anthropic) are not affected
- Workaround: Return tool results as JSON strings (e.g.,
{"result": "..."})
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in GoogleGenAiChatModel.java at messageToGeminiParts() and parseJsonToMap(), following the ToolResponseMessage conversion and the reported Jackson behavior. Add regression coverage for plain-text responses and text beginning with a JSON-valid token, then verify valid objects, arrays, and primitives retain their existing behavior without crashing or losing trailing text.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, spring
- Domain
- ai, backend-api-design
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100