Skip to main content
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.
RunOutcome (this page) and AgentRunOutcome (see Agent Run Outcomes) are two different types with two different jobs:

Quick Start

1

Simple usage (bool)

Branch on outcome.succeeded — no try/except.
2

Async with a hard timeout

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

Full gateway/bot dispatch

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

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. The eight terminal reasons and the action each one signals: 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

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. The core classifies the response with classify_finish_reason(finish_reason, refusal): a truthy refusalrefused; finish_reason containing content_filtercontent_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. 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: Class methods: from_exception matches by type name: hardtimeout/runtimeout/budgettimeouthard_timeout; asyncio.CancelledError or supersed/interrupt/cancelledcancelled; abort/drain/shutdownaborted; 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(): 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:
Retry ladder — retry only on failed, escalate on hard_timeout, drop the rest:
Branch on a provider block/refusal/truncation:
Manual normalisation in a wrapper that owns its own try/except:

Best Practices

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.
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.
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.
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.

Agent Run Outcomes

The different AgentRunOutcome type — a typed status for validation and handoff results, not run-level dispatch.

Autonomy Loop

The autonomy loop’s own completion_reason — conceptually adjacent, but a separate mechanism.