Please make sure you read the contribution guide and file the issues in the right place.
Contribution guide.
🔴 Required Information
Describe the Bug
LlmResponse.Builder.response(GenerateContentResponse) decides whether a model response is a
success by testing whether the candidate's content object is present, never whether it holds
anything:
// core/src/main/java/com/google/adk/models/LlmResponse.java
this.finishReason(candidate.finishReason().orElse(null));
if (candidate.content().isPresent()) {
this.content(candidate.content().get());
this.groundingMetadata(candidate.groundingMetadata().orElse(null));
} else {
candidate.finishReason().ifPresent(this::errorCode);
candidate.finishMessage().ifPresent(this::errorMessage);
}
A candidate that was cut off mid-generation comes back with content present and part-less —
"content": {} on the wire. content().isPresent() is true, so the success branch runs:
errorCode and errorMessage are never set, and candidate.finishMessage(), the model's own
explanation, is discarded. The turn ends with an empty answer that is indistinguishable from a model
that simply had nothing to say.
errorCode is documented as "Error code if the response is an error", yet the result carries
finishReason=MAX_TOKENS with errorCode empty.
Steps to Reproduce
Run any LlmAgent against a thinking-capable model with room to think and no room to answer, so the
turn ends before a visible token is emitted:
GenerateContentConfig.builder()
.maxOutputTokens(24)
.thinkingConfig(ThinkingConfig.builder().thinkingBudget(2048).build())
.build();
On gemini-3.1-flash-lite this returns:
{
"candidates": [{ "content": {}, "finishReason": "MAX_TOKENS", "index": 0 }],
"usageMetadata": { "thoughtsTokenCount": 37, "totalTokenCount": 70 }
}
Expected Behavior
An error response: errorCode set from the finish reason, errorMessage from finishMessage. A
candidate that produced nothing and did not stop normally is not a successful turn.
Observed Behavior
A success response. content is set to the empty candidate content, errorCode and errorMessage
are absent, finishMessage is dropped. Observed end-to-end through a real agent run:
--- Starved - maxOutputTokens=24, thinkingBudget=2048 ---
finish reasons : [MAX_TOKENS]
LlmResponse errorCodes: [(absent)]
parts per response : [0]
text reaching caller : []
--- Control - maxOutputTokens=200, thinking off ---
finish reasons : [STOP]
LlmResponse errorCodes: [(absent)]
parts per response : [1]
text reaching caller : [READY]
The caller gets an empty answer and no diagnostic anywhere.
Environment Details
- ADK Library Version:
1.7.2-SNAPSHOT
- OS: Windows 11 — not OS-specific
Model Information
gemini-3.1-flash-lite; any thinking-capable Gemini model reaches the same shape.
🟡 Optional Information
Regression
No — the branch has had this shape since the method was introduced.
Willingness to contribute
Yes — a PR follows immediately. One predicate, in one method: decide on whether the candidate
produced parts, treating a STOP with nothing to say as a legitimate empty turn.
boolean hasParts =
candidate.content().flatMap(Content::parts).map(parts -> !parts.isEmpty()).orElse(false);
boolean stopped =
candidate.finishReason().map(reason -> reason.knownEnum() == FinishReason.Known.STOP).orElse(false);
if (hasParts || stopped) {
this.content(candidate.content().orElse(null));
this.groundingMetadata(candidate.groundingMetadata().orElse(null));
} else {
candidate.finishReason().ifPresent(this::errorCode);
candidate.finishMessage().ifPresent(this::errorMessage);
}
The same predicate also corrects the mirror-image case in this branch — a candidate with no
content and finishReason=STOP currently comes out as errorCode=STOP, marking a turn that ended
normally as an error. It becomes a plain successful response.
The prompt-feedback and no-candidates branches are left exactly as they are.
How often has this issue occurred?
Always (100%), whenever the model returns a part-less candidate.
Please make sure you read the contribution guide and file the issues in the right place.
Contribution guide.
🔴 Required Information
Describe the Bug
LlmResponse.Builder.response(GenerateContentResponse)decides whether a model response is asuccess by testing whether the candidate's content object is present, never whether it holds
anything:
A candidate that was cut off mid-generation comes back with
contentpresent and part-less —"content": {}on the wire.content().isPresent()is true, so the success branch runs:errorCodeanderrorMessageare never set, andcandidate.finishMessage(), the model's ownexplanation, is discarded. The turn ends with an empty answer that is indistinguishable from a model
that simply had nothing to say.
errorCodeis documented as "Error code if the response is an error", yet the result carriesfinishReason=MAX_TOKENSwitherrorCodeempty.Steps to Reproduce
Run any
LlmAgentagainst a thinking-capable model with room to think and no room to answer, so theturn ends before a visible token is emitted:
On
gemini-3.1-flash-litethis returns:{ "candidates": [{ "content": {}, "finishReason": "MAX_TOKENS", "index": 0 }], "usageMetadata": { "thoughtsTokenCount": 37, "totalTokenCount": 70 } }Expected Behavior
An error response:
errorCodeset from the finish reason,errorMessagefromfinishMessage. Acandidate that produced nothing and did not stop normally is not a successful turn.
Observed Behavior
A success response.
contentis set to the empty candidate content,errorCodeanderrorMessageare absent,
finishMessageis dropped. Observed end-to-end through a real agent run:The caller gets an empty answer and no diagnostic anywhere.
Environment Details
1.7.2-SNAPSHOTModel Information
gemini-3.1-flash-lite; any thinking-capable Gemini model reaches the same shape.🟡 Optional Information
Regression
No — the branch has had this shape since the method was introduced.
Willingness to contribute
Yes — a PR follows immediately. One predicate, in one method: decide on whether the candidate
produced parts, treating a
STOPwith nothing to say as a legitimate empty turn.The same predicate also corrects the mirror-image case in this branch — a candidate with no
content and
finishReason=STOPcurrently comes out aserrorCode=STOP, marking a turn that endednormally as an error. It becomes a plain successful response.
The prompt-feedback and no-candidates branches are left exactly as they are.
How often has this issue occurred?
Always (100%), whenever the model returns a part-less candidate.