Gateway Abort and Per-Turn Timeout
Cancel a running agent turn or drain a whole session (turn + queued backlog) from the gateway with /stop, /stop all, /cancel, an abort WebSocket frame, or a per-turn timeout
Stop a hung or runaway gateway turn cooperatively using /stop, an abort WebSocket frame, or a configurable per-turn timeout — or stop the whole session (the running turn and everything queued behind it) with /stop all, /cancel, or abort carrying scope: "session".
from praisonaiagents import Agent
agent = Agent(name="gateway-agent", instructions="Answer questions.")The gateway relays a message_abort event to clients when a turn is cancelled, so a custom client can render the turn as “cancelled” instead of hanging or leaking a raw traceback.
graph LR
S1[/stop / stop] --> H[Session Handler]
S2[/stop all / /cancel] --> H
W1[abort scope=turn] --> H
W2[abort scope=session] --> H
T[per_turn_timeout] --> H
H -->|TURN| IC[Per-turn<br/>InterruptController]
H -->|SESSION| IC
H -->|SESSION| Q[Drain session inbox<br/>+ bump stop_epoch]
IC --> A[Agent turn]
Q --> C[pending_cancelled: N]
A --> Fin[turn_aborted: true]
Fin --> F[message_abort frame]
C --> R[reply payload]
F --> R
classDef in fill:#8B0000,stroke:#7C90A0,color:#fff
classDef proc fill:#189AB4,stroke:#7C90A0,color:#fff
classDef cfg fill:#F59E0B,stroke:#7C90A0,color:#fff
classDef out fill:#10B981,stroke:#7C90A0,color:#fff
class S1,S2,W1,W2,T in
class H,IC,A,Q proc
class Fin,F,C,R outPick the scope that matches what you want stopped — the running answer only, or that answer plus everything you queued behind it.
graph TB
Q{What do you want to stop?} --> A[Just the running answer]
Q --> B[Everything I typed — the running answer AND my queued follow-ups]
A --> A1[/stop · stop · abort frame default]
B --> B1[/stop all · /cancel · abort frame with scope=session]
classDef q fill:#8B0000,stroke:#7C90A0,color:#fff
classDef turn fill:#189AB4,stroke:#7C90A0,color:#fff
classDef session fill:#F59E0B,stroke:#7C90A0,color:#fff
class Q q
class A,A1 turn
class B,B1 sessionQuick Start
- Cancel from any chat client with /stop
Any chat client can cancel the in-flight turn by sending
/stop(or barestop, case-insensitive). Reply is either{"type":"aborted","session_id":"..."}or{"type":"no_active_turn","session_id":"..."}.json {"type": "message", "content": "/stop"} - Cancel the whole session with /stop all or /cancel
When the user typed several follow-ups and wants everything they queued cancelled too, a session-scoped stop cancels the in-flight turn and drains the session inbox.
json {"type": "message", "content": "/stop all"}Reply carries the drained count:
json { "type": "aborted", "session_id": "sess-1", "scope": "session", "turn_aborted": true, "pending_cancelled": 2 }/cancelis an alias for/stop all. - Session-scope the abort WebSocket frame
The same
abortframe drains the backlog when you addscope: "session":json {"type": "abort", "reason": "user", "scope": "session"}scopeis optional; absent or unknown falls back toTURN. - Cancel with an abort WebSocket frame
Send an
abortframe — requires thewriteoperator scope (same as sending a message as the agent):json {"type": "abort", "reason": "user"}reasonis optional (defaults to"user") and is echoed back in the terminal turn message. - Set a per-turn wall-clock ceiling
Set a wall-clock ceiling per turn in the gateway config.
0(default) disables the timeout — behaviour is byte-identical to earlier releases.yaml gateway: per_turn_timeout: 60.0 # cancel any turn that runs longer than 60sA timed-out turn’s terminal response is
"Turn cancelled: exceeded per-turn timeout.". - Render the message_abort event on the client
When the gateway cancels a turn it emits a
message_abortevent; treat it as a terminal outcome for that turn.python from praisonaiagents.gateway.protocols import EventType if event["type"] == EventType.MESSAGE_ABORT.value: # "message_abort" mark_turn_cancelled(event)
Config: gateway.per_turn_timeout
Wall-clock ceiling for a single agent turn. When exceeded, the turn is cancelled cooperatively (via its InterruptController) and, if it does not unwind within a bounded grace window, the driving task is cancelled hard.
| Field | Type | Default | Description |
|---|---|---|---|
per_turn_timeout | float (seconds) | 0.0 | Wall-clock ceiling per turn. 0 disables the timeout entirely. A negative value raises ValueError at config load. |
Three ways to set it:
from praisonaiagents.gateway.config import GatewayConfig
config = GatewayConfig(per_turn_timeout=60.0)# multi-channel gateway config
gateway:
per_turn_timeout: 60.0# via the bot pydantic schema — same key
from praisonai_bot.bots._config_schema import GatewayServerSchema
GatewayServerSchema(per_turn_timeout=60.0)Stop Scopes
Two scopes. TURN stops the current answer. SESSION stops the current answer and drains everything the user queued behind it.
| Scope | Chat command | Abort frame | What it stops |
|---|---|---|---|
TURN (default, back-compat) | /stop, stop | {"type":"abort"} or {"scope":"turn"} | The in-flight turn only. Backlog preserved. |
SESSION | /stop all, /cancel | {"type":"abort","scope":"session"} | The in-flight turn and every queued-but-unstarted message on the session inbox. |
Both StopScope and StopResult are exported from the public gateway surface:
from praisonaiagents.gateway import StopScope, StopResultStopResult.to_dict() serialises to exactly {"scope": ..., "turn_aborted": ..., "pending_cancelled": ...}. pending_cancelled is the count of queued messages a SESSION stop drained — a visible, intentional non-outcome, never a silent drop — and is always 0 for a TURN stop.
Frame shapes
Client → Gateway
Abort frame — requires the write operator scope:
{"type": "abort", "reason": "user", "scope": "session"}message_abort is accepted as an alias for type.
| Field | Type | Default | Description | |
|---|---|---|---|---|
reason | string | "user" | Echoed back in the terminal turn message. | |
scope | "turn" \ | "session" | "turn" | "session" also drains the queued backlog; absent/unknown → "turn". |
Portable stop command — any client already joined to a session can cancel via the message channel:
| Command | Scope | What it stops |
|---|---|---|
/stop, stop | TURN | The in-flight turn only. Backlog preserved. |
/stop all | SESSION | The in-flight turn and the queued backlog. |
/cancel | SESSION | Alias for /stop all. |
{"type": "message", "content": "/stop all"}Matching is case-insensitive and whitespace-trimmed.
Gateway → Client (reply to abort)
The reply carries the StopResult fields alongside type and session_id.
| Reply | When | Extra fields |
|---|---|---|
{"type": "aborted", ...} | A turn was active or the SESSION drain cancelled queued messages. | scope, turn_aborted (bool), pending_cancelled (int ≥ 0) |
{"type": "no_active_turn", ...} | No turn was running and (for SESSION) the inbox was empty. | scope, turn_aborted: false, pending_cancelled: 0 |
{"type": "error", "code": "insufficient_scope", "message": "insufficient scope", "required_scope": "write"} | The client is missing the WRITE scope. | (unchanged) |
{"type": "error", "message": "Not joined to any session"} | The client hasn’t joined a session. | (unchanged) |
type is "aborted" when either turn_aborted is true or pending_cancelled > 0; otherwise "no_active_turn". A SESSION stop with no active turn but 3 queued messages still replies "aborted" with pending_cancelled: 3 — the queued work counts as work stopped.
Turn-scoped reply:
{
"type": "aborted",
"session_id": "sess-1",
"scope": "turn",
"turn_aborted": true,
"pending_cancelled": 0
}Session-scoped reply:
{
"type": "aborted",
"session_id": "sess-1",
"scope": "session",
"turn_aborted": true,
"pending_cancelled": 3
}Terminal turn message
The turn’s own response (delivered on the normal message channel) is a typed string:
| Reason | Response text |
|---|---|
| Per-turn timeout expired | "Turn cancelled: exceeded per-turn timeout." |
User abort (chat /stop or WS abort) | "Turn cancelled: user." |
| Any other reason string | "Turn cancelled: <reason>." |
The message_abort event
EventType.MESSAGE_ABORT is defined in praisonaiagents/gateway/protocols.py and serialises to the wire string "message_abort".
| Enum member | Wire value | Meaning |
|---|---|---|
EventType.MESSAGE_ABORT | "message_abort" | The currently-driving turn on the session was cancelled; the client should treat it as terminal. |
How It Works
Every turn runs through a per-turn InterruptController, so a /stop, an abort frame, or a timeout on one session never touches another session’s turn.
Why cancellation is cooperative
Cancellation always requests interruption via the turn’s InterruptController first, so the agent stops at its next safe checkpoint and partial output is preserved. A hard task.cancel() fires only if the turn hasn’t unwound within 5 seconds (_ABORT_GRACE_SECONDS) — this bounds the case where a sync agent.chat running in a worker thread cannot be force-killed but must not keep mutating shared state after the queue advances.
One controller per turn — never shared
Each turn creates its own InterruptController and passes it as cancel_token= into arun / achat / chat. Overlapping turns from different sessions never share a controller, so one session’s /stop never interrupts another’s turn — even when they share the same Agent instance.
Fallback for older entry points
Agent entry points that predate cancel_token= fall back to stamping agent.interrupt_controller on the shared attribute for the turn’s duration. This is best-effort and non-isolated — prefer keeping agents on the current SDK so per-turn isolation applies.
Timeout is opt-in
per_turn_timeout defaults to 0.0, which means no wall-clock cancellation. Every turn runs to completion, exactly as in releases before #3472 — enabling the timeout is opt-in.
Default-path cooperative cancel needed PR #4301
Prior to PraisonAI PR #4301, the gateway’s cooperative cancel reached the agent but the agent dropped the token before OpenAIClient on the default sync path. So the gateway’s _ABORT_GRACE_SECONDS = 5.0 hard-cancel was the only real halt on plain Agent(llm="gpt-4o-mini"). Upgrade to get cooperative interruption between tool iterations, not just after the 5-second grace.
Race-window: the dequeue-to-registration gap
The queue worker dequeues a message before registering it as an active turn. A SESSION stop bumps a per-session _stop_epoch counter; the worker snapshots the epoch at dequeue and re-checks it just before dispatch. A mismatch means a stop landed in the gap — the message is dropped instead of executed, and it is counted toward pending_cancelled so the reported outcome is never a silent loss. See praisonai_bot/gateway/server.py (search dequeued_stop_epoch).
Common Patterns
Real scenarios this page lets you pattern-match to:
| Scenario | Trigger | Outcome |
|---|---|---|
| Telegram user stuck in a slow tool call | User types /stop | Controller fires, turn unwinds in ~5s, user sees "Turn cancelled: user." |
| Operator dashboard spots a runaway turn | WS client (WRITE scope) sends {"type": "abort", "reason": "operator-timeout"} | Gateway replies {"type": "aborted", "session_id": "..."}; next turn resumes |
| Ops set a hard ceiling | gateway.per_turn_timeout: 120.0 | A wedged turn auto-cancels after 2 minutes with "Turn cancelled: exceeded per-turn timeout." |
One Agent serves ten users | User A hits /stop | User B’s parallel turn keeps running — controllers are per-turn, not per-agent |
| User fires 3 follow-ups, then wants everything stopped | User types /stop all (or /cancel) | Current turn cancels, 3 queued messages drop, reply shows pending_cancelled: 3 |
| Operator dashboard revokes an entire session’s plan | WS {"type":"abort","scope":"session","reason":"operator-timeout"} | Same drain, plus the explicit reason echoed in the terminal turn message |
| User cancels while queue is running but no active turn | /stop all | turn_aborted: false, pending_cancelled: N, backlog is now empty |