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
- Define a tool method with a
Long (boxed) parameter:
public static Map<String, Object> getUserByIdBoxed(Long id) {
return Map.of("userId", id, "name", "Alice");
}
- 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"));
}
-
Invoke the agent with a prompt that causes the model to call the tool with a small integer argument (e.g. 42).
-
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.2 — ParserBase.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, runAsync → call → buildArguments → resolveArgumentValue → castValue. 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 Integer → Long for boxed types (it only auto-widens for primitives — int → long). 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 int → long 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_L — jackson-core 2.20.2, com/fasterxml/jackson/core/base/ParserMinimalBase.java:113
Bug Report: FunctionTool silently returns generic error when tool has a boxed
Longparameter and model returns a small integerSummary
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.google-adk 1.4.1core/src/main/java/com/google/adk/tools/FunctionTool.javacastValue(Object value, Class<?> type)2.20.2Steps to Reproduce
Long(boxed) parameter:FunctionTooland attach it to anLlmAgent:Invoke the agent with a prompt that causes the model to call the tool with a small integer argument (e.g.
42).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:
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, notLongWhen the model returns a function call with argument
{"id": 42}, the ADK framework deserializes it intoMap<String, Object>via Jackson. Jackson's number parsing logic inParserBase._parseNumericValue()decides the Java type purely by digit length and value:jackson-core 2.20.2—ParserBase.java(inside_parseNumericValue()):https://github.com/FasterXML/jackson-core/blob/2.20/src/main/java/com/fasterxml/jackson/core/base/ParserBase.java#L928
Where
MAX_INT_Lis defined as:getNumberValue()then checks_numTypesValidand returns_numberInt(anInteger) whenNR_INTis set.Rule:
42(2 digits) →Integer. Only numbers exceedingInteger.MAX_VALUE(2,147,483,647) produce aLong. This is unconditional for untypedMap<String, Object>deserialization.Step 2 — The ADK framework passes the args map unchanged into
tool.runAsync()In
Functions.javaline 303, the framework takes the args directly from the model'sFunctionCalland passes them to the tool with no type conversion:At this point
functionArgscontains{"id": Integer(42)}.Step 3 —
castValue()returns the rawIntegerinstead of widening it toLongIn
FunctionTool.java,runAsync→call→buildArguments→resolveArgumentValue→castValue. TheLongbranch at line 452:When
typeisLong.classandvalueisInteger(42), the condition is true and the rawIntegeris returned unchanged. No widening conversion is applied.Compare this to the
Doublebranch immediately below, which correctly performs widening:The
Longbranch was simply missing the equivalent widening step.Step 4 — Java reflection throws
IllegalArgumentException;runAsyncswallows itBack in
call(), reflection invokes the method with the rawInteger:Java reflection does not auto-widen
Integer→Longfor boxed types (it only auto-widens for primitives —int→long). So it throws:This exception propagates synchronously out of
call()and is caught by thecatch (Exception e)inrunAsync():The real cause is only logged. The user sees the generic error.
Why Primitive
longIs Not AffectedJava reflection does auto-widen primitive types. When the parameter type is
long(primitive), passing anIntegertoMethod.invoke()succeeds because the JVM widensint→longautomatically. Only the boxedLongtriggers the failure.References
FunctionTool.castValue()—core/src/main/java/com/google/adk/tools/FunctionTool.java:446Functions.handleFunctionCalls()—core/src/main/java/com/google/adk/flows/llmflows/Functions.java:302ParserBase._parseNumericValue()—jackson-core 2.20.2,com/fasterxml/jackson/core/base/ParserBase.java:909ParserMinimalBase.MAX_INT_L—jackson-core 2.20.2,com/fasterxml/jackson/core/base/ParserMinimalBase.java:113