spring-projects / spring-projects/spring-ai

GoogleGenAiChatModel.parseJsonToMap() crashes on non-JSON tool responses and silently loses data

Open
#5,437 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

GoogleGenAiChatModel.parseJsonToMap() has two defects when handling tool response strings:

  1. Crash: Any tool that returns plain text (not valid JSON) causes an unrecoverable RuntimeException, terminating the entire agent loop.
  2. 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 lossis 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 any Map<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

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 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.