2026-07-14 20:56:08 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-07-14 21:02:54 +08:00
|
|
|
import contextlib
|
|
|
|
|
import signal
|
2026-07-14 20:56:08 +08:00
|
|
|
import time
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
|
|
|
|
|
from plyngent.cli.display import render_events
|
2026-07-15 00:00:40 +08:00
|
|
|
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
|
2026-07-14 20:56:08 +08:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
2026-07-14 21:02:54 +08:00
|
|
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
2026-07-14 20:56:08 +08:00
|
|
|
|
|
|
|
|
from plyngent.agent import AgentEvent
|
|
|
|
|
from plyngent.agent.chat import ChatAgent
|
|
|
|
|
|
2026-07-17 15:36:29 +08:00
|
|
|
# Auto-retry budget after the first failure (10 attempts by default).
|
|
|
|
|
DEFAULT_MAX_AUTO_RETRIES = 10
|
|
|
|
|
# First four waits; each further wait is previous + 10s.
|
|
|
|
|
_RETRY_BASE_DELAYS_SECONDS: tuple[float, ...] = (5.0, 10.0, 15.0, 20.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def default_retry_delays(max_retries: int = DEFAULT_MAX_AUTO_RETRIES) -> tuple[float, ...]:
|
|
|
|
|
"""Build wait times: 5, 10, 15, 20, then +10s each step, length *max_retries*."""
|
|
|
|
|
if max_retries <= 0:
|
|
|
|
|
return ()
|
|
|
|
|
delays: list[float] = list(_RETRY_BASE_DELAYS_SECONDS)
|
|
|
|
|
while len(delays) < max_retries:
|
|
|
|
|
delays.append(delays[-1] + 10.0)
|
|
|
|
|
return tuple(delays[:max_retries])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = default_retry_delays()
|
2026-07-14 20:56:08 +08:00
|
|
|
_PREVIEW_LEN = 80
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def sleep_cancellable(seconds: float) -> bool:
|
|
|
|
|
"""Sleep in short steps so Ctrl+C can cancel. Returns False if interrupted."""
|
|
|
|
|
deadline = time.monotonic() + seconds
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
remaining = deadline - time.monotonic()
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
return True
|
|
|
|
|
await asyncio.sleep(min(0.5, remaining))
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
return False
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 21:02:54 +08:00
|
|
|
async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
|
|
|
|
"""Await ``coro`` as a task; Ctrl+C / SIGINT cancels the task.
|
|
|
|
|
|
2026-07-15 00:00:40 +08:00
|
|
|
Interactive prompts (max-rounds / destructive confirm) temporarily disable
|
|
|
|
|
task cancel so the user can answer instead of aborting the whole turn.
|
|
|
|
|
|
2026-07-14 21:02:54 +08:00
|
|
|
Raises:
|
|
|
|
|
asyncio.CancelledError: If the task was cancelled (including via SIGINT).
|
|
|
|
|
"""
|
2026-07-15 00:00:40 +08:00
|
|
|
task: asyncio.Task[T] = asyncio.create_task(coro)
|
2026-07-14 21:02:54 +08:00
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
installed = False
|
|
|
|
|
|
|
|
|
|
def _on_sigint() -> None:
|
2026-07-18 19:31:40 +08:00
|
|
|
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
|
|
|
|
|
# so this remains correct even if the handler was installed under a
|
|
|
|
|
# frozen context (asyncio signal handles capture contextvars).
|
2026-07-15 00:00:40 +08:00
|
|
|
if allow_task_cancel() and not task.done():
|
2026-07-14 21:02:54 +08:00
|
|
|
_ = task.cancel()
|
|
|
|
|
|
2026-07-15 00:00:40 +08:00
|
|
|
def _reinstall() -> None:
|
|
|
|
|
try:
|
|
|
|
|
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
2026-07-15 11:36:01 +08:00
|
|
|
except NotImplementedError, RuntimeError, ValueError:
|
2026-07-15 00:00:40 +08:00
|
|
|
return
|
|
|
|
|
|
2026-07-14 21:02:54 +08:00
|
|
|
try:
|
|
|
|
|
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
|
|
|
|
installed = True
|
2026-07-15 00:00:40 +08:00
|
|
|
set_sigint_reinstall(_reinstall)
|
2026-07-15 11:36:01 +08:00
|
|
|
except NotImplementedError, RuntimeError, ValueError:
|
2026-07-14 21:02:54 +08:00
|
|
|
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:
|
2026-07-15 00:00:40 +08:00
|
|
|
set_sigint_reinstall(None)
|
2026-07-14 21:02:54 +08:00
|
|
|
if installed:
|
2026-07-15 09:41:32 +08:00
|
|
|
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
|
|
|
|
_ = loop.remove_signal_handler(signal.SIGINT)
|
|
|
|
|
# Only cancel if still running (e.g. KeyboardInterrupt path above).
|
|
|
|
|
# Do not cancel a finished task — that would mask success.
|
2026-07-14 21:02:54 +08:00
|
|
|
if not task.done():
|
|
|
|
|
_ = task.cancel()
|
|
|
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
|
|
|
await task
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 10:48:07 +08:00
|
|
|
def _echo_turn_usage(agent: ChatAgent) -> None:
|
2026-07-15 11:04:41 +08:00
|
|
|
if agent.last_turn_usage.is_zero() and agent.last_request_usage.is_zero():
|
|
|
|
|
return
|
|
|
|
|
rounds = agent.last_turn_rounds
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
if not agent.last_request_usage.is_zero():
|
2026-07-15 11:07:52 +08:00
|
|
|
# prompt_tokens on the last call ≈ context the model just saw
|
|
|
|
|
req = agent.last_request_usage
|
|
|
|
|
parts.append(
|
|
|
|
|
f"context={req.prompt_tokens} "
|
|
|
|
|
f"(prompt+completion={req.prompt_tokens}+{req.completion_tokens}"
|
|
|
|
|
f"={req.total_tokens}"
|
|
|
|
|
f"{' est' if req.source == 'estimate' else ''})"
|
|
|
|
|
)
|
|
|
|
|
if not agent.last_turn_usage.is_zero() and (
|
|
|
|
|
rounds > 1 or agent.last_turn_usage.total_tokens != agent.last_request_usage.total_tokens
|
|
|
|
|
):
|
2026-07-15 11:04:41 +08:00
|
|
|
label = agent.last_turn_usage.format_line(billed=True)
|
|
|
|
|
if rounds > 1:
|
|
|
|
|
parts.append(f"turn {label} over {rounds} rounds")
|
|
|
|
|
else:
|
|
|
|
|
parts.append(f"turn {label}")
|
|
|
|
|
click.secho(f"[{'; '.join(parts)}]", fg="bright_black")
|
2026-07-15 10:48:07 +08:00
|
|
|
|
|
|
|
|
|
2026-08-01 01:45:10 +08:00
|
|
|
def _echo_cancel_lines(*lines: str) -> None:
|
|
|
|
|
"""Print post-cancel lines, treating a further Ctrl+C as benign.
|
|
|
|
|
|
|
|
|
|
After a turn-cancel the asyncio SIGINT handler is removed, so a second
|
|
|
|
|
Ctrl+C during this output would otherwise raise KeyboardInterrupt and
|
|
|
|
|
exit the REPL instead of returning to the prompt.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
for line in lines:
|
|
|
|
|
if line:
|
|
|
|
|
click.secho(line, fg="yellow")
|
|
|
|
|
else:
|
|
|
|
|
click.echo()
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 10:48:07 +08:00
|
|
|
async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool:
|
|
|
|
|
try:
|
2026-08-01 01:45:10 +08:00
|
|
|
click.secho(
|
|
|
|
|
f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)",
|
|
|
|
|
fg="yellow",
|
|
|
|
|
)
|
2026-07-15 10:48:07 +08:00
|
|
|
ok = await sleep_cancellable(wait)
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
ok = False
|
|
|
|
|
if not ok:
|
2026-08-01 01:45:10 +08:00
|
|
|
_echo_cancel_lines("auto-retry cancelled; use /retry to try again", "")
|
2026-07-15 10:48:07 +08:00
|
|
|
return False
|
2026-08-01 01:45:10 +08:00
|
|
|
_echo_cancel_lines(f"retrying ({attempt}/{max_retries})…")
|
2026-07-15 10:48:07 +08:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 20:56:08 +08:00
|
|
|
async def run_turn_with_retries(
|
|
|
|
|
agent: ChatAgent,
|
|
|
|
|
*,
|
|
|
|
|
starter: Callable[[], AsyncIterator[AgentEvent]],
|
|
|
|
|
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Run a chat turn with automatic retries on failure.
|
|
|
|
|
|
2026-07-15 11:21:01 +08:00
|
|
|
``starter`` produces the event stream for the first attempt (usually
|
2026-07-15 11:27:29 +08:00
|
|
|
``agent.run``). After a failure (history ends with the user message),
|
|
|
|
|
further attempts use ``agent.retry`` so the user message is not duplicated.
|
2026-07-14 21:02:54 +08:00
|
|
|
|
2026-07-15 11:21:01 +08:00
|
|
|
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
|
2026-07-14 20:56:08 +08:00
|
|
|
"""
|
|
|
|
|
max_retries = len(delays)
|
|
|
|
|
attempt = 0
|
2026-07-15 11:21:01 +08:00
|
|
|
current: Callable[[], AsyncIterator[AgentEvent]] = starter
|
2026-07-14 20:56:08 +08:00
|
|
|
while True:
|
|
|
|
|
try:
|
2026-07-15 11:21:01 +08:00
|
|
|
await run_cancellable(render_events(current()))
|
2026-07-14 21:02:54 +08:00
|
|
|
except asyncio.CancelledError:
|
2026-07-14 22:22:14 +08:00
|
|
|
# Do not auto-retry cancelled turns — user intent was stop, not retry.
|
2026-08-01 01:45:10 +08:00
|
|
|
_echo_cancel_lines("", "cancelled; user message kept — use /retry to try again", "")
|
2026-07-14 21:02:54 +08:00
|
|
|
return False
|
2026-07-14 20:56:08 +08:00
|
|
|
except KeyboardInterrupt:
|
2026-08-01 01:45:10 +08:00
|
|
|
_echo_cancel_lines("", "interrupted", "")
|
2026-07-14 20:56:08 +08:00
|
|
|
return False
|
|
|
|
|
except Exception as exc: # noqa: BLE001 — surface and optionally retry
|
|
|
|
|
click.secho(f"error: {exc}", fg="red")
|
2026-07-15 11:21:01 +08:00
|
|
|
if agent.pending_retry_text is not None:
|
|
|
|
|
current = agent.retry
|
2026-07-14 20:56:08 +08:00
|
|
|
if attempt >= max_retries:
|
|
|
|
|
if agent.pending_retry_text is not None:
|
|
|
|
|
click.secho(
|
|
|
|
|
"auto-retry exhausted; use /retry to try again, or send a new message",
|
|
|
|
|
fg="yellow",
|
|
|
|
|
)
|
|
|
|
|
click.echo()
|
|
|
|
|
return False
|
|
|
|
|
wait = delays[attempt]
|
|
|
|
|
attempt += 1
|
2026-07-15 10:48:07 +08:00
|
|
|
if not await _wait_for_retry(attempt, max_retries, wait):
|
2026-07-14 20:56:08 +08:00
|
|
|
return False
|
|
|
|
|
else:
|
2026-07-15 10:48:07 +08:00
|
|
|
_echo_turn_usage(agent)
|
2026-07-14 20:56:08 +08:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 13:33:18 +08:00
|
|
|
async def run_user_text_with_retries(
|
|
|
|
|
agent: ChatAgent,
|
|
|
|
|
text: str,
|
|
|
|
|
*,
|
|
|
|
|
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Send a new user message with auto-retry (empty ``delays`` = no auto-retry)."""
|
|
|
|
|
return await run_turn_with_retries(agent, starter=lambda: agent.run(text), delays=delays)
|
2026-07-14 20:56:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def retry_pending_with_retries(agent: ChatAgent) -> bool:
|
2026-07-15 15:01:15 +08:00
|
|
|
"""Retry/continue an incomplete turn with auto-retry.
|
|
|
|
|
|
|
|
|
|
Continues from committed tool results when present (does not re-run them).
|
|
|
|
|
"""
|
2026-07-14 20:56:08 +08:00
|
|
|
if agent.pending_retry_text is None:
|
|
|
|
|
click.echo("nothing to retry")
|
|
|
|
|
return False
|
|
|
|
|
preview = agent.pending_retry_text
|
|
|
|
|
if len(preview) > _PREVIEW_LEN:
|
|
|
|
|
preview = preview[:_PREVIEW_LEN] + "…"
|
2026-07-15 15:01:15 +08:00
|
|
|
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
|
|
|
|
|
|
|
|
|
if agent.messages and isinstance(agent.messages[-1], ToolChatMessage):
|
|
|
|
|
click.echo(f"continuing after tools (user: {preview})")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"retrying: {preview}")
|
2026-07-14 20:56:08 +08:00
|
|
|
return await run_turn_with_retries(agent, starter=agent.retry)
|