Run Outcome

Get a typed terminal outcome from every agent run — no exception juggling

Add return_outcome=True to agent.run() or agent.astart() and the call returns a small frozen RunOutcome telling you exactly how the run ended — instead of raising and making you catch every exception type.

Diagram

Run Outcome

No

Yes

🤖 agent.run

return_outcome=True?

📄 str response

📋 RunOutcome

✅ completed

⏰ hard_timeout

🛑 cancelled

🚫 aborted

❌ failed

🛡️ content_filtered

🚫 refused

✂️ length_truncated

Quick Start

  1. Simple usage (bool)

    Branch on outcome.succeeded — no try/except.

    python
    from praisonaiagents import Agent, RunOutcome
    
    agent = Agent(name="Assistant", instructions="Be helpful")
    
    outcome = agent.run("Draft a reply", return_outcome=True)
    
    if outcome.succeeded:
        print(outcome.output)
    else:
        print(f"{outcome.reason}: {outcome.error}")
  2. Async with a hard timeout

    astart() accepts a run-level timeout (seconds). When it elapses, reason is hard_timeout.

    python
    import asyncio
    from praisonaiagents import Agent, RunOutcome
    
    agent = Agent(name="Assistant", instructions="Be helpful")
    
    async def main():
        outcome = await agent.astart("Draft a reply", return_outcome=True, timeout=30)
        print(outcome.reason)
    
    asyncio.run(main())
  3. Full gateway/bot dispatch

    The headline use case — one branch per terminal reason, no exception chains.

    python
    import asyncio
    from praisonaiagents import Agent, RunOutcome
    
    agent = Agent(name="Assistant", instructions="Be helpful")
    
    async def handle(prompt, run_timeout=30):
        outcome = await agent.astart(prompt, return_outcome=True, timeout=run_timeout)
    
        if outcome.reason == "completed":
            await deliver(outcome.output)
        elif outcome.reason in ("cancelled", "aborted"):
            pass                              # never DLQ, never replay
        elif outcome.reason == "hard_timeout":
            await notify_timeout()
        else:                                 # "failed"
            dlq.enqueue(error=outcome.error)

How It Works

A host receives a request, dispatches it to the agent with return_outcome=True, and picks a channel action from the single reason field.

Diagram
AgentBot / GatewayUserAgentBot / GatewayUseralt[reason == completed][reason == hard_timeout][reason == cancelled /aborted][reason == failed]Send requestastart(prompt, return_outcome=True, timeout=30)RunOutcome(reason=…)Deliver outputNotify timeout (do not replay)Silently drop (no DLQ, no re-deliver)DLQ with error

The eight terminal reasons and the action each one signals:

reasonMeaningTypical host action
completedRun ended normally with outputDeliver outcome.output
hard_timeoutThe run-level budget on astart(..., timeout=...) elapsedNotify user of timeout; do not replay
cancelledExternal asyncio.CancelledError or a domain supersede/interrupt error surfacedSilently drop; never DLQ; never re-deliver
abortedDrain / shutdown-named error (host is shutting down)Silently drop
failedAnything else — including a nested inner asyncio.TimeoutErrorDLQ with outcome.error
content_filteredProvider’s content filter blocked the responseRephrase; adjust provider safety settings
refusedModel declined to answer (safety refusal)Rephrase or try a different model
length_truncatedResponse hit the model’s output length limitRe-run with a higher max_tokens

By default the agent attempts up to 3 auto-continuations before surfacing length_truncated — see Output Continuation. Set ExecutionConfig(output_continuation=False) to surface it on the first truncation.

The last three — content_filtered, refused, length_truncated — come from the LLM’s finish_reason / refusal signal on the response, not from a raised exception. See Provider block outcomes.

Precedence is sticky: a hard_timeout is never silently downgraded by a later cleanup error. A provider block/refusal/truncation outranks failed (so an actionable reason is not masked by a generic empty-result failure) but stays below aborted / cancelled / hard_timeout (host-level lifecycle signals win).


Which reason applies when

Diagram

Yes

No

Yes

No

Yes

No

Yes

No

content_filter

refusal set

length / max_tokens

No

🤖 Run ends

Finished with output?

✅ completed

Run budget elapsed?

⏰ hard_timeout

Superseded / interrupted / cancelled?

🛑 cancelled

Host draining / shutting down?

🚫 aborted

Model produced no usable text?

🛡️ content_filtered

🚫 refused

✂️ length_truncated

❌ failed

Nested inner asyncio.TimeoutError

A nested inner asyncio.TimeoutError (a tool or handoff that exhausted its own budget) resolves to failed, not hard_timeout. Only the run-level timeout budget produces hard_timeout.


Provider block outcomes

Three reasons come from the LLM’s own response signal instead of an exception — so a run that ends without a usable answer is no longer collapsed into a silent empty completed or a generic failed.

Diagram

Provider Block Outcomes

finish_reason=content_filter

message.refusal set

finish_reason=length

stop / tool_calls

🤖 agent.run / astart

🧠 LLM call

Provider signal?

🛡️ content_filtered

🚫 refused

✂️ length_truncated

✅ completed

The core classifies the response with classify_finish_reason(finish_reason, refusal): a truthy refusal → refused; finish_reason containing content_filter → content_filtered; length / max_tokens / truncat → length_truncated; a normal stop / tool_calls / unknown → no block. The specific reason surfaces on both RunOutcome.reason and agent.last_stop_reason.

Precedence ladder

A provider block sits above failed but below the host-level lifecycle signals — so an actionable reason is never masked, yet a shutdown/cancel still wins.

Diagram

Terminal Reason Precedence (sticky, higher wins)

completed
0

failed
1

content_filtered / refused / length_truncated
2

aborted
3

cancelled
4

hard_timeout
5

A max_steps truncation is sticky and is never downgraded by a later provider block — a provider block only wins when the run did not already hit the step limit.


Configuration Options

RunOutcome is a frozen dataclass. Every field:

AttributeTypeDefaultDescription
reasonTerminalReasonrequiredOne of `”completed” \“hard_timeout” \“cancelled” \“aborted” \“failed” \“content_filtered” \“refused” \“length_truncated”`
outputOptional[str]NonePartial or final text, if any
errorOptional[str]NoneRedacted error message when reason == "failed"
succeededbool (property)—True only when reason == "completed"

Class methods:

MethodSignaturePurpose
RunOutcome.completed(output: Optional[str] = None) -> RunOutcomeFactory for the success case
RunOutcome.from_exception(exc: BaseException, output: Optional[str] = None) -> RunOutcomeNormalise a raised exception into a canonical outcome

from_exception matches by type name: hardtimeout/runtimeout/budgettimeout → hard_timeout; asyncio.CancelledError or supersed/interrupt/cancelled → cancelled; abort/drain/shutdown → aborted; everything else → failed with error=str(exc).

TerminalReason is a Literal["completed", "hard_timeout", "cancelled", "aborted", "failed", "content_filtered", "refused", "length_truncated"] (with a str fallback on Python <3.8), exported from praisonaiagents.agent. The tuple PROVIDER_BLOCK_REASONS = ("content_filtered", "refused", "length_truncated") groups the three provider-block reasons.

New kwargs on run() and astart():

KwargTypeDefaultApplies toDescription
return_outcomeboolFalserun(), astart()Return a RunOutcome instead of the raw string; the call never raises for known terminal states.
timeoutfloat (seconds)Noneastart() when return_outcome=TrueHard run-level budget. When it expires, the outcome is hard_timeout. Only this budget produces hard_timeout; a nested inner asyncio.TimeoutError maps to failed.

Verified behaviour:

  • outcome.reason == "hard_timeout" is authoritative and sticky — never downgraded.
  • A nested inner asyncio.TimeoutError resolves to failed, not hard_timeout.
  • External asyncio.CancelledError on the enclosing task is re-raised — host shutdown is honoured, not swallowed.
  • RunOutcome is frozen — assigning to .reason raises FrozenInstanceError.

Common Patterns

Simple success/failure branch:

python
outcome = agent.run(prompt, return_outcome=True)
if outcome.succeeded:
    send(outcome.output)
else:
    log(outcome.reason)

Retry ladder — retry only on failed, escalate on hard_timeout, drop the rest:

python
outcome = await agent.astart(prompt, return_outcome=True, timeout=30)
if outcome.reason == "failed":
    retry(prompt)
elif outcome.reason == "hard_timeout":
    escalate()
elif outcome.reason in ("cancelled", "aborted"):
    pass  # do not retry

Branch on a provider block/refusal/truncation:

python
outcome = agent.run(prompt, return_outcome=True)
if outcome.reason == "content_filtered":
    notify_user("The provider blocked that request. Try rephrasing.")
elif outcome.reason == "refused":
    notify_user("The model declined. Try a different phrasing or model.")
elif outcome.reason == "length_truncated":
    notify_user("Response was cut off — please increase max_tokens.")

Manual normalisation in a wrapper that owns its own try/except:

python
from praisonaiagents import RunOutcome

try:
    text = my_custom_call(prompt)
    outcome = RunOutcome.completed(text)
except BaseException as exc:
    outcome = RunOutcome.from_exception(exc)

Best Practices

Never assume timeout means retry

A hard_timeout is the run-level budget — the run didn’t finish. A failed carrying a nested inner asyncio.TimeoutError is a tool/handoff timeout inside the run; only the second one is often safely retryable.

Don't swallow cancelled

A try/except around astart() without return_outcome=True can absorb host-shutdown cancellation. Use return_outcome=True and check reason instead — external asyncio.CancelledError is re-raised, not hidden.

Route on reason, not on exception identity

The point of RunOutcome is to remove isinstance chains from your gateway. If you’re still catching exceptions, you’ve lost the benefit — branch on outcome.reason.

Treat RunOutcome as immutable

It’s a frozen dataclass. Build a new one with RunOutcome.completed(...) or RunOutcome.from_exception(...) instead of mutating fields.


Migration

The default behaviour of run() / astart() is unchanged — they still return a str and raise on error, so existing code needs no change. Opt in with one flag to collapse three catches into one branch.

python
# Before — three catches, order matters
try:
    text = await agent.astart(prompt)
    await deliver(text)
except asyncio.TimeoutError:
    await notify_timeout()
except asyncio.CancelledError:
    raise
except Exception as exc:
    dlq.enqueue(error=str(exc))

# After — one branch
outcome = await agent.astart(prompt, return_outcome=True, timeout=run_timeout)
match outcome.reason:
    case "completed":     await deliver(outcome.output)
    case "hard_timeout":  await notify_timeout()
    case "cancelled" | "aborted": pass
    case "failed":        dlq.enqueue(error=outcome.error)

Related