mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-06 23:07:56 +08:00
cli/state: cancel a compact's summary instead of hanging on it
This commit is contained in:
@@ -128,7 +128,7 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down; the handler is rebound to whichever loop owns the session, since asyncio drops it with its loop); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop (a slash command's own work is a task too — `/compact` cancels its summary request, `/btw` its side turn — and `handle_slash` answers that `CancelledError` with one `cancelled` line, so the REPL survives; before this, nothing was registered as a target during a slash command and the router dropped the signal); auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per node, a `pipe` chain joined into one line with ` | `, a `then` / `else` body indented (two spaces per level) under the node it follows, each line elided past 80 characters.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
@@ -315,7 +315,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/sessions` | Sessions for this workspace |
|
||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||
| `/compact` | Soft-compact + model summary into a **new** session |
|
||||
| `/compact` | Soft-compact + model summary into a **new** session (Ctrl+C drops the summary request, and no session is created) |
|
||||
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
|
||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||
|
||||
|
||||
+24
-10
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -1007,16 +1008,29 @@ class ReplState:
|
||||
hint = self.agent.last_request_usage.prompt_tokens
|
||||
# Approximate: calibrate against current full history char-est.
|
||||
sent_est = estimate_messages_tokens(messages)
|
||||
summary = await summarize_messages(
|
||||
self.client,
|
||||
messages,
|
||||
model=self.model,
|
||||
max_context_tokens=self.agent.max_context_tokens,
|
||||
prompt_tokens_hint=hint,
|
||||
sent_estimate_tokens=sent_est,
|
||||
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
||||
)
|
||||
# The summary is the only long wait in here, and nothing has been
|
||||
# created yet, so it is the safe place to be interrupted: Ctrl+C drops
|
||||
# the request and reports back (see the CancelledError below). Everything
|
||||
# after it is a couple of DB writes that build the new session, and a
|
||||
# cancel that landed there would leave it half-seeded.
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
|
||||
try:
|
||||
summary = await run_cancellable(
|
||||
summarize_messages(
|
||||
self.client,
|
||||
messages,
|
||||
model=self.model,
|
||||
max_context_tokens=self.agent.max_context_tokens,
|
||||
prompt_tokens_hint=hint,
|
||||
sent_estimate_tokens=sent_est,
|
||||
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
msg = "compaction cancelled (nothing changed)"
|
||||
raise ValueError(msg) from None
|
||||
carried_todos = self.todo_stack.to_raw()
|
||||
session_name = name or f"compact-from-{old_id}"
|
||||
await self.new_session(name=session_name)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import pytest
|
||||
@@ -57,6 +58,31 @@ class SummaryClient:
|
||||
)
|
||||
|
||||
|
||||
class HangingClient:
|
||||
"""A summarizer that never answers until the request is cancelled."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.entered = asyncio.Event()
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del param, stream
|
||||
self.entered.set()
|
||||
await asyncio.Event().wait() # until the caller cancels
|
||||
raise AssertionError("the summary request should have been cancelled")
|
||||
|
||||
|
||||
async def test_compact_to_new_session(tmp_path: Path) -> None:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
@@ -128,6 +154,50 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_compact_cancel_leaves_the_session_untouched(tmp_path: Path) -> None:
|
||||
"""Ctrl+C during the summary drops the request before anything is created."""
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||
config.providers = {"local": provider}
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=False,
|
||||
)
|
||||
await state.new_session("orig")
|
||||
old_id = state.session_id
|
||||
assert old_id is not None
|
||||
client = HangingClient()
|
||||
state.client = client
|
||||
state.agent = ChatAgent(
|
||||
state.client,
|
||||
model=state.model,
|
||||
memory=state.memory,
|
||||
session_id=old_id,
|
||||
system_prompt="Be brief.",
|
||||
)
|
||||
state.agent.messages = [UserChatMessage(content="do work")]
|
||||
before = {row.sid for row in await memory.list_sessions(workspace=tmp_path)}
|
||||
|
||||
task = asyncio.ensure_future(state.compact_to_new_session())
|
||||
await asyncio.wait_for(client.entered.wait(), timeout=5)
|
||||
_ = task.cancel()
|
||||
with pytest.raises(ValueError, match="compaction cancelled"):
|
||||
await task
|
||||
|
||||
assert state.session_id == old_id
|
||||
after = {row.sid for row in await memory.list_sessions(workspace=tmp_path)}
|
||||
assert after == before
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user