Skip to content

fix(small-business-loan-agent): empty-string enum schema bug and LLM-judge multi-turn false-block - #2495

Open
ProDost wants to merge 3 commits into
google:mainfrom
ProDost:fix/loan-purpose-schema-and-judge-multiturn-grounding
Open

fix(small-business-loan-agent): empty-string enum schema bug and LLM-judge multi-turn false-block#2495
ProDost wants to merge 3 commits into
google:mainfrom
ProDost:fix/loan-purpose-schema-and-judge-multiturn-grounding

Conversation

@ProDost

@ProDost ProDost commented Aug 10, 2026

Copy link
Copy Markdown

Summary

Two bugs found while running small-business-loan-agent end-to-end against the Gemini API key surface:

  1. loan_purpose empty-string enum bug. LoanApplicationData.loan_purpose is a Literal[...] that includes "" as a member, so Pydantic's generated JSON schema puts an empty string in the enum. The current google-genai structured-output validation rejects that outright:

    400 INVALID_ARGUMENT: GenerateContentRequest.generation_config.response_schema
    .properties[loan_purpose].enum[6]: cannot be empty
    

    This makes DocumentExtractionAgent fail on every single request — the sample cannot process any document as shipped. Fixed by making the field Optional[Literal[...]] with default=None instead of including "" as a literal value. No behavior change downstream — the missing-field checks in state_callbacks.py treat None the same way they treated "".

  2. LLM-judge gate false-blocks on any multi-turn continuation. llm_judge_gate's trajectory check (_extract_tool_sequence_and_messages) only inspects tool calls made in the current invocation (session.events filtered by invocation_id). Since a loan application is processed across multiple user turns by design (submit → approve, or submit → pause-for-repair → resume), any turn that correctly skips a step completed in an earlier turn gets misjudged as "skipped mandatory steps," and the judge blocks the response — even though the underlying decision was correctly made and persisted to Firestore. I reproduced this on both:

    • the post-approval turn (only LoanDecisionAgent is called this turn — Extraction/Underwriting/Pricing ran in the prior turn)
    • the post-repair resume turn (skips the already-completed DocumentExtractionAgent)

    Fixed by adding _build_process_history(), which reads the persisted ProcessStateService state to reconstruct which ALL_STEPS entries were already completed before this turn, and passes that to the judge prompt as an explicit "Process History" section, distinct from "Tool Call Sequence (this turn only)". The judge prompt now explains the multi-turn nature of the workflow and only treats a step as genuinely missing when Process History confirms it wasn't done in any prior turn either.

Verification

Ran the full documented flow (happy path with approval, and pause/repair/resume) end-to-end against the Gemini API with real model calls:

  • Fix 1: DocumentExtractionAgent now successfully returns structured output instead of a 400 on every call.
  • Fix 2: both previously-blocking scenarios (approval-only turn, resume-after-repair turn) now get trajectory_correct=True, grounded_in_context=True from the judge, with no regression on the single-turn full-pipeline case (extraction → underwriting → pricing all in one turn, which already passed before this change).

Notes for reviewers

  • This PR is intentionally scoped to just these two bugs. I initially adapted this sample to run locally against a plain Gemini API key (instead of Vertex AI) and a local JSON state store (instead of Firestore) to get it running without GCP access — those changes are local-dev-only workarounds and are not included here, since they'd replace the sample's intended Vertex/Firestore architecture rather than fix a bug in it.
  • I have not yet signed the Google CLA — will do so via the CLA bot on this PR.

…ulti-turn false-block

Two bugs found while running the sample end-to-end against the Gemini API:

1. loan_purpose's Literal includes "" as a member so Pydantic emits an empty
   string in the generated JSON schema enum. The current google-genai
   structured-output validation rejects that outright:

     400 INVALID_ARGUMENT: GenerateContentRequest.generation_config.response_schema
     .properties[loan_purpose].enum[6]: cannot be empty

   Fixed by making the field Optional[Literal[...]] with default=None instead
   of including "" as a literal value. No behavior change for downstream code
   (missing-field checks treat None the same as they treated "").

2. llm_judge_gate's trajectory check only inspects tool calls made in the
   CURRENT invocation (session.events filtered by invocation_id). Since a loan
   application is processed across multiple user turns (submit -> approve,
   or submit -> pause-for-repair -> resume), any turn that correctly skips a
   step completed in an earlier turn gets misjudged as having "skipped
   mandatory steps" and the judge blocks the response — even though the
   underlying decision was correctly made and persisted. Reproduced on both
   the post-approval turn (only LoanDecisionAgent called) and the
   post-repair resume turn (skips the already-completed
   DocumentExtractionAgent).

   Fixed by adding _build_process_history(), which reads the persisted
   ProcessStateService state to reconstruct which ALL_STEPS entries were
   already completed before this turn, and passing that to the judge prompt
   as an explicit "Process History" section distinct from "Tool Call
   Sequence (this turn only)". The judge prompt now explains the multi-turn
   nature of the workflow and treats a step as a valid skip only when
   Process History confirms it was already done.

Verified against both scenarios (approval-only turn, resume-after-repair
turn) with a fresh request ID each time -- previously both were BLOCKED
(trajectory_correct=False), now both are APPROVED (trajectory_correct=True,
grounded_in_context=True), with no regression on the single-turn full
pipeline case.
@ProDost
ProDost requested a review from happyhuman as a code owner August 10, 2026 02:51
@google-cla

google-cla Bot commented Aug 10, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@happyhuman

Copy link
Copy Markdown
Collaborator

We have recently introduced a major restructuring of this repo and we no longer accept receipes under python/agents. Please take a look at the docs to learn more about the new changes and how you can update your code properly and resubmit it under contrib/python:
https://github.com/google/adk-samples/tree/main/docs

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Correctness review — 1 finding(s).

if not process_state:
return "No process history found for this request (first turn)."

steps = process_state.get("steps", {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the process state dictionary has a 'steps' key that is explicitly set to None, .get('steps', {}) returns None instead of the fallback empty dict. Use process_state.get('steps') or {} to prevent an AttributeError when calling .get() on line 134.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Security review — 2 finding(s).

return "No request ID available — cannot look up process history."

try:
process_state = ProcessStateService().get_process_status(request_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request_id parameter is used directly in database lookups without validation. If the identifier is user-controlled, this can lead to NoSQL injection or path traversal; please validate and sanitize the input to ensure it matches an expected pattern (e.g., alphanumeric or UUID).

try:
process_state = ProcessStateService().get_process_status(request_id)
except Exception as e:
return f"Process history unavailable (lookup error: {e})"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning raw exception messages can expose sensitive system details, internal paths, or database structures. Log the detailed error message internally and return a generic error description to prevent information leakage.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Correctness review — 1 finding(s).

steps = process_state.get("steps", {})
lines = []
for step_name in ProcessStateService.ALL_STEPS:
status = steps.get(step_name, {}).get("status", ProcessStateService.STATUS_NOT_STARTED)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a step's value in the steps dictionary is stored as null in Firestore, steps.get(step_name, {}) will return None instead of an empty dictionary. This will cause an AttributeError when .get("status") is called on it.

@happyhuman happyhuman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this recipe to contrib/python before submitting this PR. See docs/ for more info.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants