Failure Reply

Every failed bot turn ends in a visible, actionable reply keyed by failure class — no more 'Error: 401' or silent downgrades.

Failure Reply is the failure-path counterpart of the Visible-Outcome Guarantee: every failed turn — expired auth, missing key, rate limit, timeout, budget, doom-loop, needs-help — reaches the user as a typed, actionable message telling them exactly what to do next.

python
from praisonaiagents import Agent

agent = Agent(
    name="Support Bot",
    instructions="Answer the user's question.",
)
# When this turn fails (expired credential, rate limit, budget, ...),
# every channel adapter delivers a visible, actionable reply keyed by the
# failure class — no more generic "Error: ..." strings.
agent.start("Summarise today's ops log.")
Diagram

Failure Reply Flow

success

failure

🤖 Agent Run

✅ / ⚠️ outcome

💬 Normal reply

🧭 render_failure_reply

📮 FailureReply
text + reason_code + retryable

📱 User

Quick Start

  1. It's on by default

    Every adapter calls render_failure_reply at the failure-handling seam — there’s no config knob to enable. A normal agent already benefits:

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Support Bot",
        instructions="Answer the user's question.",
    )
  2. Inspect a failure yourself

    Call render_failure_reply on any failed run — an AgentRunOutcome, a PraisonAIError, or a plain exception:

    python
    from praisonaiagents.bots import render_failure_reply
    from praisonaiagents.errors import PraisonAIConfigError
    
    reply = render_failure_reply(PraisonAIConfigError("OPENAI_API_KEY missing"))
    
    print(reply.text)         # "I couldn't complete that - a required credential ..."
    print(reply.reason_code)  # "missing_key"
    print(reply.retryable)    # False
  3. Record the reason code

    Log reply.reason_code for aggregation. Compare against the REASON_* module constants, not the string literals:

    python
    from praisonaiagents.bots import render_failure_reply, REASON_RATE_LIMIT
    
    reply = render_failure_reply(outcome)
    if reply.reason_code == REASON_RATE_LIMIT:
        logger.info("provider throttled this turn")

Which primitive?

classify_final (silence) and render_failure_reply (failure) are easy to confuse — one handles empty finals, the other handles failed runs.

Diagram

Agent returned NO_REPLY / SILENT

Agent returned blank / tool_calls placeholder

Agent returned real text

Run failed — exception or error outcome

Turn ended — which primitive?

What happened?

classify_final → silence

classify_final → empty → fallback

classify_final → text — deliver as-is

render_failure_reply → FailureReply


FailureReply

render_failure_reply(outcome) is a single, pure, channel-agnostic decision that maps any failed run into a typed FailureReply.

  • Import: from praisonaiagents.bots import FailureReply, render_failure_reply
  • Signature: render_failure_reply(outcome: AgentRunOutcome | PraisonAIError | Any) -> FailureReply
  • Zero deps. Inputs are duck-typed — an AgentRunOutcome, a PraisonAIError subclass, or any object exposing error_category / status. Anything else degrades to a generic (but still visible) unknown reply — it never raises.
FieldTypeDescription
textstrVisible, actionable message to deliver. Includes next-step copy keyed by failure class.
reason_codestrMachine-readable failure class — one of the REASON_* constants.
retryableboolWhether the failure is worth retrying without user intervention. True for rate-limit / overload / transient timeout, plus any input outcome whose is_retryable() was True (e.g. invalid_output).

Reason codes

Compare against the REASON_* module constants, not the string literals.

ConstantString valueUser-facing meaning
REASON_AUTH_EXPIRED"auth_expired"Credential expired — run praisonai onboard to re-authenticate.
REASON_AUTH_PERMANENT"auth_permanent"Credential rejected — check API key/permissions, then re-onboard.
REASON_MISSING_KEY"missing_key"Required credential missing — run praisonai onboard (or praisonai doctor).
REASON_RATE_LIMIT"rate_limit"Provider is rate-limiting requests — wait and resend.
REASON_OVERLOADED"overloaded"Provider temporarily overloaded — wait and resend.
REASON_TIMEOUT"timeout"Request timed out — resend or shorten.
REASON_BUDGET_EXHAUSTED"budget_exhausted"Hit budget limit — raise budget or narrow the task.
REASON_DOOM_LOOP"doom_loop"Detected repeating loop and halted — rephrase and resend.
REASON_NEEDS_HELP"needs_help"Agent needs clarification — provide the missing detail.
REASON_CANCELLED"cancelled"Request was cancelled before it finished.
REASON_CONTEXT_OVERFLOW"context_overflow"Conversation too long — start a fresh thread or shorten.
REASON_MODEL_NOT_FOUND"model_not_found"Configured model unavailable — check model name in config.
REASON_FORMAT_ERROR"format_error"Request or configuration was invalid — check input and resend.
REASON_UNKNOWN"unknown"Unexpected error — please resend.

REASON_RATE_LIMIT, REASON_OVERLOADED, and REASON_TIMEOUT are the always-retryable set. Other reasons still land as retryable=True when the source outcome’s is_retryable() was affirmative (e.g. invalid_output → format_error reason, but retryable).


Mapping tables

_ERROR_KIND_TO_REASON maps a PraisonAIError.error_category to a reason code:

error_categoryreason_code
authauth_expired
auth_permanentauth_permanent
rate_limitrate_limit
overloadedoverloaded
context_overflowcontext_overflow
idle_timeouttimeout
billingbudget_exhausted
model_not_foundmodel_not_found
format_errorformat_error
validationformat_error
unknown (or missing)unknown

Special case: if the error carries a config_key and the category is format_error, the reason becomes missing_key — steering the user to onboarding rather than re-auth.

_TERMINATION_TO_REASON maps an AgentRunOutcome.context["termination_reason"]:

termination valuereason_code
budget_exhaustedbudget_exhausted
doom_loopdoom_loop
needs_helpneeds_help
timeouttimeout
cancelledcancelled
interruptedcancelled

When neither map hits, the fallback ladder is error_category → status (timeout / cancelled / invalid_output → format_error) → unknown.


How It Works

The module never imports PraisonAIError / AgentRunOutcome at runtime — it reads fields by duck-typing, keeping it a zero-dependency leaf.

Diagram
sequenceDiagram
    participant Adapter as Channel Adapter
    participant Failure as render_failure_reply
    participant Outcome as AgentRunOutcome / PraisonAIError
    participant User

    Adapter->>Failure: render_failure_reply(outcome)
    Failure->>Outcome: read error_category / status / termination / remediation_hint
    Outcome-->>Failure: fields
    Failure->>Failure: pick reason code + copy (hint wins)
    Failure-->>Adapter: FailureReply(text, reason_code, retryable)
    Adapter-->>User: deliver text; log reason_code

A concrete remediation_hint on a PraisonAIError (e.g. PraisonAIConfigError) always wins over the static per-class copy. Retryability is preserved from the source outcome — invalid_output maps to format_error but stays retryable=True when outcome.is_retryable() was True.


User Interaction Flow

A user asks the assistant on Slack to summarise a report. The configured OpenAI key has expired. Instead of Error: 401 Unauthorized, the assistant replies with a concrete next step, and the adapter records reason_code="auth_expired" for the operator.

Diagram
render_failure_reply🤖 Assistant💬 User (Slack)render_failure_reply🤖 Assistant💬 User (Slack)“Summarise Q3 ops report.”run fails — 401 Unauthorizedrender_failure_reply(error)FailureReply(text=…, reason_code=”auth_expired”, retryable=False)“I couldn’t complete that — the credential has expired. Run praisonai onboard…”

Common Patterns

Offer a retry affordance only when it makes sense — inspect reply.retryable before showing a “Retry” button:

python
from praisonaiagents.bots import render_failure_reply

reply = render_failure_reply(outcome)
send(reply.text, show_retry=reply.retryable)

Aggregate by reason_code — because it’s a bounded, machine-readable enum, dashboards bucket failures by class (auth_expired, rate_limit, budget_exhausted, …) without regex on prose.

Custom remediation_hint for domain errors — a PraisonAIConfigError that sets remediation_hint="Run the migration first: alembic upgrade head." delivers that hint verbatim, keyed by missing_key.


Best Practices

Never hand-roll 'Error: …' strings

Route every failed turn through render_failure_reply so downgrade messaging stays consistent across channels.

Compare against REASON_* constants, not string literals

The reason codes are stable module constants for exactly this reason — import REASON_RATE_LIMIT rather than typing "rate_limit".

A hint is more precise than a category

When raising a custom error, set remediation_hint on the exception so the user sees the concrete next step rather than the generic per-class copy.

Trust reply.retryable

retryable mirrors the source outcome — do not re-derive retry policy from the text.


Related