Skip to content

[BUG] LlmResponse reports a candidate that produced nothing as a success #1416

Description

@svetanis

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions