fix: cancel/time-bound in-flight gateway agent turns - #3472
Conversation
Wire per-turn abort + timeout into the gateway so a runaway turn can no longer wedge the serial per-session queue: - Advertise the `abort` method (+ `message_abort` event) in the hello capability handshake instead of the "abort not implemented" placeholder. - Handle `abort`/`message_abort` WS frames and a portable `/stop` chat command; both drive the existing InterruptController and cancel the driving asyncio task via a new `_active_turns` registry. - Run each turn under an optional `gateway.per_turn_timeout` (YAML/config knob, 0 = disabled) via `_drive_turn`, normalising cancelled/timed-out turns to a typed terminal message instead of leaving them hanging. Reuses core's InterruptController (already checked by the agent run loop) and `agent.interrupt_controller`; no new agent params. Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
|
@coderabbitai review |
|
/review |
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
✅ Action performedReview finished.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe gateway adds a configurable per-turn timeout, tracks active turns, advertises abort support, handles ChangesGateway turn control
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Gateway
participant SessionQueue
participant InterruptController
participant AgentTurn
Client->>Gateway: Send abort request
Gateway->>InterruptController: Request interruption
Gateway->>AgentTurn: Cancel driving task
SessionQueue->>AgentTurn: Finalize cancelled turn
AgentTurn-->>Gateway: Return terminal response
Gateway-->>Client: Send aborted outcome
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThe PR adds cancellable and time-bounded gateway turns.
Confidence Score: 3/5The PR is not yet safe to merge because sync executor turns can outlive cancellation, and the compatibility fallback can duplicate a turn while restoring shared cancellation state. A timed-out executor-backed chat leaves its worker running while the session queue advances, and an internal TypeError from an entry point causes the gateway to retry the full turn after assigning its controller to the shared agent. Files Needing Attention: src/praisonai-bot/praisonai_bot/gateway/server.py
|
| Filename | Overview |
|---|---|
| src/praisonai-bot/praisonai_bot/gateway/server.py | Adds abort and timeout orchestration, but unrestricted TypeError fallbacks can rerun turns and the executor cancellation path still advances before the worker stops. |
| src/praisonai-bot/praisonai_bot/bots/_config_schema.py | Extends validated gateway YAML with a non-negative per-turn timeout field. |
| src/praisonai-agents/praisonaiagents/gateway/config.py | Adds validated parsing and serialization for the per-turn timeout setting. |
Sequence Diagram
sequenceDiagram
participant Client
participant Gateway
participant Queue
participant Agent
Client->>Gateway: message
Gateway->>Queue: enqueue turn
Queue->>Agent: run with cancel_token
alt abort or timeout
Gateway->>Agent: request interruption
Gateway->>Queue: await bounded settlement
end
Agent-->>Queue: result or cancellation
Queue-->>Client: terminal response
Reviews (2): Last reviewed commit: "fix: reach + isolate + settle gateway pe..." | Re-trigger Greptile
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. Phase 1: Review per AGENTS.md
Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task —— View job Final Architecture Review — PR #3472 ✅ Fixes pushedReviewed against AGENTS.md and validated all three P1 issues raised by Greptile. All three were valid and are now fixed on this branch ( Phase 1 — Architecture (per AGENTS.md)
Phase 2 — Fixes implemented
Verification
Files modified
Skipped: no core-SDK changes needed beyond the existing config field; no Phase 3 — Verdict✅ Approve. All P1 blocking issues resolved; architecture is lightweight, protocol-driven, and backward-compatible. Note: I cannot submit a formal GitHub approval (permissions), so this comment is the review sign-off. |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/praisonai-bot/praisonai_bot/gateway/server.py (1)
7519-7535: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMajor:
per_turn_timeoutis never wired fromgateway.yamlintoself.config, so the feature is inert for the primary runtime path.
start_with_configbuildsself.configin__init__asGatewayConfig(host=host, port=port)(defaultper_turn_timeout=0.0) and only patches specific keys from the parsedgw_cfgdict onto it here (max_buffered_bytes,max_queued_frames,notify_on_undelivered,undelivered_template, etc.).per_turn_timeoutis not among them, even though_run_session_queue(line 3195) readsgetattr(self.config, "per_turn_timeout", 0.0).MultiChannelGatewayConfig.from_dictinconfig.pycorrectly parsesper_turn_timeoutfrom YAML, but that code path is not used bystart_with_config(which loads a raw dict viaload_gateway_config, notMultiChannelGatewayConfig.from_dict). As a result, any operator settinggateway.per_turn_timeoutingateway.yamlwill see it silently ignored — timeouts stay permanently disabled.🐛 Suggested fix
if "max_queued_frames" in gw_cfg: self.config.max_queued_frames = int(gw_cfg["max_queued_frames"]) + if "per_turn_timeout" in gw_cfg: + self.config.per_turn_timeout = float(gw_cfg["per_turn_timeout"] or 0.0)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/praisonai-bot/praisonai_bot/gateway/server.py` around lines 7519 - 7535, Wire the validated gateway configuration’s per_turn_timeout into self.config within start_with_config, alongside the existing max_buffered_bytes and max_queued_frames assignments. Read the value from gw_cfg, convert it to the expected numeric type, and preserve the default behavior when the key is absent so _run_session_queue receives the configured timeout.
🧹 Nitpick comments (1)
src/praisonai-bot/praisonai_bot/gateway/server.py (1)
3102-3124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMinor: silent exception swallowing in
_abort_active_turnwithout logging.Both
try/except Exception: passblocks (controller.request, task.cancel) swallow any failure silently, per Ruff S110/BLE001. If either fails (e.g. a buggy interrupt controller or a task in an unexpected state), an abort request would silently no-op with no trace to debug. Consider at least alogger.debug(...)on the except branches.As per static analysis hints (Ruff S110/BLE001: "`try`-`except`-`pass` detected, consider logging the exception").🩹 Suggested fix
try: if controller is not None: controller.request(reason) - except Exception: - pass + except Exception as e: + logger.debug("Failed to request interrupt for session %s: %s", session_id, e) try: if task is not None and not task.done(): task.cancel() - except Exception: - pass + except Exception as e: + logger.debug("Failed to cancel turn task for session %s: %s", session_id, e)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/praisonai-bot/praisonai_bot/gateway/server.py` around lines 3102 - 3124, Update both exception handlers in _abort_active_turn to log the caught exception at debug level, including whether controller.request or task.cancel failed and the session context, while preserving the current best-effort abort behavior and return value.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/praisonai-bot/praisonai_bot/gateway/server.py`:
- Around line 3126-3167: Update the asyncio.CancelledError handler in
_drive_turn to distinguish external cancellation from requested turn
interruption: if controller.is_set() is false, re-raise the cancellation;
otherwise preserve the existing _finalise_aborted_turn behavior using the
controller reason.
- Around line 3077-3100: Update _dispatch_agent_turn to avoid assigning the
per-turn interrupt controller to the shared agent.interrupt_controller. Use a
per-turn agent clone or explicit per-turn state so concurrent sessions retain
independent controllers, while preserving native async dispatch and the
synchronous agent.chat executor fallback.
---
Outside diff comments:
In `@src/praisonai-bot/praisonai_bot/gateway/server.py`:
- Around line 7519-7535: Wire the validated gateway configuration’s
per_turn_timeout into self.config within start_with_config, alongside the
existing max_buffered_bytes and max_queued_frames assignments. Read the value
from gw_cfg, convert it to the expected numeric type, and preserve the default
behavior when the key is absent so _run_session_queue receives the configured
timeout.
---
Nitpick comments:
In `@src/praisonai-bot/praisonai_bot/gateway/server.py`:
- Around line 3102-3124: Update both exception handlers in _abort_active_turn to
log the caught exception at debug level, including whether controller.request or
task.cancel failed and the session context, while preserving the current
best-effort abort behavior and return value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b1484562-170c-47a5-9b30-a9eea64805d0
📒 Files selected for processing (2)
src/praisonai-agents/praisonaiagents/gateway/config.pysrc/praisonai-bot/praisonai_bot/gateway/server.py
| async def _dispatch_agent_turn( | ||
| agent: Any, content: str, interrupt: Any = None | ||
| ) -> Any: | ||
| """Execute a single agent turn. | ||
|
|
||
| Prefers the agent's native async entry point (``arun``/``achat``) so | ||
| turns run directly on the event loop, giving native asyncio concurrency, | ||
| cleaner cancellation/timeout and true async streaming. Falls back to | ||
| offloading the synchronous ``chat`` onto the default thread pool only | ||
| when no async entry point is available (sync-only agents). | ||
|
|
||
| Issue #3467: when ``interrupt`` is supplied it is stamped onto the agent | ||
| (``agent.interrupt_controller``) so the agent's run loop cooperatively | ||
| stops on the next checkpoint; the caller also cancels the driving task | ||
| for a hard stop when the agent does not yield promptly. | ||
| """ | ||
| if interrupt is not None and hasattr(agent, "interrupt_controller"): | ||
| agent.interrupt_controller = interrupt | ||
| for _name in ("arun", "achat"): | ||
| _fn = getattr(agent, _name, None) | ||
| if _fn is not None and asyncio.iscoroutinefunction(_fn): | ||
| return await _fn(content) | ||
| loop = asyncio.get_running_loop() | ||
| return await loop.run_in_executor(None, agent.chat, content) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
🧩 Analysis chain
🌐 Web query:
Does praisonaiagents Agent.arun/achat accept a cancel_token or interrupt parameter?
💡 Result:
No, the praisonaiagents Agent.arun and Agent.achat methods do not explicitly accept a cancel_token or interrupt parameter in their function signatures [1][2]. While the Agent class does maintain an internal interrupt_controller (an InterruptController instance) intended for cooperative cancellation [3], this controller is typically initialized at the agent level rather than being passed as an argument to individual execution methods like arun() or achat() [2][3]. The public method signatures for these functions are focused on prompt processing and task configuration (e.g., temperature, tools, output_json) rather than low-level interrupt or cancellation tokens [1][2].
Citations:
- 1: https://docs.praison.ai/docs/sdk/reference/praisonaiagents/functions/Agent-arun
- 2: https://docs.praison.ai/docs/sdk/reference/praisonaiagents/functions/Agent-achat
- 3: https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/praisonaiagents/agent/agent.py
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate relevant files =="
git ls-files | rg '(^src/praisonai-agents/praisonaiagents/agent/.*\.py$|^src/praisonai-agents/.*/.*\.py$|^src/praisonai-bot/praisonai_bot/gateway/server.py$|^src/praisonai-agents/praisonaiagents/agent/chat_mixin\.py$)' || true
echo
echo "== server.py relevant sections =="
wc -l src/praisonai-bot/praisonai_bot/gateway/server.py
sed -n '3050,3125p' src/praisonai-bot/praisonai_bot/gateway/server.py
echo
sed -n '7485,7580p' src/praisonai-bot/praisonai_bot/gateway/server.py
echo
echo "== agent-related signatures/usages =="
rg -n "class Agent|def arun|def achat|def chat|interrupt_controller|cancel_token|clone_for_channel" src/praisonai-agents src/praisonai-bot/praisonai_bot/gateway/server.py | head -n 200
echo
echo "== relevant agent.py sections =="
# locate agent.py without assuming path
for f in $(git ls-files | rg '/praisonaiagents/agent/agent\.py$'); do
echo "--- $f"
wc -l "$f"
sed -n '1,220p' "$f"
sed -n '350,600p' "$f"
done
echo
echo "== chat_mixin.py relevant sections =="
f=$(git ls-files | rg '/chat_mixin\.py$' | head -n1 || true)
if [ -n "$f" ]; then
echo "--- $f"
wc -l "$f"
sed -n '1,260p' "$f"
fiRepository: MervinPraison/PraisonAI
Length of output: 50380
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== server dispatch/agent resolution =="
wc -l src/praisonai-bot/praisonai_bot/gateway/server.py
sed -n '3000,3105p' src/praisonai-bot/praisonai_bot/gateway/server.py
rg -n "_process_agent_message|_dispatch_agent_turn|self\\._agents\\.get|agent\\.interrupt_controller|agent\\.clone_for_channel" src/praisonai-bot/praisonai_bot/gateway/server.py | head -n 80
echo
echo "== agent.py class/init/run/chat/signatures =="
wc -l src/praisonai-agents/praisonaiagents/agent/agent.py
rg -n "class Agent|def run\\(|def run_async\\(|def chat\\(|def arun\\(|def achat\\(|def clone\\(|def clone_for_channel\\(|interrupt_controller|cancel_token" src/praisonai-agents/praisonaiagents/agent/agent.py src/praisonai-agents/praisonaiagents/agent/interrupt.py src/praisonai-agents/praisonaiagents/agent/chat_mixin.py | head -n 160
echo
echo "== focused agent.py sections =="
sed -n '1,360p' src/praisonai-agents/praisonaiagents/agent/agent.py
sed -n '360,740p' src/src/praisonai-agents/praisonaiagents/agent/agent.py 2>/dev/null || sed -n '360,740p' src/praisonai-agents/praisonaiagents/agent/agent.py
sed -n '740,1140p' src/praisonai-agents/praisonaiagents/agent/agent.py
echo
echo "== focused mixin/interrupt sections =="
sed -n '1,320p' src/praisonai-agents/praisonaiagents/agent/chat_mixin.py
sed -n '1,220p' src/praisonai-agents/praisonaiagents/agent/interrupt.pyRepository: MervinPraison/PraisonAI
Length of output: 50381
Avoid mutating the shared agent’s interrupt_controller.
_dispatch_agent_turn stamps the per-turn InterruptController onto agent.interrupt_controller, but _process_agent_message reuses the same Agent object for every WebSocket session with the same agent_id. If concurrent turns share that agent, one turn can overwrite the controller another turn is checkpointing against, breaking cooperative cancellation for that agent while only the hard task cancellation remains reliable. Use per-turn cloning or pass the controller through explicit per-turn state instead of mutating shared agent state.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai-bot/praisonai_bot/gateway/server.py` around lines 3077 - 3100,
Update _dispatch_agent_turn to avoid assigning the per-turn interrupt controller
to the shared agent.interrupt_controller. Use a per-turn agent clone or explicit
per-turn state so concurrent sessions retain independent controllers, while
preserving native async dispatch and the synchronous agent.chat executor
fallback.
Source: Coding guidelines
| async def _drive_turn( | ||
| self, | ||
| session: GatewaySession, | ||
| agent: Any, | ||
| content: str, | ||
| controller: Any, | ||
| timeout: float, | ||
| ) -> Any: | ||
| """Run one agent turn cancellably and under an optional per-turn timeout. | ||
|
|
||
| Registers the driving task in ``_active_turns`` so ``_abort_active_turn`` | ||
| can interrupt it, then awaits it with ``asyncio.wait_for`` when a | ||
| positive ``timeout`` is configured. A cancelled or timed-out turn is | ||
| normalised to a terminal string rather than left hanging or surfaced as | ||
| a raw traceback. | ||
| """ | ||
| sid = session.session_id | ||
| task = asyncio.ensure_future( | ||
| self._dispatch_agent_turn(agent, content, interrupt=controller) | ||
| ) | ||
| self._active_turns[sid] = (task, controller) | ||
| try: | ||
| if timeout and timeout > 0: | ||
| try: | ||
| return await asyncio.wait_for(task, timeout=timeout) | ||
| except asyncio.TimeoutError: | ||
| controller.request("timeout") | ||
| task.cancel() | ||
| try: | ||
| await task | ||
| except (asyncio.CancelledError, Exception): | ||
| pass | ||
| return self._finalise_aborted_turn(controller, "timeout") | ||
| return await task | ||
| except asyncio.CancelledError: | ||
| reason = controller.reason or "user" | ||
| return self._finalise_aborted_turn(controller, reason) | ||
| finally: | ||
| existing = self._active_turns.get(sid) | ||
| if existing is not None and existing[0] is task: | ||
| self._active_turns.pop(sid, None) | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does InterruptController in praisonaiagents.agent.interrupt expose an is_set() method?
💡 Result:
Yes, the InterruptController in praisonaiagents.agent.interrupt utilizes an is_set method, typically accessed via its internal cancellation attributes [1]. According to official PraisonAI documentation, the InterruptController is used for cooperative cancellation [1]. When passed as a cancel_token to agent methods like chat, the agent checks for an interrupt signal at various checkpoints [1]. Documentation explicitly notes that when this token's is_set returns true, an InterruptedError is raised [1]. While the exact implementation details can vary across versions, common patterns in this framework involve the InterruptController managing an internal state (often utilizing threading.Event or similar mechanisms) where is_set is used to verify if an interruption has been requested [1][2][3].
Citations:
- 1: https://docs.praison.ai/docs/features/core-controls
- 2: https://github.com/nrl-ai/edgevox/blob/main/docs/documentation/interrupt.md
- 3: https://github.com/nrl-ai/edgevox/blob/main/tests/harness/test_interrupt.py
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate files =="
git ls-files | rg 'src/praisonai-bot/praisonai_bot/gateway/server.py|InterruptController|interrupt' | head -100
echo
echo "== server excerpt =="
sed -n '2980,3190p' src/praisonai-bot/praisonai_bot/gateway/server.py
echo
echo "== interrupt-related usages in server =="
rg -n "_drive_turn|_finalise_aborted_turn|_active_turns|_abort_active_turn|InterruptController|controller\.is_set|controller\.request|reason or \"user\"" src/praisonai-bot/praisonai_bot/gateway/server.py
echo
echo "== InterruptController definitions/usages in repo and installed package if available =="
rg -n "class InterruptController|def is_set|is_set\\(" . -g '*.py' | head -200
python3 - <<'PY'
try:
import praisonaiagents.agent.interrupt as mod
print("package imported ok")
for name in dir(mod):
if "Interrupt" in name:
cls = getattr(mod, name)
print(name, type(cls), hasattr(cls, "is_set"))
except Exception as e:
print("import failed:", type(e).__name__, e)
PYRepository: MervinPraison/PraisonAI
Length of output: 15971
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== server queue excerpt =="
sed -n '3188,3230p' src/praisonai-bot/praisonai_bot/gateway/server.py
echo
echo "== InterruptController implementation =="
sed -n '1,115p' src/praisonai-agents/praisonaiagents/agent/interrupt.py
echo
echo "== InterruptController tests =="
sed -n '1,135p' src/praisonai-agents/tests/unit/agent/test_interrupt.py
echo
echo "== asyncio cancellation behavior probe for await CancelledError catch =="
python3 - <<'PY'
import asyncio
async def cancels_await():
for i in range(3):
await a_task()
print("loop", i)
async def a_task():
raise asyncio.CancelledError("example external cancellation")
try:
asyncio.get_event_loop().run_until_complete(cancels_await())
except asyncio.CancelledError as e:
print("external cancellation propagated:", e.args)
else:
print("external cancellation swallowed")
PYRepository: MervinPraison/PraisonAI
Length of output: 10013
Propagate external cancellations in _drive_turn
At lines 3161-3162, the current except asyncio.CancelledError catches CancelledError from the whole _drive_turn coroutine and returns "Turn cancelled: user." instead of propagating an external cancellation. Since controller.is_set() is only true after _abort_active_turn/timeout explicitly requests interruption, re-raise CancelledError when the controller is not set so the enclosing queue task can actually stop as intended.
🧰 Tools
🪛 Ruff (0.15.21)
[error] 3156-3157: try-except-pass detected, consider logging the exception
(S110)
[warning] 3156-3156: Do not catch blind exception: Exception
(BLE001)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai-bot/praisonai_bot/gateway/server.py` around lines 3126 - 3167,
Update the asyncio.CancelledError handler in _drive_turn to distinguish external
cancellation from requested turn interruption: if controller.is_set() is false,
re-raise the cancellation; otherwise preserve the existing
_finalise_aborted_turn behavior using the controller reason.
Address three P1 gaps in the gateway cancellation/timeout work: - Timeout was unreachable: add per_turn_timeout to the bot GatewayServerSchema (extra="forbid" was rejecting it) and stamp it onto self.config in start_with_config so the documented YAML knob actually takes effect. - Turn isolation: pass the InterruptController per-turn as the entry point's cancel_token instead of mutating the shared agent.interrupt_controller, so overlapping sessions on one Agent instance no longer interrupt each other. Legacy agents without a cancel_token kwarg fall back to the shared attribute. - Cancellation honesty: interrupt cooperatively first and wait a bounded grace window (_ABORT_GRACE_SECONDS) for the turn — including a sync agent.chat in a worker thread that cannot be force-killed — to unwind before the serial session queue advances; hard task.cancel() only as a fallback. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
| try: | ||
| return await fn(content, **_kw) | ||
| except TypeError: | ||
| if not _kw: | ||
| raise | ||
| # Entry point predates cancel_token: fall back to the shared | ||
| # attribute for this turn (best-effort, non-isolated). | ||
| if hasattr(agent, "interrupt_controller"): | ||
| agent.interrupt_controller = interrupt | ||
| return await fn(content) |
There was a problem hiding this comment.
TypeError fallback duplicates turns
When an agent entry point accepts cancel_token but raises TypeError after beginning provider or tool execution, this handler treats the runtime error as an unsupported argument, assigns the controller to the shared agent, and invokes the entire turn again. This can repeat tool side effects and restore cross-session cancellation interference.
Knowledge Base Used: praisonai-bot
Fixes #3467
Summary
The gateway had no way to stop an agent turn already running, and no per-turn timeout — a single slow tool call or doom-looping agent wedged the serial per-session queue indefinitely, with no recourse short of restarting the process. Core already supplied the contract (
EventType.MESSAGE_ABORT) and the primitive (InterruptController); this wires them into the gateway.Changes
abortin the hello capability handshake (methods: ["message","leave","abort"]+message_abortevent), replacing the# abort not implementedplaceholder.abort/message_abortWS frame branch (WRITE scope) and a portable/stop(orstop) chat command. Both call_abort_active_turn, which drives the existingInterruptControllerand cancels the drivingasyncio.Task._drive_turn, registered in a new_active_turnsregistry and awaited underasyncio.wait_for(...)whengateway.per_turn_timeout > 0. Cancelled/timed-out turns are normalised to a typed terminal message instead of hanging or surfacing a raw traceback.gateway.per_turn_timeout(float seconds,0= disabled) to coreGatewayConfig— dataclass field, validation,from_dict, andto_dict.Reuses
agent.interrupt_controller(already checked by the agent run loop); no new agent params, keeping the surface lightweight.Layer placement
praisonai_botgateway server): abort handling,/stop,_active_turns,_drive_turn, timeout.praisonaiagents): only theper_turn_timeoutconfig field — protocol/config surface, no heavy implementation.Testing
tests/**/test_gateway*).per_turn_timeout) and the abort path (/stop), confirming the_active_turnsregistry is cleaned up.Generated with Claude Code
Summary by CodeRabbit
New Features
/stopor supported abort messages.Bug Fixes