mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +08:00
core/cli: level-1 cancel of in-flight turns via Ctrl+C
Run turns as asyncio tasks; SIGINT cancels the task; agent rolls back and keeps pending_retry_text for /retry.
This commit is contained in:
@@ -78,6 +78,10 @@ class ChatAgent:
|
||||
if self.memory is not None and self.session_id is not None:
|
||||
_ = await self.memory.append_message(self.session_id, message)
|
||||
|
||||
def _rollback_turn(self, pre_len: int, user_text: str) -> None:
|
||||
del self.messages[pre_len:]
|
||||
self.pending_retry_text = user_text
|
||||
|
||||
async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]:
|
||||
"""Run the tool loop for an already-appended user message; persist only on success."""
|
||||
pre_len = len(self.messages) - 1
|
||||
@@ -85,6 +89,7 @@ class ChatAgent:
|
||||
pre_len = len(self.messages)
|
||||
self.messages.append(user_msg)
|
||||
|
||||
completed = False
|
||||
try:
|
||||
async for event in run_chat_loop(
|
||||
self.client,
|
||||
@@ -96,10 +101,11 @@ class ChatAgent:
|
||||
on_limit=self.on_limit,
|
||||
):
|
||||
yield event
|
||||
except Exception:
|
||||
# Roll back the whole turn so a failed user message is not left half-applied.
|
||||
del self.messages[pre_len:]
|
||||
self.pending_retry_text = user_msg.content
|
||||
completed = True
|
||||
except BaseException:
|
||||
# Includes CancelledError / KeyboardInterrupt paths via task cancel.
|
||||
if not completed:
|
||||
self._rollback_turn(pre_len, user_msg.content)
|
||||
raise
|
||||
|
||||
for message in self.messages[pre_len:]:
|
||||
|
||||
@@ -37,8 +37,9 @@ Commands:
|
||||
/rounds [n] Show or set max tool-loop rounds
|
||||
/retry Retry the last failed user turn (after errors)
|
||||
|
||||
On network/API errors, the user turn is not saved to the DB. Auto-retry
|
||||
waits 10s, 20s, then 30s (Ctrl+C cancels waits; use /retry later).
|
||||
On network/API errors or Ctrl+C during a turn, the turn is not saved to
|
||||
the DB. Auto-retry waits 10s, 20s, then 30s (Ctrl+C cancels waits or the
|
||||
in-flight turn; use /retry later).
|
||||
|
||||
Tab completes slash commands and some arguments (provider, model, tools).
|
||||
Use --session ID or /resume to continue a prior chat after restart.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -9,7 +11,7 @@ import click
|
||||
from plyngent.cli.display import render_events
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
|
||||
from plyngent.agent import AgentEvent
|
||||
from plyngent.agent.chat import ChatAgent
|
||||
@@ -34,6 +36,43 @@ async def sleep_cancellable(seconds: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
||||
"""Await ``coro`` as a task; Ctrl+C / SIGINT cancels the task.
|
||||
|
||||
Raises:
|
||||
asyncio.CancelledError: If the task was cancelled (including via SIGINT).
|
||||
"""
|
||||
task = asyncio.create_task(coro)
|
||||
loop = asyncio.get_running_loop()
|
||||
installed = False
|
||||
|
||||
def _on_sigint() -> None:
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
||||
installed = True
|
||||
except (NotImplementedError, RuntimeError, ValueError):
|
||||
installed = False
|
||||
|
||||
try:
|
||||
return await task
|
||||
except KeyboardInterrupt:
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
raise asyncio.CancelledError from None
|
||||
finally:
|
||||
if installed:
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def run_turn_with_retries(
|
||||
agent: ChatAgent,
|
||||
*,
|
||||
@@ -44,15 +83,27 @@ async def run_turn_with_retries(
|
||||
|
||||
``starter`` produces the event stream for the current attempt (``agent.run``
|
||||
or ``agent.retry``). Returns True if the turn completed successfully.
|
||||
|
||||
Ctrl+C during a model/tool turn cancels the in-flight task (level-1 cancel).
|
||||
The agent rolls back the turn and keeps ``pending_retry_text`` for ``/retry``.
|
||||
"""
|
||||
max_retries = len(delays)
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
await render_events(starter())
|
||||
await run_cancellable(render_events(starter()))
|
||||
except asyncio.CancelledError:
|
||||
click.echo()
|
||||
click.secho(
|
||||
"cancelled (turn not saved); use /retry to try again",
|
||||
fg="yellow",
|
||||
)
|
||||
click.echo()
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
click.echo()
|
||||
click.secho("interrupted", fg="yellow")
|
||||
click.echo()
|
||||
return False
|
||||
except Exception as exc: # noqa: BLE001 — surface and optionally retry
|
||||
click.secho(f"error: {exc}", fg="red")
|
||||
|
||||
Reference in New Issue
Block a user