Skip to navigation

Recipe: Retry with handoff

Lesson
Level 2 · Intermediate · 10 min

When collection fails repeatedly — wrong format, no match in the database, ambiguous input — you need a reliable escalation path. This recipe uses a counter in conv.state to enforce a hard retry limit and hand off after N failures.

When to use this

Use this pattern when:

  • A client SLA specifies maximum retry attempts (e.g., “hand off after 3 failures”)
  • The flow collects high-stakes data where LLM-led retries are not predictable enough
  • Compliance requires that callers always reach a human if automation fails

The complete pattern

MAX_ATTEMPTS = 3
def collect_account_number(conv, flow, account_number: str) -> dict:
attempts = conv.state.get("account_attempts", 0) + 1
conv.state["account_attempts"] = attempts
# Validate format: 8 digits
if not account_number.isdigit() or len(account_number) != 8:
if attempts >= MAX_ATTEMPTS:
return {
"utterance": "I'm sorry, I'm having trouble with that account number. Let me connect you with someone who can help.",
"handoff": True,
}
# Return content so the LLM re-prompts naturally
return {
"content": f"Account number invalid (attempt {attempts} of {MAX_ATTEMPTS}). Ask the user to repeat their 8-digit account number."
}
# Validation passed
conv.state["account_number"] = account_number
conv.state["account_attempts"] = 0 # Reset for next use
flow.goto_step("verify_account")
return {"content": f"Account number {account_number} collected."}

How the counter works

Yes No Yes No Caller provides input Increment attempt counter Format valid? Store value, reset counter, next step attempts >= MAX? Return {utterance, handoff: True} Return content – LLM re-prompts

The counter is incremented before validation, not after. This ensures that even if something unexpected happens, the counter still advances and the caller is never trapped in an infinite loop.

Resetting the counter

Reset conv.state["account_attempts"] to 0 after a successful collection. This matters if the same flow is used for multiple collection steps — you don’t want attempt count from Step 1 bleeding into Step 2.

Separating the utterance from the function

For the handoff message, consider passing the utterance as a parameter so the LLM can generate a contextually appropriate goodbye, while the handoff itself is deterministic:

def escalate(conv, flow, utterance: str) -> dict:
"""
Call this when retries are exhausted or the user requests a human.
The LLM generates the utterance; the handoff is guaranteed.
"""
return {
"utterance": utterance,
"handoff": True,
}

Step prompt: “If the user requests a live agent or retries are exhausted, call escalate with an appropriate transition message.”

Key decisions

Returning content lets the LLM re-phrase the request naturally each time. Returning a hard-coded utterance would make every re-prompt sound identical, which feels robotic after the first failure.

Three attempts is a common SLA default. Adjust to match your client’s requirements. Store it as a constant at the top of the file so it’s easy to find and change.

If the same validation function is reused in multiple parts of the flow (or if the flow is called again in the same session), you don’t want previous failures counting against new attempts.

Check your understanding

  • It makes the math simpler
  • It ensures the counter always advances, preventing infinite loops even if the validation logic has a bug
  • It allows the counter to be reset on the next successful attempt
  • The LLM requires the counter to be set before it can generate a response

Answer: It ensures the counter always advances, preventing infinite loops even if the validation logic has a bug

Incrementing before validation is a safety measure. If validation throws an unexpected error or the function returns early, the counter still advances. This guarantees the caller can never be trapped in an infinite loop due to a code path the developer didn’t anticipate.