core/cli+agent: /compact and resume latest by workspace recency

Soft-compact history, model-summarize without tools, seed a new session;
/resume with no id loads the lately-used session for this workspace.
This commit is contained in:
2026-07-15 10:03:22 +08:00
parent 19128735a2
commit 001619d9dd
8 changed files with 452 additions and 10 deletions
+3 -2
View File
@@ -56,6 +56,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. - **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request; cooperative cancel points; optional `on_limit`. - **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request; cooperative cancel points; optional `on_limit`.
- **`ChatAgent`**: optional `MemoryStore` (persist on success only); `stream`; system prompt; `pending_retry_text` + `retry()`. - **`ChatAgent`**: optional `MemoryStore` (persist on success only); `stream`; system prompt; `pending_retry_text` + `retry()`.
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`). - Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`).
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_chars`. - Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_chars`.
@@ -75,8 +76,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes latest **for cwd/`--workspace`** by default (`--new` / `--session`). - **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`).
- Slash: `/history`, `/sessions`, `/resume`, `/rounds`, `/retry`, … - Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/compact`, `/rounds`, `/retry`, …
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**. - Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
- Failed/cancelled turns: not written to DB; Ctrl+C cancels the in-flight turn task; **TTY confirms** (max-rounds / destructive tools) run off-loop via `asyncio.to_thread` + pause cancel so prompts do not abort the turn; auto-retry 10s/20s/30s; `/retry` manual. - Failed/cancelled turns: not written to DB; Ctrl+C cancels the in-flight turn task; **TTY confirms** (max-rounds / destructive tools) run off-loop via `asyncio.to_thread` + pause cancel so prompts do not abort the turn; auto-retry 10s/20s/30s; `/retry` manual.
- **`plyngent providers`**: list config providers. - **`plyngent providers`**: list config providers.
+2
View File
@@ -1,5 +1,7 @@
from .chat import ChatAgent as ChatAgent from .chat import ChatAgent as ChatAgent
from .client import ChatClient as ChatClient from .client import ChatClient as ChatClient
from .compact import build_compacted_seed_messages as build_compacted_seed_messages
from .compact import summarize_messages as summarize_messages
from .events import AgentEvent as AgentEvent from .events import AgentEvent as AgentEvent
from .events import AssistantMessageEvent as AssistantMessageEvent from .events import AssistantMessageEvent as AssistantMessageEvent
from .events import CancelledEvent as CancelledEvent from .events import CancelledEvent as CancelledEvent
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionsParam,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
from .budget import DEFAULT_CONTEXT_MAX_CHARS, compact_messages_for_request
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .client import ChatClient
_SUMMARY_SYSTEM = (
"You compress chat histories for a coding agent. "
"Write a dense, factual summary that preserves: goals, decisions, "
"file paths touched, commands run, open tasks, and constraints. "
"Omit chit-chat and redundant tool dumps. Use short bullet sections. "
"Do not invent facts not present in the transcript."
)
_SUMMARY_USER_PREFIX = (
"Summarize the following conversation for continued agent work. "
"Output only the summary (no preamble).\n\n--- transcript ---\n"
)
def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
"""Render messages as plain text for a summarization prompt."""
lines: list[str] = []
for msg in messages:
if isinstance(msg, SystemChatMessage):
lines.append(f"[system] {msg.content}")
elif isinstance(msg, UserChatMessage):
lines.append(f"[user] {msg.content}")
elif isinstance(msg, AssistantChatMessage):
content = msg.content if isinstance(msg.content, str) else ""
if content:
lines.append(f"[assistant] {content}")
tool_calls = msg.tool_calls
if tool_calls is not UNSET and tool_calls:
for call in tool_calls:
if isinstance(call, AssistantFunctionToolCall):
lines.append(
f"[assistant tool_call] {call.function.name}({call.function.arguments})"
)
else:
lines.append(f"[assistant tool_call] custom id={call.id}")
elif isinstance(msg, ToolChatMessage):
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
else:
lines.append(f"[message] {msg!r}")
return "\n".join(lines)
def soft_compact_transcript(
messages: Sequence[AnyChatMessage],
*,
max_chars: int = DEFAULT_CONTEXT_MAX_CHARS,
) -> str:
"""Soft-compact tool dumps then format as transcript text."""
compacted = compact_messages_for_request(messages, max_chars=max_chars)
return format_transcript(compacted)
async def summarize_messages(
client: ChatClient,
messages: Sequence[AnyChatMessage],
*,
model: str,
max_context_chars: int = DEFAULT_CONTEXT_MAX_CHARS,
temperature: float | None = 0.2,
) -> str:
"""Soft-compact history and ask the model for a dense summary (no tools)."""
if not messages:
msg = "nothing to compact"
raise ValueError(msg)
transcript = soft_compact_transcript(messages, max_chars=max_context_chars)
if not transcript.strip():
msg = "nothing to compact"
raise ValueError(msg)
param = ChatCompletionsParam(
messages=[
SystemChatMessage(content=_SUMMARY_SYSTEM),
UserChatMessage(content=_SUMMARY_USER_PREFIX + transcript),
],
model=model,
temperature=temperature if temperature is not None else UNSET,
)
response = await client.chat_completions(param, stream=False)
if not response.choices:
msg = "summarization response contained no choices"
raise RuntimeError(msg)
content = response.choices[0].message.content
if not isinstance(content, str) or not content.strip():
msg = "summarization returned empty content"
raise RuntimeError(msg)
return content.strip()
def build_compacted_seed_messages(
summary: str,
*,
system_prompt: str | None = None,
source_session_id: int | None = None,
) -> list[AnyChatMessage]:
"""Messages to seed a new session after compact."""
out: list[AnyChatMessage] = []
if system_prompt:
out.append(SystemChatMessage(content=system_prompt))
src = f"session {source_session_id}" if source_session_id is not None else "prior session"
body = (
f"Conversation summary (compacted from {src}):\n\n"
f"{summary}\n\n"
"Continue from this summary. Prefer not to re-ask for information already covered."
)
out.append(UserChatMessage(content=body))
return out
+1
View File
@@ -24,6 +24,7 @@ SLASH_COMMANDS: tuple[str, ...] = (
"/sessions", "/sessions",
"/new", "/new",
"/resume", "/resume",
"/compact",
"/provider", "/provider",
"/model", "/model",
"/tools", "/tools",
+29 -4
View File
@@ -28,9 +28,10 @@ Commands:
/quit, /exit Leave the REPL /quit, /exit Leave the REPL
/clear Clear in-memory conversation (keeps session id) /clear Clear in-memory conversation (keeps session id)
/history [n] Show last n messages in this session (default 20) /history [n] Show last n messages in this session (default 20)
/sessions List sessions for this workspace /sessions List sessions for this workspace (newest first)
/new [name] Start a new session (bound to workspace) /new [name] Start a new session (bound to workspace)
/resume <id> Resume a session by id (prompts if workspace differs) /resume [id] Resume session id, or latest for this workspace if omitted
/compact [name] Soft-compact + model-summarize into a new session
/provider [name] Show or switch provider /provider [name] Show or switch provider
/model [id] Show or switch model /model [id] Show or switch model
/tools [on|off] Show or toggle tools /tools [on|off] Show or toggle tools
@@ -86,12 +87,19 @@ async def _cmd_new(state: ReplState, arg: str) -> None:
async def _cmd_resume(state: ReplState, arg: str) -> None: async def _cmd_resume(state: ReplState, arg: str) -> None:
if not arg.strip(): if not arg.strip():
click.echo("usage: /resume <session_id>") mode = await state.resume_latest_or_new()
if mode == "new":
click.echo(f"no prior session; created new session {state.session_id}")
else:
click.echo(
f"resumed latest session {state.session_id} "
f"({len(state.agent.messages)} messages) workspace={state.workspace}"
)
return return
try: try:
session_id = int(arg.strip()) session_id = int(arg.strip())
except ValueError: except ValueError:
click.echo("session id must be an integer") click.echo("session id must be an integer (or omit for latest)")
return return
try: try:
await state.resume_session(session_id) await state.resume_session(session_id)
@@ -104,6 +112,22 @@ async def _cmd_resume(state: ReplState, arg: str) -> None:
) )
async def _cmd_compact(state: ReplState, arg: str) -> None:
name = arg.strip() or None
click.secho("compacting (soft-compact + model summary)…", fg="yellow")
try:
old_id, new_id, summary = await state.compact_to_new_session(name=name)
except ValueError as exc:
click.echo(f"error: {exc}")
return
except Exception as exc: # noqa: BLE001 — surface model/API failures
click.secho(f"error: compact failed: {exc}", fg="red")
return
preview = summary if len(summary) <= 400 else summary[:400] + "" # noqa: PLR2004
click.echo(f"compacted session {old_id} -> new session {new_id}")
click.secho(preview, fg="bright_black")
def _cmd_provider(state: ReplState, arg: str) -> None: def _cmd_provider(state: ReplState, arg: str) -> None:
if not arg.strip(): if not arg.strip():
click.echo(f"provider={state.provider_name}") click.echo(f"provider={state.provider_name}")
@@ -245,6 +269,7 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
"sessions": lambda: _cmd_sessions(state), "sessions": lambda: _cmd_sessions(state),
"new": lambda: _cmd_new(state, arg), "new": lambda: _cmd_new(state, arg),
"resume": lambda: _cmd_resume(state, arg), "resume": lambda: _cmd_resume(state, arg),
"compact": lambda: _cmd_compact(state, arg),
"provider": lambda: _cmd_provider(state, arg), "provider": lambda: _cmd_provider(state, arg),
"model": lambda: _cmd_model(state, arg), "model": lambda: _cmd_model(state, arg),
"tools": lambda: _cmd_tools(state, arg), "tools": lambda: _cmd_tools(state, arg),
+44 -4
View File
@@ -142,14 +142,54 @@ class ReplState:
await self.agent.load_history() await self.agent.load_history()
async def resume_latest_or_new(self, name: str = "chat") -> str: async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume latest session for this workspace, or create one if none exist.""" """Resume most recently updated session for this workspace, or create one."""
sessions = await self.memory.list_sessions(workspace=self.workspace) latest = await self.memory.get_latest_session(workspace=self.workspace)
if not sessions: if latest is None:
await self.new_session(name=name) await self.new_session(name=name)
return "new" return "new"
latest = max(sessions, key=lambda s: (s.updated_at, s.sid))
# Same-workspace list: no mismatch prompt expected. # Same-workspace list: no mismatch prompt expected.
self.session_id = latest.sid self.session_id = latest.sid
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
_ = await self.memory.touch_session(latest.sid)
return "resume" return "resume"
async def compact_to_new_session(self, *, name: str | None = None) -> tuple[int, int, str]:
"""Soft-compact + model-summarize current history into a new workspace session.
Returns ``(old_session_id, new_session_id, summary)``.
"""
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
old_id = self.session_id
if old_id is None:
msg = "no active session to compact"
raise ValueError(msg)
messages = list(self.agent.messages)
if len(messages) < 1:
msg = "nothing to compact (empty history)"
raise ValueError(msg)
summary = await summarize_messages(
self.client,
messages,
model=self.model,
max_context_chars=self.agent.max_context_chars,
)
session_name = name or f"compact-from-{old_id}"
await self.new_session(name=session_name)
new_id = self.session_id
if new_id is None:
msg = "failed to create compact session"
raise RuntimeError(msg)
seed = build_compacted_seed_messages(
summary,
system_prompt=self.agent.system_prompt,
source_session_id=old_id,
)
self.agent.messages = list(seed)
for message in seed:
_ = await self.memory.append_message(new_id, message)
self.agent.pending_retry_text = None
return old_id, new_id, summary
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from plyngent.agent.compact import (
build_compacted_seed_messages,
format_transcript,
soft_compact_transcript,
summarize_messages,
)
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def test_format_transcript() -> None:
text = format_transcript(
[
UserChatMessage(content="hi"),
AssistantChatMessage(content="yo"),
ToolChatMessage(content="out", tool_call_id="1"),
]
)
assert "[user] hi" in text
assert "[assistant] yo" in text
assert "[tool 1] out" in text
def test_soft_compact_transcript_shrinks_tools() -> None:
big = "Z" * 2000
messages = [
UserChatMessage(content="u"),
ToolChatMessage(content=big, tool_call_id="1"),
ToolChatMessage(content="recent", tool_call_id="2"),
]
out = soft_compact_transcript(messages, max_chars=500)
assert "truncated" in out or len(out) < len(big) + 50
assert "recent" in out
def test_build_compacted_seed_messages() -> None:
seed = build_compacted_seed_messages("summary text", system_prompt="sys", source_session_id=3)
assert isinstance(seed[0], SystemChatMessage)
assert seed[0].content == "sys"
assert isinstance(seed[1], UserChatMessage)
assert "summary text" in seed[1].content
assert "session 3" in seed[1].content
class SummaryClient:
last: ChatCompletionsParam | None
def __init__(self) -> None:
self.last = None
@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 stream
self.last = param
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="t",
choices=[
ChatCompletionChoice(
index=0,
message=AssistantChatMessage(content=" done summary "),
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
async def test_summarize_messages() -> None:
client = SummaryClient()
summary = await summarize_messages(
client,
[UserChatMessage(content="hello"), AssistantChatMessage(content="world")],
model="m",
)
assert summary == "done summary"
assert client.last is not None
assert client.last.model == "m"
from msgspec import UNSET
assert client.last.tools is UNSET
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import pytest
import tomlkit
from plyngent.agent import ChatAgent
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools import set_workspace_root
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
class SummaryClient:
@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
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=AssistantChatMessage(content="compacted goals: ship feature"),
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
async def test_compact_to_new_session(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
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,
)
state.client = SummaryClient()
await state.new_session("orig")
old_id = state.session_id
assert old_id is not None
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"),
AssistantChatMessage(content="done lots of stuff"),
]
for msg in state.agent.messages:
_ = await memory.append_message(old_id, msg)
new_old, new_id, summary = await state.compact_to_new_session()
assert new_old == old_id
assert new_id != old_id
assert state.session_id == new_id
assert "compacted goals" in summary
loaded = await memory.list_messages(new_id)
assert any("compacted goals" in getattr(m, "content", "") for m in loaded)
# Old session still exists and is listable
sessions = await memory.list_sessions(workspace=tmp_path)
ids = {s.sid for s in sessions}
assert old_id in ids
assert new_id in ids
finally:
await memory.close()
async def test_compact_empty_fails(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
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,
)
state.client = SummaryClient()
await state.new_session("empty")
state.agent.messages = []
with pytest.raises(ValueError, match="nothing to compact"):
_ = await state.compact_to_new_session()
finally:
await memory.close()