Skip to content

Bug: FunctionTool silently returns generic error when tool has a boxed Long parameter and model returns a small integer #1289

@jjhz

Description

@jjhz

Bug Report: FunctionTool silently returns generic error when tool has a boxed Long parameter and model returns a small integer

Summary

When a user defines a tool method with a Long (boxed) parameter and the model returns a small integer value (e.g. 42), the tool invocation silently fails with {status=error, message=An internal error occurred.} instead of returning the actual tool result.

  • Affected version: google-adk 1.4.1
  • Affected file: core/src/main/java/com/google/adk/tools/FunctionTool.java
  • Affected method: castValue(Object value, Class<?> type)
  • Jackson version in use: 2.20.2

Steps to Reproduce

  1. Define a tool method with a Long (boxed) parameter:
public static Map<String, Object> getUserByIdBoxed(Long id) {
    return Map.of("userId", id, "name", "Alice");
}
  1. Register it as a FunctionTool and attach it to an LlmAgent:
@Test
void toolWithBoxedLongParam_whenModelReturnsSmallInteger_shouldReturnResult() throws Exception {
  FunctionTool tool =
      FunctionTool.create(
          LongParameterToolBugTest.class.getMethod("getUserByIdBoxed", Long.class));

  LlmAgent agent =
      LlmAgent.builder()
          .name("TestAgent")
          .model("gemini-2.5-flash")
          .description("test")
          .tools(tool)
          .build();

  Session session = Session.builder("session-2").appName("test-app").userId("user-1").build();

  InvocationContext invCtx =
      InvocationContext.builder()
          .invocationId("inv-2")
          .agent(agent)
          .session(session)
          .sessionService(new InMemorySessionService())
          .artifactService(new InMemoryArtifactService())
          .memoryService(new InMemoryMemoryService())
          .runConfig(RunConfig.builder().build())
          .build();

  ToolContext toolContext = ToolContext.builder(invCtx).build();

  // Jackson deserializes small JSON integers as Integer — this is what the model produces.
  Integer jacksonDeserializedValue = 42;
  Map<String, Object> args = Map.of("id", jacksonDeserializedValue);

  Map<String, Object> result = tool.runAsync(args, toolContext).blockingGet();

  // silently fails with {status=error, message=An internal error occurred.} instead of returning the actual tool result
  assertEquals(2, result.size());
  assertEquals("error", result.get("status"));
  assertEquals("An internal error occurred.", result.get("message"));
}
  1. Invoke the agent with a prompt that causes the model to call the tool with a small integer argument (e.g. 42).

  2. Observe {status=error, message=An internal error occurred.} returned instead of the tool result.


Expected Behavior

The tool executes successfully and returns its result, e.g. {userId=42, name=Alice}.


Actual Behavior

The tool returns the generic error map:

{status=error, message=An internal error occurred.}

The real cause (IllegalArgumentException: argument type mismatch) is only visible in logs.


Root Cause Analysis

The bug involves a chain of four components working against each other.

Step 1 — Jackson always deserializes small JSON integers as Integer, not Long

When the model returns a function call with argument {"id": 42}, the ADK framework deserializes it into Map<String, Object> via Jackson. Jackson's number parsing logic in ParserBase._parseNumericValue() decides the Java type purely by digit length and value:

jackson-core 2.20.2ParserBase.java (inside _parseNumericValue()):
https://github.com/FasterXML/jackson-core/blob/2.20/src/main/java/com/fasterxml/jackson/core/base/ParserBase.java#L928

} else if (_currToken == JsonToken.VALUE_NUMBER_INT) {
    final int len = _intLength;
    if (len <= 9) {
        _numberInt = _textBuffer.contentsAsInt(_numberNegative);
        _numTypesValid = NR_INT;   // ← 42 has 2 digits: always Integer
        return;
    }
    if (len <= 18) { // definitely fits AND is easy to parse using 2 int parse calls
        long l = _textBuffer.contentsAsLong(_numberNegative);

        // Might still fit in int, need to check
        if (len == 10) {
            boolean fitsInInt =
                    (_numberNegative && l >= MIN_INT_L)
                            || (!_numberNegative && l <= MAX_INT_L);  // MAX_INT_L = Integer.MAX_VALUE

            if (fitsInInt) {
                _numberInt = (int) l;
                _numTypesValid = NR_INT;   // ← still fits in int → Integer
                return;
            }
        }

        _numberLong = l;
        _numTypesValid = NR_LONG;  // ← too big for int → Long
        return;
    }

Where MAX_INT_L is defined as:

// ParserMinimalBase.java line 113
protected final static long MAX_INT_L = Integer.MAX_VALUE;

getNumberValue() then checks _numTypesValid and returns _numberInt (an Integer) when NR_INT is set.

Rule: 42 (2 digits) → Integer. Only numbers exceeding Integer.MAX_VALUE (2,147,483,647) produce a Long. This is unconditional for untyped Map<String, Object> deserialization.

Step 2 — The ADK framework passes the args map unchanged into tool.runAsync()

In Functions.java line 303, the framework takes the args directly from the model's FunctionCall and passes them to the tool with no type conversion:

// Functions.java:302-303
Map<String, Object> functionArgs =
    functionCall.args().map(HashMap::new).orElse(new HashMap<>());
// ... eventually calls:
tool.runAsync(functionArgs, toolContext)  // line 640

At this point functionArgs contains {"id": Integer(42)}.

Step 3 — castValue() returns the raw Integer instead of widening it to Long

In FunctionTool.java, runAsynccallbuildArgumentsresolveArgumentValuecastValue. The Long branch at line 452:

// FunctionTool.java:452-455
if (type.equals(Long.class) || type.equals(long.class)) {
    if (value instanceof Long || value instanceof Integer) {
        return value;  // BUG: returns raw Integer(42) when type is Long
    }
}

When type is Long.class and value is Integer(42), the condition is true and the raw Integer is returned unchanged. No widening conversion is applied.

Compare this to the Double branch immediately below, which correctly performs widening:

// FunctionTool.java:456-468
} else if (type.equals(Double.class) || type.equals(double.class)) {
    if (value instanceof Double d)  return d.doubleValue();
    if (value instanceof Float f)   return f.doubleValue();
    if (value instanceof Integer i) return i.doubleValue();  // ← widening done correctly
    if (value instanceof Long l)    return l.doubleValue();  // ← widening done correctly
}

The Long branch was simply missing the equivalent widening step.

Step 4 — Java reflection throws IllegalArgumentException; runAsync swallows it

Back in call(), reflection invokes the method with the raw Integer:

// FunctionTool.java:273
Object result = func.invoke(instance, arguments);

Java reflection does not auto-widen IntegerLong for boxed types (it only auto-widens for primitives — intlong). So it throws:

java.lang.IllegalArgumentException: argument type mismatch

This exception propagates synchronously out of call() and is caught by the catch (Exception e) in runAsync():

// FunctionTool.java:263-266
} catch (Exception e) {
    logger.error("Exception occurred while calling function tool: " + func.getName(), e);
    return Single.just(
        ImmutableMap.of("status", "error", "message", "An internal error occurred."));
}

The real cause is only logged. The user sees the generic error.


Why Primitive long Is Not Affected

Java reflection does auto-widen primitive types. When the parameter type is long (primitive), passing an Integer to Method.invoke() succeeds because the JVM widens intlong automatically. Only the boxed Long triggers the failure.


References

  • FunctionTool.castValue()core/src/main/java/com/google/adk/tools/FunctionTool.java:446
  • Functions.handleFunctionCalls()core/src/main/java/com/google/adk/flows/llmflows/Functions.java:302
  • Jackson ParserBase._parseNumericValue()jackson-core 2.20.2, com/fasterxml/jackson/core/base/ParserBase.java:909
  • Jackson ParserMinimalBase.MAX_INT_Ljackson-core 2.20.2, com/fasterxml/jackson/core/base/ParserMinimalBase.java:113

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions