Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions core/src/main/java/com/google/adk/models/LlmResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,22 @@ public final Builder response(GenerateContentResponse response) {
if (candidatesOpt.isPresent() && !candidatesOpt.get().isEmpty()) {
Candidate candidate = candidatesOpt.get().get(0);
this.finishReason(candidate.finishReason().orElse(null));
if (candidate.content().isPresent()) {
this.content(candidate.content().get());
// A blocked or truncated candidate still carries a content object, so its presence says
// nothing about whether the model produced anything. Decide on the parts instead, and let a
// candidate that stopped normally with nothing to say remain a successful, 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);
Expand Down
132 changes: 132 additions & 0 deletions core/src/test/java/com/google/adk/models/LlmResponseTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.JsonBaseModel;
import com.google.common.collect.ImmutableList;
import com.google.genai.types.Candidate;
import com.google.genai.types.Content;
import com.google.genai.types.FinishReason;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.GenerateContentResponse;
import com.google.genai.types.GenerateContentResponseUsageMetadata;
import com.google.genai.types.Part;
import com.google.genai.types.Transcription;
Expand Down Expand Up @@ -250,4 +252,134 @@ public void testDeserialization_someOptionalFieldsMissingSomePresent()
assertThat(deserializedResponse.interrupted()).isEmpty();
assertThat(deserializedResponse.usageMetadata()).isEmpty();
}

private LlmResponse createFromCandidate(Candidate candidate) {
return LlmResponse.create(
GenerateContentResponse.builder().candidates(ImmutableList.of(candidate)).build());
}

/** The shape a blocked or budget-exhausted candidate arrives in: content present, no parts. */
private Content emptyModelContent() {
return Content.builder().role("model").build();
}

@Test
public void testCreate_partlessCandidateOutOfTokens_isReportedAsError() {
LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(emptyModelContent())
.finishReason(new FinishReason(FinishReason.Known.MAX_TOKENS))
.build());

assertThat(response.errorCode()).hasValue(new FinishReason(FinishReason.Known.MAX_TOKENS));
assertThat(response.finishReason()).hasValue(new FinishReason(FinishReason.Known.MAX_TOKENS));
assertThat(response.content()).isEmpty();
}

@Test
public void testCreate_partlessCandidateBlocked_reportsFinishMessageAsError() {
LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(emptyModelContent())
.finishReason(new FinishReason(FinishReason.Known.SAFETY))
.finishMessage("Blocked by safety filters.")
.build());

assertThat(response.errorCode()).hasValue(new FinishReason(FinishReason.Known.SAFETY));
assertThat(response.errorMessage()).hasValue("Blocked by safety filters.");
assertThat(response.content()).isEmpty();
}

@Test
public void testCreate_candidateWithEmptyPartsList_isReportedAsError() {
LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(Content.builder().role("model").parts(ImmutableList.of()).build())
.finishReason(new FinishReason(FinishReason.Known.MAX_TOKENS))
.build());

assertThat(response.errorCode()).hasValue(new FinishReason(FinishReason.Known.MAX_TOKENS));
assertThat(response.content()).isEmpty();
}

@Test
public void testCreate_candidateWithEmptyTextPart_isReportedAsSuccess() {
Content content =
Content.builder().role("model").parts(ImmutableList.of(Part.fromText(""))).build();

LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(content)
.finishReason(new FinishReason(FinishReason.Known.MAX_TOKENS))
.build());

assertThat(response.errorCode()).isEmpty();
assertThat(response.errorMessage()).isEmpty();
assertThat(response.content()).hasValue(content);
}

@Test
public void testCreate_partlessCandidateStoppedNormally_isReportedAsSuccess() {
Content content = emptyModelContent();

LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(content)
.finishReason(new FinishReason(FinishReason.Known.STOP))
.build());

assertThat(response.errorCode()).isEmpty();
assertThat(response.errorMessage()).isEmpty();
assertThat(response.content()).hasValue(content);
}

@Test
public void testCreate_truncatedCandidateWithText_isReportedAsSuccess() {
Content content = createSampleContent("The bicycle began as the");

LlmResponse response =
createFromCandidate(
Candidate.builder()
.content(content)
.finishReason(new FinishReason(FinishReason.Known.MAX_TOKENS))
.build());

assertThat(response.errorCode()).isEmpty();
assertThat(response.errorMessage()).isEmpty();
assertThat(response.content()).hasValue(content);
}

/**
* A candidate that produced nothing and reported no reason has no failure to raise, so no error
* code is set. The part-less content object is not carried over either — it holds nothing, and
* this pins the behavior so a later change to the predicate cannot alter it unnoticed.
*/
@Test
public void testCreate_partlessCandidateWithoutFinishReason_reportsNeitherContentNorError() {
LlmResponse response =
createFromCandidate(Candidate.builder().content(emptyModelContent()).build());

assertThat(response.errorCode()).isEmpty();
assertThat(response.errorMessage()).isEmpty();
assertThat(response.finishReason()).isEmpty();
assertThat(response.content()).isEmpty();
}

/** A turn that ended normally is not an error, even when the candidate carries no content. */
@Test
public void testCreate_contentlessCandidateStoppedNormally_isReportedAsSuccess() {
LlmResponse response =
createFromCandidate(
Candidate.builder().finishReason(new FinishReason(FinishReason.Known.STOP)).build());

assertThat(response.errorCode()).isEmpty();
assertThat(response.errorMessage()).isEmpty();
assertThat(response.finishReason()).hasValue(new FinishReason(FinishReason.Known.STOP));
assertThat(response.content()).isEmpty();
}
}